diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index ac15c69..482868a 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -13,6 +13,9 @@ from pydantic import BaseModel from app.cloud_session import CloudUser, get_session from app.db.session import get_store +from app.services.expiration_predictor import ExpirationPredictor + +_predictor = ExpirationPredictor() from app.db.store import Store from app.models.schemas.inventory import ( BarcodeScanResponse, @@ -33,6 +36,25 @@ from app.models.schemas.inventory import ( 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 ────────────────────────────────────────────────────────────────── @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), ): 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]) async def get_expiring_items(days: int = 7, store: Store = Depends(get_store)): 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) @@ -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) if not item: 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) @@ -194,10 +216,26 @@ async def update_inventory_item( updates["purchase_date"] = str(updates["purchase_date"]) if "expiration_date" in updates and 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) if not item: 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) @@ -211,7 +249,7 @@ async def consume_item(item_id: int, store: Store = Depends(get_store)): ) if not item: 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) diff --git a/app/db/migrations/030_opened_date.sql b/app/db/migrations/030_opened_date.sql new file mode 100644 index 0000000..76083a2 --- /dev/null +++ b/app/db/migrations/030_opened_date.sql @@ -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; diff --git a/app/db/store.py b/app/db/store.py index de838c0..1a30f38 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -218,7 +218,7 @@ class Store: def update_inventory_item(self, item_id: int, **kwargs) -> dict[str, Any] | None: 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} if not updates: return self.get_inventory_item(item_id) diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index 57a3caf..9e6ccbf 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -90,6 +90,7 @@ class InventoryItemUpdate(BaseModel): location: Optional[str] = None sublocation: Optional[str] = None expiration_date: Optional[date] = None + opened_date: Optional[date] = None status: Optional[str] = None notes: Optional[str] = None @@ -106,6 +107,8 @@ class InventoryItemResponse(BaseModel): sublocation: Optional[str] purchase_date: Optional[str] expiration_date: Optional[str] + opened_date: Optional[str] = None + opened_expiry_date: Optional[str] = None status: str notes: Optional[str] source: str diff --git a/app/services/expiration_predictor.py b/app/services/expiration_predictor.py index 22eca01..7fab4da 100644 --- a/app/services/expiration_predictor.py +++ b/app/services/expiration_predictor.py @@ -116,6 +116,53 @@ class ExpirationPredictor: '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. # Rules: # - canned/processed goods BEFORE raw-meat terms (canned chicken != raw chicken) diff --git a/frontend/src/components/InventoryList.vue b/frontend/src/components/InventoryList.vue index 83f5718..cb2f7b0 100644 --- a/frontend/src/components/InventoryList.vue +++ b/frontend/src/components/InventoryList.vue @@ -323,9 +323,16 @@
{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }} + 📂 {{ formatDateShort(item.opened_expiry_date) }} + {{ formatDateShort(item.expiration_date) }}
@@ -334,6 +341,19 @@ + +
+
+
@@ -43,7 +57,7 @@
@@ -66,7 +80,7 @@ :checked="checkedIngredients.has(ing)" @change="toggleIngredient(ing)" /> - {{ ing }} + {{ scaleIngredient(ing, servingScale) }} savedStore.isSaved(props.recipe.id)) const cookDone = 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 const checkedIngredients = ref>(new Set()) const addingToPantry = ref(false) @@ -327,6 +404,7 @@ function groceryLinkFor(ingredient: string): GroceryLink | undefined { } function handleCook() { + recipesStore.logCook(props.recipe.id, props.recipe.title) cookDone.value = true emit('cooked', props.recipe) } @@ -445,6 +523,40 @@ function handleCook() { -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 { display: grid; diff --git a/frontend/src/components/SavedRecipesPanel.vue b/frontend/src/components/SavedRecipesPanel.vue index a242788..3928be3 100644 --- a/frontend/src/components/SavedRecipesPanel.vue +++ b/frontend/src/components/SavedRecipesPanel.vue @@ -79,6 +79,11 @@ >{{ tag }}
+ +
+ {{ lastCookedLabel(recipe.recipe_id) }} +
+
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { useSavedRecipesStore } from '../stores/savedRecipes' +import { useRecipesStore } from '../stores/recipes' import type { SavedRecipe } from '../services/api' import SaveRecipeModal from './SaveRecipeModal.vue' @@ -155,7 +161,24 @@ const emit = defineEmits<{ }>() const store = useSavedRecipesStore() +const recipesStore = useRecipesStore() const editingRecipe = ref(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) // #44: two-step remove confirmation @@ -340,6 +363,11 @@ async function createCollection() { padding: var(--spacing-xl); } +.last-cooked-hint { + font-style: italic; + opacity: 0.75; +} + .modal-overlay { position: fixed; inset: 0; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 622077f..a946dcd 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -91,6 +91,8 @@ export interface InventoryItem { sublocation: string | null purchase_date: string | null expiration_date: string | null + opened_date: string | null + opened_expiry_date: string | null status: string source: string notes: string | null @@ -239,6 +241,14 @@ export const inventoryAPI = { await api.post(`/inventory/items/${itemId}/consume`) }, + /** + * Mark item as opened today — starts secondary shelf-life tracking + */ + async openItem(itemId: number): Promise { + const response = await api.post(`/inventory/items/${itemId}/open`) + return response.data + }, + /** * Create a new product */