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:
pyr0ball 2026-04-16 06:01:25 -07:00
parent 4423373750
commit 64a0abebe3
9 changed files with 316 additions and 14 deletions

View file

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

View 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;

View file

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

View file

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

View file

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

View file

@ -323,9 +323,16 @@
<div class="inv-row-right">
<span class="inv-qty">{{ formatQuantity(item.quantity, item.unit, settingsStore.unitSystem) }}</span>
<!-- Opened expiry takes priority over sell-by date -->
<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)]"
:title="formatDateFull(item.expiration_date)"
>{{ formatDateShort(item.expiration_date) }}</span>
<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"/>
</svg>
</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">
<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"/>
@ -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) {
showConfirm(
`Mark ${item.product_name || 'item'} as consumed?`,
@ -721,20 +751,35 @@ function exportExcel() {
window.open(`${apiUrl}/export/inventory/excel`, '_blank')
}
// Short date for compact row display
function formatDateShort(dateStr: string): string {
// Full date string for tooltip (accessible label)
function formatDateFull(dateStr: string): string {
const date = new Date(dateStr)
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 = 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 'today'
if (diffDays === 1) return 'tmrw'
if (diffDays <= 14) return `${diffDays}d`
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
if (diffDays === 1) return `tmrw · ${cal}`
if (diffDays <= 14) return `${diffDays}d · ${cal}`
return cal
}
function getExpiryBadgeClass(expiryStr: string): string {
@ -1147,6 +1192,20 @@ function getItemClass(item: InventoryItem): string {
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 */
.inv-actions {
display: flex;

View file

@ -36,6 +36,20 @@
<!-- Scrollable 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 -->
<div class="ingredients-grid">
<div v-if="recipe.matched_ingredients?.length > 0" class="ingredient-col ingredient-col-have">
@ -43,7 +57,7 @@
<ul class="ingredient-list">
<li v-for="ing in recipe.matched_ingredients" :key="ing" class="ing-row">
<span class="ing-icon ing-icon-have"></span>
<span>{{ ing }}</span>
<span>{{ scaleIngredient(ing, servingScale) }}</span>
</li>
</ul>
</div>
@ -66,7 +80,7 @@
:checked="checkedIngredients.has(ing)"
@change="toggleIngredient(ing)"
/>
<span class="ing-name">{{ ing }}</span>
<span class="ing-name">{{ scaleIngredient(ing, servingScale) }}</span>
</label>
<a
v-if="groceryLinkFor(ing)"
@ -248,6 +262,69 @@ const isSaved = computed(() => 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<Set<string>>(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;

View file

@ -79,6 +79,11 @@
>{{ tag }}</span>
</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 -->
<div v-if="recipe.notes" class="mt-xs">
<div
@ -146,6 +151,7 @@
<script setup lang="ts">
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<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)
// #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;

View file

@ -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<InventoryItem> {
const response = await api.post(`/inventory/items/${itemId}/open`)
return response.data
},
/**
* Create a new product
*/