diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8537cfe..dd837dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import Dashboard from './pages/Dashboard' import MealDetail from './pages/MealDetail' import Pantry from './pages/Pantry' +import ShoppingList from './pages/ShoppingList' const queryClient = new QueryClient() @@ -21,6 +22,9 @@ function App() { Pantry + + Shopping List + @@ -30,6 +34,7 @@ function App() { } /> } /> } /> + } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts new file mode 100644 index 0000000..4cecfd9 --- /dev/null +++ b/frontend/src/api/index.ts @@ -0,0 +1,64 @@ +import axios from 'axios' + +const API_BASE = import.meta.env.VITE_API_URL || '/api' + +const api = axios.create({ + baseURL: API_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}) + +export const mealPlannerApi = { + profile: { + get: () => api.get('/profile'), + update: (data: any) => api.put('/profile', data), + getMembers: () => api.get('/profile/members'), + addMember: (data: any) => api.post('/profile/members', data), + deleteMember: (id: string) => api.delete(`/profile/members/${id}`), + }, + + recipes: { + list: (params?: any) => api.get('/recipes', { params }), + get: (id: string) => api.get(`/recipes/${id}`), + create: (data: any) => api.post('/recipes', data), + delete: (id: string) => api.delete(`/recipes/${id}`), + listIngredients: () => api.get('/recipes/ingredients/list'), + createIngredient: (data: any) => api.post('/recipes/ingredients', data), + }, + + meals: { + getPlanned: () => api.get('/meals/planned'), + get: (id: string) => api.get(`/meals/${id}`), + getItem: (id: string) => api.get(`/meals/items/${id}`), + create: (data: any) => api.post('/meals', data), + lock: (id: string) => api.post(`/meals/${id}/lock`), + getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`), + submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data), + swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`), + }, + + pantry: { + list: () => api.get('/pantry'), + add: (data: any) => api.post('/pantry', data), + update: (id: string, data: any) => api.put(`/pantry/${id}`, data), + remove: (id: string) => api.delete(`/pantry/${id}`), + }, + + shoppingList: { + get: () => api.get('/shopping-list'), + getPrint: () => api.get('/shopping-list/print'), + }, + + admin: { + triggerScrape: (source?: string, type?: string) => api.post('/admin/scrape', null, { params: { source, scrape_type: type } }), + getLogs: (params?: any) => api.get('/admin/logs', { params }), + getLog: (id: string) => api.get(`/admin/logs/${id}`), + getEmailLogs: (params?: any) => api.get('/admin/email-logs', { params }), + getMealPlans: (params?: any) => api.get('/admin/meal-plans', { params }), + getStats: () => api.get('/admin/stats'), + testEmail: (email: string) => api.post('/admin/test-email', null, { params: { email } }), + }, +} + +export default api \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 57b3dfc..8b677e2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,8 +1,153 @@ -export default function Dashboard() { +import { useQuery } from '@tanstack/react-query' +import { mealPlannerApi } from '../api' +import type { MealPlan, MealPlanItem } from '../types' + +const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'] +const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const + +function MealCard({ item }: { item: MealPlanItem }) { return ( -
-

This Week's Meal Plan

-

Meal planning UI coming soon...

+
+ {item.recipe?.image_url && ( + {item.recipe.name} + )} +
+

{item.recipe?.name || 'Unknown Recipe'}

+ {item.recipe && ( +

+ {item.recipe.total_time_minutes || (item.recipe.prep_time_minutes || 0) + (item.recipe.cook_time_minutes || 0)} min + {' ยท '} + {item.recipe.servings} servings +

+ )} +
+ + {item.approval_status} + + {item.estimated_cost && ( + ${item.estimated_cost.toFixed(2)} + )} +
+
) } + +function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) { + return ( +
+
+ {DAY_NAMES[dayIndex]} +
+
+ {MEAL_TYPES.map(mealType => { + const item = items.find(i => i.meal_type === mealType) + return ( +
+ {mealType} + {item ? :
} +
+ ) + })} +
+
+ ) +} + +export default function Dashboard() { + const { data: mealPlan, isLoading } = useQuery({ + queryKey: ['mealPlan'], + queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data), + }) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!mealPlan) { + return ( +
+

This Week's Meal Plan

+
+

No meal plan generated yet

+ +
+
+ ) + } + + const itemsByDay = Array.from({ length: 7 }, (_, i) => + mealPlan.items.filter(item => item.day_of_week === i + 1) + ) + + return ( +
+
+

+ Week of {new Date(mealPlan.week_start_date).toLocaleDateString()} +

+
+ + {mealPlan.status.replace('_', ' ')} + + {mealPlan.total_estimated_cost && ( + + ${mealPlan.total_estimated_cost.toFixed(2)} total + + )} +
+
+ +
+

Weekly Overview

+
+
+ {itemsByDay.map((items, index) => ( + + ))} +
+
+
+ + +
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/MealDetail.tsx b/frontend/src/pages/MealDetail.tsx index 69546c3..3aa0fa0 100644 --- a/frontend/src/pages/MealDetail.tsx +++ b/frontend/src/pages/MealDetail.tsx @@ -1,11 +1,119 @@ +import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router-dom' +import { mealPlannerApi } from '../api' +import type { MealPlanItem } from '../types' export default function MealDetail() { - const { id } = useParams() + const { id } = useParams<{ id: string }>() + + const { data: item, isLoading } = useQuery({ + queryKey: ['mealItem', id], + queryFn: () => mealPlannerApi.meals.getItem(id!).then(r => r.data), + enabled: !!id, + }) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!item?.recipe) { + return ( +
+

Recipe Not Found

+

This meal item doesn't have an associated recipe.

+
+ ) + } + + const recipe = item.recipe + return (
-

Meal Detail

-

Meal {id} details coming soon...

+ {recipe.image_url && ( + {recipe.name} + )} + +
+
+

{recipe.name}

+ {recipe.description && ( +

{recipe.description}

+ )} +
+
+
+ ${item.estimated_cost?.toFixed(2) || 'N/A'} +
+
per serving
+
+
+ +
+ {recipe.prep_time_minutes && ( + Prep: {recipe.prep_time_minutes} min + )} + {recipe.cook_time_minutes && ( + Cook: {recipe.cook_time_minutes} min + )} + Serves: {recipe.servings} + {recipe.cuisine_tags?.length > 0 && ( + {recipe.cuisine_tags.join(', ')} + )} +
+ +
+

Ingredients

+
    + {recipe.ingredients?.map((ing, idx) => ( +
  • + + + {ing.quantity && `${ing.quantity} `} + {ing.unit && `${ing.unit} `} + {ing.name} + {ing.is_optional && ' (optional)'} + +
  • + ))} +
+
+ +
+

Instructions

+
    + {recipe.instructions?.map((step, idx) => ( +
  1. + + {idx + 1} + +

    {step}

    +
  2. + ))} +
+
+ +
+ + + Back to Meal Plan + +
) -} +} \ No newline at end of file diff --git a/frontend/src/pages/Pantry.tsx b/frontend/src/pages/Pantry.tsx index 6a4e58d..78e7411 100644 --- a/frontend/src/pages/Pantry.tsx +++ b/frontend/src/pages/Pantry.tsx @@ -1,8 +1,179 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { mealPlannerApi } from '../api' +import type { HomePantryItem, Ingredient } from '../types' +import { useState } from 'react' + export default function Pantry() { + const queryClient = useQueryClient() + const [showAddForm, setShowAddForm] = useState(false) + const [selectedIngredient, setSelectedIngredient] = useState('') + const [quantity, setQuantity] = useState('') + const [unit, setUnit] = useState('') + + const { data: pantryItems, isLoading } = useQuery({ + queryKey: ['pantry'], + queryFn: () => mealPlannerApi.pantry.list().then(r => r.data), + }) + + const { data: ingredients } = useQuery({ + queryKey: ['ingredients'], + queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data), + }) + + const addMutation = useMutation({ + mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) => + mealPlannerApi.pantry.add(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pantry'] }) + setShowAddForm(false) + setSelectedIngredient('') + setQuantity('') + setUnit('') + }, + }) + + const removeMutation = useMutation({ + mutationFn: (id: string) => mealPlannerApi.pantry.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pantry'] }) + }, + }) + + const handleAdd = () => { + if (!selectedIngredient) return + addMutation.mutate({ + ingredient_id: selectedIngredient, + quantity: quantity ? parseFloat(quantity) : undefined, + unit: unit || undefined, + }) + } + + if (isLoading) { + return ( +
+
+
+ ) + } + return (
-

Home Pantry

-

Manage your pantry items...

+
+

Home Pantry

+ +
+ + {showAddForm && ( +
+

Add Pantry Item

+
+
+ + +
+
+ + setQuantity(e.target.value)} + placeholder="e.g., 5" + className="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+
+ + setUnit(e.target.value)} + placeholder="e.g., cans, lbs" + className="w-full border border-gray-300 rounded-lg px-3 py-2" + /> +
+
+ +
+
+
+ )} + + {pantryItems && pantryItems.length > 0 ? ( +
+ + + + + + + + + + + + {pantryItems.map((item) => ( + + + + + + + + ))} + +
ItemAisleQuantityExpiresActions
+
+ {item.ingredient?.name || 'Unknown'} +
+
+ {item.ingredient?.aisle || '-'} + + {item.quantity} {item.unit || ''} + + {item.expires_at ? new Date(item.expires_at).toLocaleDateString() : '-'} + + +
+
+ ) : ( +
+

Your pantry is empty

+ +
+ )}
) -} +} \ No newline at end of file diff --git a/frontend/src/pages/ShoppingList.tsx b/frontend/src/pages/ShoppingList.tsx new file mode 100644 index 0000000..5662b20 --- /dev/null +++ b/frontend/src/pages/ShoppingList.tsx @@ -0,0 +1,106 @@ +import { useQuery } from '@tanstack/react-query' +import { mealPlannerApi } from '../api' +import type { ShoppingList } from '../types' + +export default function ShoppingListPage() { + const { data: shoppingList, isLoading } = useQuery({ + queryKey: ['shoppingList'], + queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data), + }) + + if (isLoading) { + return ( +
+
+
+ ) + } + + if (!shoppingList || shoppingList.items.length === 0) { + return ( +
+

Shopping List

+
+

No shopping list available

+

Generate a meal plan first to see your shopping list.

+
+
+ ) + } + + return ( +
+
+

+ Shopping List - Week of {new Date(shoppingList.week_start_date).toLocaleDateString()} +

+ +
+ +
+
+
+ ${shoppingList.total_estimated_cost.toFixed(2)} +
+
Estimated Total
+
+
+
{shoppingList.items.length}
+
Total Items
+
+
+
{shoppingList.sale_items_count}
+
Items on Sale
+
+
+ + {Object.entries(shoppingList.by_aisle).map(([aisle, items]) => ( +
+
+

{aisle}

+ {items.length} items +
+
    + {items.map((item, idx) => ( +
  • +
    +
    +
    + {item.name} + {item.in_pantry && ( + + In Pantry + + )} + {item.is_on_sale && ( + + SALE + + )} +
    +
    +
    + {item.quantity && ( + + {item.quantity} {item.unit || ''} + + )} + {item.sale_price ? ( + ${item.sale_price.toFixed(2)} + ) : item.estimated_price ? ( + ${item.estimated_price.toFixed(2)} + ) : null} +
    +
  • + ))} +
+
+ ))} +
+ ) +} \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..75ded7d --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,154 @@ +export interface FamilyProfile { + id: string + name: string + household_size: number + adult_count: number + child_count: number + dietary_notes?: string + budget_per_meal: number + created_at?: string + updated_at?: string + members: FamilyMember[] +} + +export interface FamilyMember { + id: string + family_profile_id: string + name: string + email?: string + role: 'adult' | 'child' + likes_mushrooms: boolean + created_at?: string + updated_at?: string +} + +export interface Recipe { + id: string + family_profile_id?: string + name: string + description?: string + image_url?: string + image_source?: string + prep_time_minutes?: number + cook_time_minutes?: number + servings: number + servings_scaled?: number + cuisine_tags: string[] + dietary_tags: string[] + protein_type?: string + spice_level?: number + ingredients: RecipeIngredient[] + instructions: string[] + source_url?: string + is_manually_added: boolean + scraped_at?: string + created_at?: string + updated_at?: string + total_time_minutes?: number +} + +export interface RecipeIngredient { + ingredient_id?: string + name: string + quantity?: number + unit?: string + is_optional: boolean +} + +export interface MealPlan { + id: string + family_profile_id: string + week_start_date: string + status: 'draft' | 'pending_approval' | 'approved' | 'locked' + approval_deadline?: string + total_estimated_cost?: number + notes?: string + created_at?: string + updated_at?: string + items: MealPlanItem[] +} + +export interface MealPlanItem { + id: string + meal_plan_id: string + recipe_id: string + day_of_week: number + meal_type: 'breakfast' | 'lunch' | 'dinner' + approval_status: 'pending' | 'approved' | 'denied' | 'swapped' + denial_reason?: string + denial_details?: string + estimated_cost?: number + used_pantry_items: string[] + recipe?: Recipe + created_at?: string + updated_at?: string +} + +export interface HomePantryItem { + id: string + family_profile_id: string + ingredient_id: string + quantity?: number + unit?: string + expires_at?: string + added_at?: string + created_at?: string + ingredient?: Ingredient +} + +export interface Ingredient { + id: string + name: string + name_lower: string + plural_name?: string + aisle?: string + typical_price?: number + unit?: string + season_months?: number[] + created_at?: string +} + +export interface ShoppingListItem { + ingredient_id?: string + name: string + quantity?: number + unit?: string + aisle?: string + estimated_price?: number + is_on_sale: boolean + sale_price?: number + in_season: boolean + in_pantry: boolean +} + +export interface ShoppingList { + week_start_date: string + items: ShoppingListItem[] + total_estimated_cost: number + sale_items_count: number + by_aisle: Record +} + +export interface VoteRequest { + vote: boolean + denial_reason?: string + denial_details?: string +} + +export interface ScrapeLog { + id: string + source: string + scrape_type: string + status: string + items_scraped?: number + error_message?: string + started_at?: string + completed_at?: string + duration_seconds?: number +} + +export interface SystemStats { + recipes: number + ingredients: number + meal_plans: number +} \ No newline at end of file diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..c6bbcbc --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_URL?: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} \ No newline at end of file