feat: pantry intel cluster — #61 expiry display, #64 cook log, #66 scaling, #59 open-package tracking
#61: expiry badge now shows relative + calendar date ("5d · Apr 15") with tooltip "Expires in 5 days (Apr 15)"; traffic-light colors already in place #64: RecipeDetailPanel.handleCook() calls recipesStore.logCook(); SavedRecipesPanel shows "Last made: X ago" below each card using cookLog entries #66: Serving multiplier (1x/2x/3x/4x) in RecipeDetailPanel scales ingredient quantities using regex; handles integers, decimals, fractions (1/2, 3/4), mixed numbers (1 1/2), and ranges (2-3); leaves unrecognised strings unchanged #59: migration 030 adds opened_date column; ExpirationPredictor gains SHELF_LIFE_AFTER_OPENING table + days_after_opening(); POST /inventory/items/{id}/open sets opened_date=today and returns computed opened_expiry_date; InventoryList shows lock-open button for unopened items and an "📂 5d · Apr 15" badge once opened
This commit is contained in:
parent
4423373750
commit
64a0abebe3
9 changed files with 316 additions and 14 deletions
|
|
@ -13,6 +13,9 @@ from pydantic import BaseModel
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
|
_predictor = ExpirationPredictor()
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
BarcodeScanResponse,
|
BarcodeScanResponse,
|
||||||
|
|
@ -33,6 +36,25 @@ from app.models.schemas.inventory import (
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _enrich_item(item: dict) -> dict:
|
||||||
|
"""Attach computed opened_expiry_date when opened_date is set."""
|
||||||
|
from datetime import date, timedelta
|
||||||
|
opened = item.get("opened_date")
|
||||||
|
if opened:
|
||||||
|
days = _predictor.days_after_opening(item.get("category"))
|
||||||
|
if days is not None:
|
||||||
|
try:
|
||||||
|
opened_expiry = date.fromisoformat(opened) + timedelta(days=days)
|
||||||
|
item = {**item, "opened_expiry_date": str(opened_expiry)}
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if "opened_expiry_date" not in item:
|
||||||
|
item = {**item, "opened_expiry_date": None}
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
# ── Products ──────────────────────────────────────────────────────────────────
|
# ── Products ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/products", response_model=ProductResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
|
@ -168,13 +190,13 @@ async def list_inventory_items(
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
):
|
):
|
||||||
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
items = await asyncio.to_thread(store.list_inventory, location, item_status)
|
||||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
@router.get("/items/expiring", response_model=List[InventoryItemResponse])
|
||||||
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)):
|
||||||
items = await asyncio.to_thread(store.expiring_soon, days)
|
items = await asyncio.to_thread(store.expiring_soon, days)
|
||||||
return [InventoryItemResponse.model_validate(i) for i in items]
|
return [InventoryItemResponse.model_validate(_enrich_item(i)) for i in items]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.get("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -182,7 +204,7 @@ async def get_inventory_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
item = await asyncio.to_thread(store.get_inventory_item, item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(item)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
@router.patch("/items/{item_id}", response_model=InventoryItemResponse)
|
||||||
|
|
@ -194,10 +216,26 @@ async def update_inventory_item(
|
||||||
updates["purchase_date"] = str(updates["purchase_date"])
|
updates["purchase_date"] = str(updates["purchase_date"])
|
||||||
if "expiration_date" in updates and updates["expiration_date"]:
|
if "expiration_date" in updates and updates["expiration_date"]:
|
||||||
updates["expiration_date"] = str(updates["expiration_date"])
|
updates["expiration_date"] = str(updates["expiration_date"])
|
||||||
|
if "opened_date" in updates and updates["opened_date"]:
|
||||||
|
updates["opened_date"] = str(updates["opened_date"])
|
||||||
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
item = await asyncio.to_thread(store.update_inventory_item, item_id, **updates)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(item)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/open", response_model=InventoryItemResponse)
|
||||||
|
async def mark_item_opened(item_id: int, store: Store = Depends(get_store)):
|
||||||
|
"""Record that this item was opened today, triggering secondary shelf-life tracking."""
|
||||||
|
from datetime import date
|
||||||
|
item = await asyncio.to_thread(
|
||||||
|
store.update_inventory_item,
|
||||||
|
item_id,
|
||||||
|
opened_date=str(date.today()),
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
@router.post("/items/{item_id}/consume", response_model=InventoryItemResponse)
|
||||||
|
|
@ -211,7 +249,7 @@ async def consume_item(item_id: int, store: Store = Depends(get_store)):
|
||||||
)
|
)
|
||||||
if not item:
|
if not item:
|
||||||
raise HTTPException(status_code=404, detail="Inventory item not found")
|
raise HTTPException(status_code=404, detail="Inventory item not found")
|
||||||
return InventoryItemResponse.model_validate(item)
|
return InventoryItemResponse.model_validate(_enrich_item(item))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
|
||||||
5
app/db/migrations/030_opened_date.sql
Normal file
5
app/db/migrations/030_opened_date.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- Migration 030: open-package tracking
|
||||||
|
-- Adds opened_date to track when a multi-use item was first opened,
|
||||||
|
-- enabling secondary shelf-life windows (e.g. salsa: 1 year sealed → 2 weeks opened).
|
||||||
|
|
||||||
|
ALTER TABLE inventory_items ADD COLUMN opened_date TEXT;
|
||||||
|
|
@ -218,7 +218,7 @@ class Store:
|
||||||
|
|
||||||
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None:
|
||||||
allowed = {"quantity", "unit", "location", "sublocation",
|
allowed = {"quantity", "unit", "location", "sublocation",
|
||||||
"expiration_date", "status", "notes", "consumed_at"}
|
"expiration_date", "opened_date", "status", "notes", "consumed_at"}
|
||||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||||
if not updates:
|
if not updates:
|
||||||
return self.get_inventory_item(item_id)
|
return self.get_inventory_item(item_id)
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ class InventoryItemUpdate(BaseModel):
|
||||||
location: Optional[str] = None
|
location: Optional[str] = None
|
||||||
sublocation: Optional[str] = None
|
sublocation: Optional[str] = None
|
||||||
expiration_date: Optional[date] = None
|
expiration_date: Optional[date] = None
|
||||||
|
opened_date: Optional[date] = None
|
||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
@ -106,6 +107,8 @@ class InventoryItemResponse(BaseModel):
|
||||||
sublocation: Optional[str]
|
sublocation: Optional[str]
|
||||||
purchase_date: Optional[str]
|
purchase_date: Optional[str]
|
||||||
expiration_date: Optional[str]
|
expiration_date: Optional[str]
|
||||||
|
opened_date: Optional[str] = None
|
||||||
|
opened_expiry_date: Optional[str] = None
|
||||||
status: str
|
status: str
|
||||||
notes: Optional[str]
|
notes: Optional[str]
|
||||||
source: str
|
source: str
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,53 @@ class ExpirationPredictor:
|
||||||
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
'prepared_foods': {'fridge': 4, 'freezer': 90},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Secondary shelf life in days after a package is opened.
|
||||||
|
# Sources: USDA FoodKeeper app, FDA consumer guides.
|
||||||
|
# Only categories where opening significantly shortens shelf life are listed.
|
||||||
|
# Items not listed default to None (no secondary window tracked).
|
||||||
|
SHELF_LIFE_AFTER_OPENING: dict[str, int] = {
|
||||||
|
# Dairy — once opened, clock ticks fast
|
||||||
|
'dairy': 5,
|
||||||
|
'milk': 5,
|
||||||
|
'cream': 3,
|
||||||
|
'yogurt': 7,
|
||||||
|
'cheese': 14,
|
||||||
|
'butter': 30,
|
||||||
|
# Condiments — refrigerated after opening
|
||||||
|
'condiments': 30,
|
||||||
|
'ketchup': 30,
|
||||||
|
'mustard': 30,
|
||||||
|
'mayo': 14,
|
||||||
|
'salad_dressing': 30,
|
||||||
|
'soy_sauce': 90,
|
||||||
|
# Canned goods — once opened, very short
|
||||||
|
'canned_goods': 4,
|
||||||
|
# Beverages
|
||||||
|
'juice': 7,
|
||||||
|
'soda': 4,
|
||||||
|
# Bread / Bakery
|
||||||
|
'bread': 5,
|
||||||
|
'bakery': 3,
|
||||||
|
# Produce
|
||||||
|
'leafy_greens': 3,
|
||||||
|
'berries': 3,
|
||||||
|
# Pantry staples (open bag)
|
||||||
|
'chips': 14,
|
||||||
|
'cookies': 14,
|
||||||
|
'cereal': 30,
|
||||||
|
'flour': 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
def days_after_opening(self, category: str | None) -> int | None:
|
||||||
|
"""Return days of shelf life remaining once a package is opened.
|
||||||
|
|
||||||
|
Returns None if the category is unknown or not tracked after opening
|
||||||
|
(e.g. frozen items, raw meat — category check irrelevant once opened).
|
||||||
|
"""
|
||||||
|
if not category:
|
||||||
|
return None
|
||||||
|
return self.SHELF_LIFE_AFTER_OPENING.get(category.lower())
|
||||||
|
|
||||||
# Keyword lists are checked in declaration order — most specific first.
|
# Keyword lists are checked in declaration order — most specific first.
|
||||||
# Rules:
|
# Rules:
|
||||||
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
# - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken)
|
||||||
|
|
|
||||||
|
|
@ -323,9 +323,16 @@
|
||||||
<div class="inv-row-right">
|
<div class="inv-row-right">
|
||||||
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
|
||||||
|
|
||||||
|
<!-- Opened expiry takes priority over sell-by date -->
|
||||||
<span
|
<span
|
||||||
v-if="item.expiration_date"
|
v-if="item.opened_expiry_date"
|
||||||
|
:class="['expiry-badge', 'expiry-opened', getExpiryBadgeClass(item.opened_expiry_date)]"
|
||||||
|
:title="`Opened · ${formatDateFull(item.opened_expiry_date)}`"
|
||||||
|
>📂 {{ formatDateShort(item.opened_expiry_date) }}</span>
|
||||||
|
<span
|
||||||
|
v-else-if="item.expiration_date"
|
||||||
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
|
:class="['expiry-badge', getExpiryBadgeClass(item.expiration_date)]"
|
||||||
|
:title="formatDateFull(item.expiration_date)"
|
||||||
>{{ formatDateShort(item.expiration_date) }}</span>
|
>{{ formatDateShort(item.expiration_date) }}</span>
|
||||||
|
|
||||||
<div class="inv-actions">
|
<div class="inv-actions">
|
||||||
|
|
@ -334,6 +341,19 @@
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
|
<path d="M13.586 3.586a2 2 0 112.828 2.828L7 14.828 4 16l1.172-3L13.586 3.586z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="!item.opened_date && item.status === 'available'"
|
||||||
|
@click="markAsOpened(item)"
|
||||||
|
class="btn-icon btn-icon-open"
|
||||||
|
aria-label="Mark as opened today"
|
||||||
|
title="I opened this today"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||||
|
<path d="M5 8V6a7 7 0 0114 0v2"/>
|
||||||
|
<rect x="3" y="8" width="14" height="10" rx="2"/>
|
||||||
|
<circle cx="10" cy="13" r="1.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
|
<button @click="markAsConsumed(item)" class="btn-icon btn-icon-success" aria-label="Mark consumed">
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="16" height="16">
|
||||||
<polyline points="4 10 8 14 16 6"/>
|
<polyline points="4 10 8 14 16 6"/>
|
||||||
|
|
@ -555,6 +575,16 @@ async function confirmDelete(item: InventoryItem) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markAsOpened(item: InventoryItem) {
|
||||||
|
try {
|
||||||
|
await inventoryAPI.openItem(item.id)
|
||||||
|
await refreshItems()
|
||||||
|
showToast(`${item.product_name || 'Item'} marked as opened — tracking freshness`, 'info')
|
||||||
|
} catch {
|
||||||
|
showToast('Could not mark item as opened', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function markAsConsumed(item: InventoryItem) {
|
async function markAsConsumed(item: InventoryItem) {
|
||||||
showConfirm(
|
showConfirm(
|
||||||
`Mark ${item.product_name || 'item'} as consumed?`,
|
`Mark ${item.product_name || 'item'} as consumed?`,
|
||||||
|
|
@ -721,20 +751,35 @@ function exportExcel() {
|
||||||
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Short date for compact row display
|
// Full date string for tooltip (accessible label)
|
||||||
function formatDateShort(dateStr: string): string {
|
function formatDateFull(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
today.setHours(0, 0, 0, 0)
|
today.setHours(0, 0, 0, 0)
|
||||||
const expiry = new Date(dateStr)
|
const expiry = new Date(dateStr)
|
||||||
expiry.setHours(0, 0, 0, 0)
|
expiry.setHours(0, 0, 0, 0)
|
||||||
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
const cal = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
if (diffDays < 0) return `Expired ${cal}`
|
||||||
|
if (diffDays === 0) return `Expires today (${cal})`
|
||||||
|
if (diffDays === 1) return `Expires tomorrow (${cal})`
|
||||||
|
return `Expires in ${diffDays} days (${cal})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Short date for compact row display
|
||||||
|
function formatDateShort(dateStr: string): string {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const expiry = new Date(dateStr)
|
||||||
|
expiry.setHours(0, 0, 0, 0)
|
||||||
|
const diffDays = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||||
|
const cal = new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
|
||||||
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
|
if (diffDays < 0) return `${Math.abs(diffDays)}d ago`
|
||||||
if (diffDays === 0) return 'today'
|
if (diffDays === 0) return 'today'
|
||||||
if (diffDays === 1) return 'tmrw'
|
if (diffDays === 1) return `tmrw · ${cal}`
|
||||||
if (diffDays <= 14) return `${diffDays}d`
|
if (diffDays <= 14) return `${diffDays}d · ${cal}`
|
||||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
return cal
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExpiryBadgeClass(expiryStr: string): string {
|
function getExpiryBadgeClass(expiryStr: string): string {
|
||||||
|
|
@ -1147,6 +1192,20 @@ function getItemClass(item: InventoryItem): string {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "I opened this today" button */
|
||||||
|
.btn-icon-open {
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-open:hover {
|
||||||
|
background: var(--color-warning-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Opened badge — distinct icon prefix signals this is after-open expiry */
|
||||||
|
.expiry-opened {
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Action icons inline */
|
/* Action icons inline */
|
||||||
.inv-actions {
|
.inv-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,20 @@
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="detail-body">
|
<div class="detail-body">
|
||||||
|
|
||||||
|
<!-- Serving multiplier -->
|
||||||
|
<div class="serving-scale-row">
|
||||||
|
<span class="serving-scale-label text-sm text-muted">Scale:</span>
|
||||||
|
<div class="serving-scale-btns" role="group" aria-label="Serving multiplier">
|
||||||
|
<button
|
||||||
|
v-for="n in [1, 2, 3, 4]"
|
||||||
|
:key="n"
|
||||||
|
:class="['scale-btn', { active: servingScale === n }]"
|
||||||
|
:aria-pressed="servingScale === n"
|
||||||
|
@click="servingScale = n"
|
||||||
|
>{{ n }}×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ingredients: have vs. need in a two-column layout -->
|
<!-- Ingredients: have vs. need in a two-column layout -->
|
||||||
<div class="ingredients-grid">
|
<div class="ingredients-grid">
|
||||||
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
|
||||||
|
|
@ -43,7 +57,7 @@
|
||||||
<ul class="ingredient-list">
|
<ul class="ingredient-list">
|
||||||
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
|
||||||
<span class="ing-icon ing-icon-have">✓</span>
|
<span class="ing-icon ing-icon-have">✓</span>
|
||||||
<span>{{ ing }}</span>
|
<span>{{ scaleIngredient(ing, servingScale) }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,7 +80,7 @@
|
||||||
:checked="checkedIngredients.has(ing)"
|
:checked="checkedIngredients.has(ing)"
|
||||||
@change="toggleIngredient(ing)"
|
@change="toggleIngredient(ing)"
|
||||||
/>
|
/>
|
||||||
<span class="ing-name">{{ ing }}</span>
|
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
|
||||||
</label>
|
</label>
|
||||||
<a
|
<a
|
||||||
v-if="groceryLinkFor(ing)"
|
v-if="groceryLinkFor(ing)"
|
||||||
|
|
@ -248,6 +262,69 @@ const isSaved = computed(() => savedStore.isSaved(props.recipe.id))
|
||||||
const cookDone = ref(false)
|
const cookDone = ref(false)
|
||||||
const shareCopied = ref(false)
|
const shareCopied = ref(false)
|
||||||
|
|
||||||
|
// Serving scale multiplier: 1×, 2×, 3×, 4×
|
||||||
|
const servingScale = ref(1)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scale a freeform ingredient string by a multiplier.
|
||||||
|
* Handles integers, decimals, and simple fractions (1/2, 1/4, 3/4, etc.).
|
||||||
|
* Ranges like "2-3" are scaled on both ends.
|
||||||
|
* Returns the original string unchanged if no leading number is found.
|
||||||
|
*/
|
||||||
|
function scaleIngredient(ing: string, scale: number): string {
|
||||||
|
if (scale === 1) return ing
|
||||||
|
|
||||||
|
// Match an optional leading fraction OR decimal OR integer,
|
||||||
|
// optionally followed by a space and another fraction (mixed number like "1 1/2")
|
||||||
|
const numPat = String.raw`(\d+\s+\d+\/\d+|\d+\/\d+|\d+(?:\.\d+)?)`
|
||||||
|
const rangePat = new RegExp(`^${numPat}(?:\\s*-\\s*${numPat})?`)
|
||||||
|
|
||||||
|
const m = ing.match(rangePat)
|
||||||
|
if (!m) return ing
|
||||||
|
|
||||||
|
function parseFrac(s: string): number {
|
||||||
|
const mixed = s.match(/^(\d+)\s+(\d+)\/(\d+)$/)
|
||||||
|
if (mixed) return parseInt(mixed[1]) + parseInt(mixed[2]) / parseInt(mixed[3])
|
||||||
|
const frac = s.match(/^(\d+)\/(\d+)$/)
|
||||||
|
if (frac) return parseInt(frac[1]) / parseInt(frac[2])
|
||||||
|
return parseFloat(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNum(n: number): string {
|
||||||
|
// Try to express as a simple fraction for common baking values
|
||||||
|
const fracs: [number, string][] = [
|
||||||
|
[0.125, '1/8'], [0.25, '1/4'], [0.333, '1/3'], [0.5, '1/2'],
|
||||||
|
[0.667, '2/3'], [0.75, '3/4'],
|
||||||
|
]
|
||||||
|
for (const [val, str] of fracs) {
|
||||||
|
if (Math.abs(n - Math.round(n / val) * val) < 0.01 && n < 1) return str
|
||||||
|
}
|
||||||
|
// Mixed numbers
|
||||||
|
const whole = Math.floor(n)
|
||||||
|
const remainder = n - whole
|
||||||
|
if (whole > 0 && remainder > 0.05) {
|
||||||
|
for (const [val, str] of fracs) {
|
||||||
|
if (Math.abs(remainder - val) < 0.05) return `${whole} ${str}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Round to reasonable precision
|
||||||
|
return whole > 0 && remainder < 0.05 ? `${whole}` : n.toFixed(1).replace(/\.0$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
const low = parseFrac(m[1])
|
||||||
|
const scaledLow = fmtNum(low * scale)
|
||||||
|
|
||||||
|
let scaled: string
|
||||||
|
if (m[2] !== undefined) {
|
||||||
|
const high = parseFrac(m[2])
|
||||||
|
scaled = `${scaledLow}-${fmtNum(high * scale)}`
|
||||||
|
} else {
|
||||||
|
scaled = scaledLow
|
||||||
|
}
|
||||||
|
|
||||||
|
return scaled + ing.slice(m[0].length)
|
||||||
|
}
|
||||||
|
|
||||||
// Shopping: add purchased ingredients to pantry
|
// Shopping: add purchased ingredients to pantry
|
||||||
const checkedIngredients = ref<Set<string>>(new Set())
|
const checkedIngredients = ref<Set<string>>(new Set())
|
||||||
const addingToPantry = ref(false)
|
const addingToPantry = ref(false)
|
||||||
|
|
@ -327,6 +404,7 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCook() {
|
function handleCook() {
|
||||||
|
recipesStore.logCook(props.recipe.id, props.recipe.title)
|
||||||
cookDone.value = true
|
cookDone.value = true
|
||||||
emit('cooked', props.recipe)
|
emit('cooked', props.recipe)
|
||||||
}
|
}
|
||||||
|
|
@ -445,6 +523,40 @@ function handleCook() {
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Serving scale row ──────────────────────────────────── */
|
||||||
|
.serving-scale-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
margin-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.serving-scale-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.serving-scale-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-btn {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-btn.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-on-primary, #fff);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Ingredients grid ───────────────────────────────────── */
|
/* ── Ingredients grid ───────────────────────────────────── */
|
||||||
.ingredients-grid {
|
.ingredients-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Last cooked hint -->
|
||||||
|
<div v-if="lastCookedLabel(recipe.recipe_id)" class="last-cooked-hint text-xs text-muted mt-xs">
|
||||||
|
{{ lastCookedLabel(recipe.recipe_id) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes preview with expand/collapse -->
|
<!-- Notes preview with expand/collapse -->
|
||||||
<div v-if="recipe.notes" class="mt-xs">
|
<div v-if="recipe.notes" class="mt-xs">
|
||||||
<div
|
<div
|
||||||
|
|
@ -146,6 +151,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
|
import { useRecipesStore } from '../stores/recipes'
|
||||||
import type { SavedRecipe } from '../services/api'
|
import type { SavedRecipe } from '../services/api'
|
||||||
import SaveRecipeModal from './SaveRecipeModal.vue'
|
import SaveRecipeModal from './SaveRecipeModal.vue'
|
||||||
|
|
||||||
|
|
@ -155,7 +161,24 @@ const emit = defineEmits<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const store = useSavedRecipesStore()
|
const store = useSavedRecipesStore()
|
||||||
|
const recipesStore = useRecipesStore()
|
||||||
const editingRecipe = ref<SavedRecipe | null>(null)
|
const editingRecipe = ref<SavedRecipe | null>(null)
|
||||||
|
|
||||||
|
function lastCookedLabel(recipeId: number): string | null {
|
||||||
|
const entries = recipesStore.cookLog.filter((e) => e.id === recipeId)
|
||||||
|
if (entries.length === 0) return null
|
||||||
|
const latestMs = Math.max(...entries.map((e) => e.cookedAt))
|
||||||
|
const diffMs = Date.now() - latestMs
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||||
|
if (diffDays === 0) return 'Last made: today'
|
||||||
|
if (diffDays === 1) return 'Last made: yesterday'
|
||||||
|
if (diffDays < 7) return `Last made: ${diffDays} days ago`
|
||||||
|
if (diffDays < 14) return 'Last made: 1 week ago'
|
||||||
|
const diffWeeks = Math.floor(diffDays / 7)
|
||||||
|
if (diffDays < 60) return `Last made: ${diffWeeks} weeks ago`
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
return `Last made: ${diffMonths} month${diffMonths !== 1 ? 's' : ''} ago`
|
||||||
|
}
|
||||||
const showNewCollection = ref(false)
|
const showNewCollection = ref(false)
|
||||||
|
|
||||||
// #44: two-step remove confirmation
|
// #44: two-step remove confirmation
|
||||||
|
|
@ -340,6 +363,11 @@ async function createCollection() {
|
||||||
padding: var(--spacing-xl);
|
padding: var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.last-cooked-hint {
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,8 @@ export interface InventoryItem {
|
||||||
sublocation: string | null
|
sublocation: string | null
|
||||||
purchase_date: string | null
|
purchase_date: string | null
|
||||||
expiration_date: string | null
|
expiration_date: string | null
|
||||||
|
opened_date: string | null
|
||||||
|
opened_expiry_date: string | null
|
||||||
status: string
|
status: string
|
||||||
source: string
|
source: string
|
||||||
notes: string | null
|
notes: string | null
|
||||||
|
|
@ -239,6 +241,14 @@ export const inventoryAPI = {
|
||||||
await api.post(`/inventory/items/${itemId}/consume`)
|
await api.post(`/inventory/items/${itemId}/consume`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark item as opened today — starts secondary shelf-life tracking
|
||||||
|
*/
|
||||||
|
async openItem(itemId: number): Promise<InventoryItem> {
|
||||||
|
const response = await api.post(`/inventory/items/${itemId}/open`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new product
|
* Create a new product
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue