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 @@
+