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.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)
|
||||
|
|
|
|||
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:
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue