kiwi/frontend/src/components/SavedRecipesPanel.vue
pyr0ball 64a0abebe3 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
2026-04-16 06:01:25 -07:00

386 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="saved-panel">
<!-- Empty state -->
<div v-if="!store.loading && store.saved.length === 0" class="empty-state card text-center">
<p class="text-secondary">No saved recipes yet.</p>
<div class="flex gap-sm mt-sm" style="justify-content: center;">
<button class="btn btn-secondary btn-sm" @click="$emit('go-to-tab', 'find')">
Find recipes for my pantry
</button>
<button class="btn btn-secondary btn-sm" @click="$emit('go-to-tab', 'browse')">
Browse recipes
</button>
</div>
</div>
<template v-else>
<!-- Controls -->
<div class="saved-controls flex-between flex-wrap gap-sm mb-md">
<div class="flex gap-sm flex-wrap">
<!-- Collection filter -->
<label class="sr-only" for="collection-filter">Filter by collection</label>
<select id="collection-filter" class="form-input sort-select" v-model="activeCollectionId" @change="reload">
<option :value="null">All saved</option>
<option v-for="col in store.collections" :key="col.id" :value="col.id">
{{ col.name }} ({{ col.member_count }})
</option>
</select>
<!-- Sort -->
<label class="sr-only" for="sort-order">Sort by</label>
<select id="sort-order" class="form-input sort-select" v-model="store.sortBy" @change="reload">
<option value="saved_at">Recently saved</option>
<option value="rating">Highest rated</option>
<option value="title">AZ</option>
</select>
</div>
<button class="btn btn-secondary btn-sm" @click="showNewCollection = true">
+ New collection
</button>
</div>
<!-- Loading -->
<div v-if="store.loading" class="text-secondary text-sm">Loading</div>
<!-- Recipe cards -->
<div class="saved-list flex-col gap-sm">
<div
v-for="recipe in store.saved"
:key="recipe.id"
class="card-sm saved-card"
:class="{ 'card-success': recipe.rating !== null && recipe.rating >= 4 }"
>
<div class="flex-between gap-sm">
<button
class="recipe-title-btn text-left"
@click="$emit('open-recipe', recipe.recipe_id)"
>
{{ recipe.title }}
</button>
<!-- Stars display -->
<div v-if="recipe.rating !== null" class="stars-display flex gap-xs" :aria-label="`Rating: ${recipe.rating} out of 5`">
<span
v-for="n in 5"
:key="n"
class="star-pip"
:class="{ filled: n <= recipe.rating }"
></span>
</div>
</div>
<!-- Tags -->
<div v-if="recipe.style_tags.length > 0" class="flex flex-wrap gap-xs mt-xs">
<span
v-for="tag in recipe.style_tags"
:key="tag"
class="tag-chip status-badge status-info"
>{{ 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
class="notes-preview text-sm text-secondary"
:class="{ expanded: expandedNotes.has(recipe.id) }"
>{{ recipe.notes }}</div>
<button
v-if="recipe.notes.length > 120"
class="btn-link text-sm"
:aria-expanded="expandedNotes.has(recipe.id)"
@click="toggleNotes(recipe.id)"
>
{{ expandedNotes.has(recipe.id) ? 'Show less' : 'Show more' }}
</button>
</div>
<!-- Actions -->
<div class="flex gap-xs mt-sm">
<button class="btn btn-secondary btn-xs" @click="editRecipe(recipe)">Edit</button>
<template v-if="confirmingRemove === recipe.id">
<button class="btn btn-danger btn-xs" @click="confirmRemove(recipe.id)">Yes, remove</button>
<button class="btn btn-secondary btn-xs" @click="cancelRemove">Cancel</button>
</template>
<button v-else class="btn btn-ghost btn-xs" @click="startRemove(recipe.id)">Remove</button>
</div>
</div>
</div>
</template>
<!-- New collection modal -->
<Teleport to="body" v-if="showNewCollection">
<div class="modal-overlay" @click.self="showNewCollection = false">
<div ref="newColDialogRef" class="modal-panel card" role="dialog" aria-modal="true" aria-label="New collection" tabindex="-1">
<h3 class="section-title mb-md">New collection</h3>
<div class="form-group">
<label class="form-label" for="col-name">Name</label>
<input id="col-name" class="form-input" v-model="newColName" placeholder="e.g. Weeknight meals" />
</div>
<div class="form-group">
<label class="form-label" for="col-desc">Description (optional)</label>
<input id="col-desc" class="form-input" v-model="newColDesc" placeholder="Optional description" />
</div>
<div class="flex gap-sm mt-md">
<button class="btn btn-primary" :disabled="!newColName.trim() || creatingCol" @click="createCollection">
{{ creatingCol ? 'Creating' : 'Create' }}
</button>
<button class="btn btn-secondary" @click="showNewCollection = false">Cancel</button>
</div>
</div>
</div>
</Teleport>
<!-- Edit modal -->
<SaveRecipeModal
v-if="editingRecipe"
:recipe-id="editingRecipe.recipe_id"
:recipe-title="editingRecipe.title"
@close="editingRecipe = null"
@saved="editingRecipe = null"
@unsave="doUnsave(editingRecipe!.recipe_id)"
/>
</div>
</template>
<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'
const emit = defineEmits<{
(e: 'open-recipe', recipeId: number): void
(e: 'go-to-tab', tab: string): void
}>()
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
const confirmingRemove = ref<number | null>(null)
function startRemove(id: number) {
confirmingRemove.value = id
}
function cancelRemove() {
confirmingRemove.value = null
}
function confirmRemove(id: number) {
const recipe = store.saved.find(r => r.id === id)
if (recipe) void unsave(recipe)
confirmingRemove.value = null
}
// #48: notes expand/collapse
const expandedNotes = ref<Set<number>>(new Set())
function toggleNotes(id: number) {
const next = new Set(expandedNotes.value)
next.has(id) ? next.delete(id) : next.add(id)
expandedNotes.value = next
}
const newColDialogRef = ref<HTMLElement | null>(null)
let newColPreviousFocus: HTMLElement | null = null
function handleNewColKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') showNewCollection.value = false
}
watch(showNewCollection, (open) => {
if (open) {
newColPreviousFocus = document.activeElement as HTMLElement
document.addEventListener('keydown', handleNewColKeydown)
nextTick(() => {
const focusable = newColDialogRef.value?.querySelector<HTMLElement>(
'button:not([disabled]), input'
)
;(focusable ?? newColDialogRef.value)?.focus()
})
} else {
document.removeEventListener('keydown', handleNewColKeydown)
newColPreviousFocus?.focus()
}
})
onUnmounted(() => {
document.removeEventListener('keydown', handleNewColKeydown)
})
const newColName = ref('')
const newColDesc = ref('')
const creatingCol = ref(false)
const activeCollectionId = computed({
get: () => store.activeCollectionId,
set: (v) => { store.activeCollectionId = v },
})
onMounted(() => store.load())
function reload() {
store.load()
}
function editRecipe(recipe: SavedRecipe) {
editingRecipe.value = recipe
}
async function unsave(recipe: SavedRecipe) {
await store.unsave(recipe.recipe_id)
}
async function doUnsave(recipeId: number) {
editingRecipe.value = null
await store.unsave(recipeId)
}
async function createCollection() {
if (!newColName.value.trim()) return
creatingCol.value = true
try {
await store.createCollection(newColName.value.trim(), newColDesc.value.trim() || undefined)
showNewCollection.value = false
newColName.value = ''
newColDesc.value = ''
} finally {
creatingCol.value = false
}
}
</script>
<style scoped>
.saved-panel {
padding: var(--spacing-sm) 0;
}
.sort-select {
width: auto;
min-width: 140px;
}
.saved-card {
transition: box-shadow 0.15s ease;
}
.recipe-title-btn {
background: none;
border: none;
cursor: pointer;
font-size: var(--font-size-base);
font-weight: 600;
color: var(--color-primary);
padding: 0;
flex: 1;
}
.recipe-title-btn:hover {
text-decoration: underline;
}
.stars-display {
flex-shrink: 0;
}
.star-pip {
font-size: 1rem;
color: var(--color-border);
}
.star-pip.filled {
color: var(--color-warning);
}
.notes-preview {
overflow: hidden;
max-height: 3.6em;
transition: max-height 0.2s ease;
}
.notes-preview.expanded {
max-height: none;
}
@media (prefers-reduced-motion: reduce) {
.notes-preview { transition: none; }
}
.btn-link {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
padding: 0;
text-decoration: underline;
}
.btn-link:hover {
text-decoration: none;
}
.tag-chip {
display: inline-flex;
align-items: center;
font-size: var(--font-size-xs, 0.75rem);
}
.btn-sm {
padding: var(--spacing-xs) var(--spacing-sm);
font-size: var(--font-size-sm);
}
.btn-xs {
padding: 2px var(--spacing-xs);
font-size: var(--font-size-xs, 0.75rem);
}
.empty-state {
padding: var(--spacing-xl);
}
.last-cooked-hint {
font-style: italic;
opacity: 0.75;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: var(--spacing-md);
}
.modal-panel {
width: 100%;
max-width: 420px;
}
</style>