feat(frontend): add MealPlan tab with grid, shopping list, and prep schedule
closes kiwi#68, kiwi#71
This commit is contained in:
parent
faaa6fbf86
commit
2baa8c49a9
2 changed files with 183 additions and 1 deletions
|
|
@ -46,6 +46,18 @@
|
||||||
<span class="sidebar-label">Receipts</span>
|
<span class="sidebar-label">Receipts</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button :class="['sidebar-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||||
|
<line x1="8" y1="4" x2="8" y2="9"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="9"/>
|
||||||
|
<line x1="7" y1="14" x2="11" y2="14"/>
|
||||||
|
<line x1="7" y1="17" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span class="sidebar-label">Meal Plan</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
<button :class="['sidebar-item', { active: currentTab === 'settings' }]" @click="switchTab('settings')">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="3"/>
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
|
@ -79,6 +91,9 @@
|
||||||
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
<div v-show="currentTab === 'settings'" class="tab-content fade-in">
|
||||||
<SettingsView />
|
<SettingsView />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-show="currentTab === 'mealplan'" class="tab-content">
|
||||||
|
<MealPlanView />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -118,6 +133,17 @@
|
||||||
</svg>
|
</svg>
|
||||||
<span class="nav-label">Settings</span>
|
<span class="nav-label">Settings</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button :class="['nav-item', { active: currentTab === 'mealplan' }]" @click="switchTab('mealplan')" aria-label="Meal Plan">
|
||||||
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2"/>
|
||||||
|
<line x1="3" y1="9" x2="21" y2="9"/>
|
||||||
|
<line x1="8" y1="4" x2="8" y2="9"/>
|
||||||
|
<line x1="16" y1="4" x2="16" y2="9"/>
|
||||||
|
<line x1="7" y1="14" x2="11" y2="14"/>
|
||||||
|
<line x1="7" y1="17" x2="14" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">Meal Plan</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
<!-- Feedback FAB — hidden when FORGEJO_API_TOKEN not configured -->
|
||||||
|
|
@ -163,12 +189,13 @@ import InventoryList from './components/InventoryList.vue'
|
||||||
import ReceiptsView from './components/ReceiptsView.vue'
|
import ReceiptsView from './components/ReceiptsView.vue'
|
||||||
import RecipesView from './components/RecipesView.vue'
|
import RecipesView from './components/RecipesView.vue'
|
||||||
import SettingsView from './components/SettingsView.vue'
|
import SettingsView from './components/SettingsView.vue'
|
||||||
|
import MealPlanView from './components/MealPlanView.vue'
|
||||||
import FeedbackButton from './components/FeedbackButton.vue'
|
import FeedbackButton from './components/FeedbackButton.vue'
|
||||||
import { useInventoryStore } from './stores/inventory'
|
import { useInventoryStore } from './stores/inventory'
|
||||||
import { useEasterEggs } from './composables/useEasterEggs'
|
import { useEasterEggs } from './composables/useEasterEggs'
|
||||||
import { householdAPI } from './services/api'
|
import { householdAPI } from './services/api'
|
||||||
|
|
||||||
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings'
|
type Tab = 'inventory' | 'receipts' | 'recipes' | 'settings' | 'mealplan'
|
||||||
|
|
||||||
const currentTab = ref<Tab>('inventory')
|
const currentTab = ref<Tab>('inventory')
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
|
|
||||||
155
frontend/src/components/MealPlanView.vue
Normal file
155
frontend/src/components/MealPlanView.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<!-- frontend/src/components/MealPlanView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="meal-plan-view">
|
||||||
|
<!-- Week picker + new plan button -->
|
||||||
|
<div class="plan-controls">
|
||||||
|
<select
|
||||||
|
class="week-select"
|
||||||
|
:value="activePlan?.id ?? ''"
|
||||||
|
aria-label="Select week"
|
||||||
|
@change="onSelectPlan(Number(($event.target as HTMLSelectElement).value))"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Select a week...</option>
|
||||||
|
<option v-for="p in plans" :key="p.id" :value="p.id">
|
||||||
|
Week of {{ p.week_start }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="activePlan">
|
||||||
|
<!-- Compact expandable week grid (always visible) -->
|
||||||
|
<MealPlanGrid
|
||||||
|
:active-meal-types="activePlan.meal_types"
|
||||||
|
:can-add-meal-type="canAddMealType"
|
||||||
|
@slot-click="onSlotClick"
|
||||||
|
@add-meal-type="onAddMealType"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
||||||
|
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
||||||
|
<button
|
||||||
|
v-for="tab in TABS"
|
||||||
|
:key="tab.id"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeTab === tab.id"
|
||||||
|
:aria-controls="`tabpanel-${tab.id}`"
|
||||||
|
:id="`tab-${tab.id}`"
|
||||||
|
class="panel-tab"
|
||||||
|
:class="{ active: activeTab === tab.id }"
|
||||||
|
@click="activeTab = tab.id"
|
||||||
|
>{{ tab.label }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'shopping'"
|
||||||
|
id="tabpanel-shopping"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-shopping"
|
||||||
|
class="tab-panel"
|
||||||
|
>
|
||||||
|
<ShoppingListPanel @load="store.loadShoppingList()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-show="activeTab === 'prep'"
|
||||||
|
id="tabpanel-prep"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="tab-prep"
|
||||||
|
class="tab-panel"
|
||||||
|
>
|
||||||
|
<PrepSessionView @load="store.loadPrepSession()" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else-if="!loading" class="empty-plan-state">
|
||||||
|
<p>No meal plan yet for this week.</p>
|
||||||
|
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
import MealPlanGrid from './MealPlanGrid.vue'
|
||||||
|
import ShoppingListPanel from './ShoppingListPanel.vue'
|
||||||
|
import PrepSessionView from './PrepSessionView.vue'
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ id: 'shopping', label: 'Shopping List' },
|
||||||
|
{ id: 'prep', label: 'Prep Schedule' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type TabId = typeof TABS[number]['id']
|
||||||
|
|
||||||
|
const store = useMealPlanStore()
|
||||||
|
const { plans, activePlan, loading } = storeToRefs(store)
|
||||||
|
|
||||||
|
const activeTab = ref<TabId>('shopping')
|
||||||
|
|
||||||
|
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
||||||
|
const canAddMealType = computed(() =>
|
||||||
|
(activePlan.value?.meal_types.length ?? 0) < 4
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => store.loadPlans())
|
||||||
|
|
||||||
|
async function onNewPlan() {
|
||||||
|
const today = new Date()
|
||||||
|
const day = today.getDay()
|
||||||
|
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
|
||||||
|
const monday = new Date(today)
|
||||||
|
monday.setDate(today.getDate() - ((day + 6) % 7))
|
||||||
|
const weekStart = monday.toISOString().split('T')[0]
|
||||||
|
await store.createPlan(weekStart, ['dinner'])
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelectPlan(planId: number) {
|
||||||
|
if (planId) await store.setActivePlan(planId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSlotClick({ dayOfWeek, mealType }: { dayOfWeek: number; mealType: string }) {
|
||||||
|
// Recipe picker integration filed as follow-up
|
||||||
|
console.log('[MealPlan] slot-click', { dayOfWeek, mealType })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddMealType() {
|
||||||
|
// Add meal type picker — Paid gate enforced by backend
|
||||||
|
console.log('[MealPlan] add-meal-type')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.meal-plan-view { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
|
|
||||||
|
.plan-controls { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||||
|
.week-select {
|
||||||
|
flex: 1; padding: 0.4rem 0.6rem; border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-border); background: var(--color-surface);
|
||||||
|
color: var(--color-text); font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.new-plan-btn {
|
||||||
|
padding: 0.4rem 1rem; border-radius: 20px; font-size: 0.82rem;
|
||||||
|
background: var(--color-accent-subtle); color: var(--color-accent);
|
||||||
|
border: 1px solid var(--color-accent); cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
||||||
|
|
||||||
|
.panel-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
||||||
|
.panel-tab {
|
||||||
|
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
||||||
|
background: none; border: 1px solid transparent; border-bottom: none; cursor: pointer;
|
||||||
|
color: var(--color-text-secondary); transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.panel-tab.active {
|
||||||
|
color: var(--color-accent); background: var(--color-accent-subtle);
|
||||||
|
border-color: var(--color-border); border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
.panel-tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.tab-panel { padding-top: 0.75rem; }
|
||||||
|
|
||||||
|
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue