diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc62ee8..e5f0885 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,6 @@ jobs: - name: Test run: pnpm turbo test + + - name: Check unused files and dependencies + run: pnpm knip diff --git a/.vscode/settings.json b/.vscode/settings.json index 7521e1c..5b7d28b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,3 @@ { - "cSpell.words": ["Auguste"] + "cSpell.words": ["Auguste", "openrouter"] } diff --git a/AGENTS.md b/AGENTS.md index 651b4ea..1506cf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,8 @@ Foreign key relationships: `Member.familyId → Family`, `MemberAvailability.mem - **Const Enums**: Used for enums with proper TypeScript types. - **No `any` Types**: TypeScript strict mode is enabled - avoid `any`. - **ESM Only**: Package type is `"module"` - uses ES2022 modules. +- **Implementation Plans**: Before implementing a feature, write down the plan in markdown in the `specs/` folder. Use subfolders to group related work (e.g., `specs/meal-planner/`). +- **Business Logic in Domain Layer**: Keep business logic in domain services (`packages/core/src/domain/services/`), not in AI tools. Tools should be thin wrappers that call domain services. ## AI Agents diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index e277dc3..b1bc112 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,9 @@ import { getMembersByFamilyId, getAvailabilityByFamilyId, getPlannerSettingsByFamilyId, + getMealPlanningByFamilyId, + getAllMealPlanningsByFamilyId, + getMealEventsByFamilyId, } from '@auguste/core'; import dotenv from 'dotenv'; import path from 'path'; @@ -46,9 +49,16 @@ app.get('/health', (_req, res) => { app.post('/api/chat', async (req, res) => { const { message, agentId = 'onboardingAgent', threadId, resourceId, familyId } = req.body; + console.log(`Chat request: agentId=${agentId}, familyId=${familyId}`); + try { const agent = mastra.getAgent(agentId); + if (!agent) { + console.error(`Agent not found: ${agentId}`); + return res.status(404).json({ error: `Agent not found: ${agentId}` }); + } + // Set headers for SSE streaming res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -59,17 +69,63 @@ app.post('/api/chat', async (req, res) => { requestContext.set('familyId', familyId); } + console.log(`Starting stream for agent: ${agentId}`); + const result = await agent.stream(message, { threadId, resourceId, requestContext, + // Ensure the agent continues to generate a response after tool calls + // Without maxSteps, some models may stop after executing a tool without providing text output + maxSteps: 10, + onStepFinish: ({ + text, + toolCalls, + finishReason, + }: { + text?: string; + toolCalls?: unknown[]; + finishReason?: string; + }) => { + console.log( + `Step finished: finishReason=${finishReason}, textLength=${text?.length ?? 0}, toolCalls=${toolCalls?.length ?? 0}`, + ); + }, }); - // Stream text chunks as SSE events - for await (const chunk of result.textStream) { - res.write(`data: ${JSON.stringify({ type: 'text', content: chunk })}\n\n`); + let chunkCount = 0; + // Stream using fullStream to catch all events including errors + for await (const chunk of result.fullStream) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const anyChunk = chunk as any; + if (chunk.type === 'text-delta') { + chunkCount++; + // Mastra fullStream wraps text content in payload.text + const textContent = anyChunk.payload?.text ?? anyChunk.textDelta ?? ''; + res.write(`data: ${JSON.stringify({ type: 'text', content: textContent })}\n\n`); + } else if (chunk.type === 'error') { + console.error('Stream error:', chunk.error); + res.write(`data: ${JSON.stringify({ type: 'error', content: String(chunk.error) })}\n\n`); + } else if (chunk.type === 'tool-call') { + const toolName = anyChunk.payload?.toolName || anyChunk.toolName || 'unknown'; + const toolArgs = anyChunk.payload?.args || anyChunk.args || {}; + console.log(`Tool call: ${toolName}`); + // Send tool-call event to frontend + res.write(`data: ${JSON.stringify({ type: 'tool-call', toolName, args: toolArgs })}\n\n`); + } else if (chunk.type === 'tool-result') { + const toolName = anyChunk.payload?.toolName || anyChunk.toolName || 'unknown'; + const toolResult = anyChunk.payload?.result || anyChunk.result || {}; + const resultStr = JSON.stringify(toolResult); + console.log(`Tool result: ${toolName} -> ${resultStr.slice(0, 200)}`); + // Send tool-result event to frontend + res.write( + `data: ${JSON.stringify({ type: 'tool-result', toolName, result: toolResult })}\n\n`, + ); + } } + console.log(`Stream completed for agent: ${agentId}, chunks: ${chunkCount}`); + // Send done event res.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`); res.end(); @@ -153,6 +209,45 @@ app.get('/api/family/:id/settings', async (req, res) => { } }); +// Meal planning endpoints +app.get('/api/family/:id/planning', async (req, res) => { + try { + const { id } = req.params; + const planning = await getMealPlanningByFamilyId(id); + + if (!planning) { + return res.status(404).json({ error: 'No meal planning found' }); + } + + res.json(planning); + } catch (error) { + console.error('Error fetching meal planning:', error); + res.status(500).json({ error: 'Failed to fetch meal planning' }); + } +}); + +app.get('/api/family/:id/plannings', async (req, res) => { + try { + const { id } = req.params; + const plannings = await getAllMealPlanningsByFamilyId(id); + res.json(plannings); + } catch (error) { + console.error('Error fetching all meal plannings:', error); + res.status(500).json({ error: 'Failed to fetch meal plannings' }); + } +}); + +app.get('/api/family/:id/events', async (req, res) => { + try { + const { id } = req.params; + const events = await getMealEventsByFamilyId(id); + res.json(events); + } catch (error) { + console.error('Error fetching meal events:', error); + res.status(500).json({ error: 'Failed to fetch meal events' }); + } +}); + app.listen(port, () => { console.log(`API running at http://localhost:${port}`); }); diff --git a/apps/web/package.json b/apps/web/package.json index 46efb71..791bc8d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@tanstack/react-query": "^5.90.16", + "@tanstack/react-router": "^1.149.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/apps/web/src/components/chat/chat-panel.tsx b/apps/web/src/components/chat/chat-panel.tsx index 0ac0472..c37578d 100644 --- a/apps/web/src/components/chat/chat-panel.tsx +++ b/apps/web/src/components/chat/chat-panel.tsx @@ -3,6 +3,8 @@ import { ChatInput } from './chat-input'; import { SuggestedActions } from './suggested-actions'; import type { Message } from '@/hooks/use-chat'; +type ChatContext = 'family' | 'planner'; + interface ChatPanelProps { messages: Message[]; isLoading: boolean; @@ -11,6 +13,7 @@ interface ChatPanelProps { onSend: () => void; messagesEndRef: React.RefObject; disabled?: boolean; + context?: ChatContext; } export function ChatPanel({ @@ -21,6 +24,7 @@ export function ChatPanel({ onSend, messagesEndRef, disabled = false, + context = 'family', }: ChatPanelProps) { const hasMessages = messages.length > 0; @@ -37,7 +41,11 @@ export function ChatPanel({ {hasMessages ? ( ) : ( - + )} diff --git a/apps/web/src/components/chat/message-item.tsx b/apps/web/src/components/chat/message-item.tsx index 48d6e0a..994ea21 100644 --- a/apps/web/src/components/chat/message-item.tsx +++ b/apps/web/src/components/chat/message-item.tsx @@ -1,6 +1,7 @@ import { cn } from '@auguste/ui/lib/utils'; import ReactMarkdown from 'react-markdown'; import type { Message } from '@/hooks/use-chat'; +import { ToolCallsSection } from './tool-calls-section'; interface MessageItemProps { message: Message; @@ -8,9 +9,11 @@ interface MessageItemProps { export function MessageItem({ message }: MessageItemProps) { const isAssistant = message.role === 'assistant'; + const hasToolCalls = isAssistant && message.toolCalls && message.toolCalls.length > 0; return ( -
+
+ {/* Message bubble */}
+ + {/* Tool calls - subtle text below the message */} + {hasToolCalls && }
); } diff --git a/apps/web/src/components/chat/suggested-actions.tsx b/apps/web/src/components/chat/suggested-actions.tsx index 2c5c3a7..0fe508c 100644 --- a/apps/web/src/components/chat/suggested-actions.tsx +++ b/apps/web/src/components/chat/suggested-actions.tsx @@ -1,38 +1,84 @@ +type ContextType = 'family' | 'planner'; + interface SuggestedActionsProps { onSelect: (message: string) => void; disabled?: boolean; + context?: ContextType; } -const suggestions = [ - { - label: 'Add new member', - message: 'I would like to add a new family member', - icon: '👤', - }, - { - label: 'Set availability', - message: 'I want to set meal availability for family members', - icon: '📅', +interface Suggestion { + label: string; + message: string; + icon: string; +} + +interface ContextConfig { + title: string; + subtitle: string; + suggestions: Suggestion[]; +} + +const contextConfigs: Record = { + family: { + title: "Hello! I'm Auguste", + subtitle: 'Your AI meal planning assistant. How can I help you today?', + suggestions: [ + { + label: 'Add new member', + message: 'I would like to add a new family member', + icon: '👤', + }, + { + label: 'Set availability', + message: 'I want to set meal availability for family members', + icon: '📅', + }, + { + label: 'Food preferences', + message: 'I want to specify food preferences or allergies', + icon: '🥗', + }, + ], }, - { - label: 'Food preferences', - message: 'I want to specify food preferences or allergies', - icon: '🥗', + planner: { + title: "Let's Plan Your Meals", + subtitle: 'I can help you create delicious meal plans for your family.', + suggestions: [ + { + label: 'Plan meals for the next week', + message: 'Plan meals for the next week', + icon: '📆', + }, + { + label: 'Suggest meals for a specific date', + message: 'Suggest meals for a specific date', + icon: '🍽️', + }, + { + label: 'Adjust meal plan for a specific date', + message: 'Adjust meal plan for a specific date', + icon: '✏️', + }, + ], }, -]; +}; + +export function SuggestedActions({ + onSelect, + disabled = false, + context = 'family', +}: SuggestedActionsProps) { + const config = contextConfigs[context]; -export function SuggestedActions({ onSelect, disabled = false }: SuggestedActionsProps) { return (
-

Hello! I'm Auguste

-

- Your AI meal planning assistant. How can I help you today? -

+

{config.title}

+

{config.subtitle}

- {suggestions.map((suggestion) => ( + {config.suggestions.map((suggestion) => ( + + {/* Expanded tool list */} + {isExpanded && ( +
+ {toolCalls.map((toolCall, index) => ( + + {toolCall.status === 'completed' ? ( + + ) : ( + + )} + {formatToolName(toolCall.toolName)} + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 1b71a03..bfa0759 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -1,12 +1,79 @@ -import { ChefHat } from 'lucide-react'; +import { ChefHat, UtensilsCrossed, Users, ArrowRight, Pencil } from 'lucide-react'; +import { Link, useNavigate } from '@tanstack/react-router'; + +interface HeaderProps { + familyId?: string | null; + isFamilyReady?: boolean; + currentRoute: 'family' | 'planner'; +} + +export function Header({ isFamilyReady = false, currentRoute }: HeaderProps) { + const navigate = useNavigate(); -export function Header() { return ( -
-
- +
+ {/* Logo - Left aligned */} +
+
+ +
+

AUGUSTE

+
+ + {/* Tab Navigation - Center aligned */} + + + {/* CTAs - Right aligned */} +
+ {currentRoute === 'family' && isFamilyReady && ( + + Go to Meal Planner + + + )} + {currentRoute === 'planner' && ( + + + Edit Family + + )} + {currentRoute === 'family' && !isFamilyReady && ( +
Complete setup to start planning
+ )}
-

AUGUSTE

); } diff --git a/apps/web/src/components/planner/calendar-view.tsx b/apps/web/src/components/planner/calendar-view.tsx new file mode 100644 index 0000000..7493610 --- /dev/null +++ b/apps/web/src/components/planner/calendar-view.tsx @@ -0,0 +1,282 @@ +import { useState, useMemo } from 'react'; +import { + format, + startOfMonth, + endOfMonth, + startOfWeek, + endOfWeek, + addDays, + addMonths, + subMonths, + isSameMonth, + isSameDay, + isWithinInterval, + parseISO, +} from 'date-fns'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import type { MealPlanning, MealEvent } from '@/hooks/use-planner-data'; + +interface CalendarViewProps { + plannings: MealPlanning[]; + events: MealEvent[]; + selectedPlanningId?: string; + onSelectPlanning: (planningId: string | undefined) => void; +} + +const STATUS_COLORS = { + draft: { + bg: 'bg-gray-100', + bgSelected: 'bg-gray-200', + text: 'text-gray-600', + }, + active: { + bg: 'bg-escoffier-green/15', + bgSelected: 'bg-escoffier-green/25', + text: 'text-escoffier-green', + }, + completed: { + bg: 'bg-blue-50', + bgSelected: 'bg-blue-100', + text: 'text-blue-600', + }, +}; + +type PlanningPosition = 'start' | 'middle' | 'end' | 'single'; + +export function CalendarView({ + plannings, + events, + selectedPlanningId, + onSelectPlanning, +}: CalendarViewProps) { + const [currentMonth, setCurrentMonth] = useState(new Date()); + + // Get days for the calendar grid + const calendarDays = useMemo(() => { + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const calendarStart = startOfWeek(monthStart); + const calendarEnd = endOfWeek(monthEnd); + + const days: Date[] = []; + let day = calendarStart; + while (day <= calendarEnd) { + days.push(day); + day = addDays(day, 1); + } + return days; + }, [currentMonth]); + + // Find plannings that overlap with a given day with position info + const getPlanningInfoForDay = (day: Date, dayIndex: number) => { + const isRowStart = dayIndex % 7 === 0; + const isRowEnd = dayIndex % 7 === 6; + + return plannings + .filter((planning) => { + const start = parseISO(planning.startDate); + const end = parseISO(planning.endDate); + return isWithinInterval(day, { start, end }); + }) + .map((planning) => { + const start = parseISO(planning.startDate); + const end = parseISO(planning.endDate); + const isStart = isSameDay(day, start) || isRowStart; + const isEnd = isSameDay(day, end) || isRowEnd; + + let position: PlanningPosition = 'middle'; + if (isStart && isEnd) position = 'single'; + else if (isStart) position = 'start'; + else if (isEnd) position = 'end'; + + return { planning, position }; + }); + }; + + // Count events for a given day + const getEventCountForDay = (day: Date) => { + const dateStr = format(day, 'yyyy-MM-dd'); + return events.filter((event) => event.date === dateStr).length; + }; + + const today = new Date(); + + return ( +
+ {/* Header with navigation */} +
+

Meal Planning Calendar

+
+ + + {format(currentMonth, 'MMMM yyyy')} + + +
+
+ + {/* Calendar Grid - fully bordered with rounded corners */} +
+ {/* Day headers - light grey background */} +
+ {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((day, index) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar days */} +
+ {calendarDays.map((day, index) => { + const planningInfos = getPlanningInfoForDay(day, index); + const eventCount = getEventCountForDay(day); + const isCurrentMonth = isSameMonth(day, currentMonth); + const isToday = isSameDay(day, today); + const isLastColumn = index % 7 === 6; + const isLastRow = index >= calendarDays.length - 7; + + return ( + + ); + })} +
+
+
+ ); +} + +interface PlanningInfo { + planning: MealPlanning; + position: PlanningPosition; +} + +interface CalendarDayProps { + day: Date; + planningInfos: PlanningInfo[]; + eventCount: number; + isCurrentMonth: boolean; + isToday: boolean; + isLastColumn: boolean; + isLastRow: boolean; + selectedPlanningId?: string; + onSelectPlanning: (planningId: string | undefined) => void; +} + +function CalendarDay({ + day, + planningInfos, + eventCount, + isCurrentMonth, + isToday, + isLastColumn, + isLastRow, + selectedPlanningId, + onSelectPlanning, +}: CalendarDayProps) { + const hasPlannings = planningInfos.length > 0; + + // Get margin/padding based on position for continuous bar effect + const getBarStyle = (position: PlanningPosition) => { + switch (position) { + case 'start': + return 'ml-1 rounded-l'; + case 'end': + return 'mr-1 rounded-r'; + case 'single': + return 'mx-1 rounded'; + default: + return ''; // middle - no margins, extends to edges + } + }; + + return ( +
+ {/* Today indicator - gold dot on top right */} + {isToday &&
} + + {/* Day number */} +
+ {format(day, 'd')} +
+ + {/* Planning bars - positioned absolutely for seamless continuation */} +
+ {planningInfos.slice(0, 2).map(({ planning, position }) => { + const colors = STATUS_COLORS[planning.status]; + const isPlanningSelected = planning.id === selectedPlanningId; + return ( + + ); + })} + {planningInfos.length > 2 && ( +
+{planningInfos.length - 2}
+ )} +
+ + {/* Event count - only show if no plannings */} + {eventCount > 0 && !hasPlannings && ( +
+ {eventCount} meal{eventCount > 1 ? 's' : ''} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/planner/planner-panel.tsx b/apps/web/src/components/planner/planner-panel.tsx new file mode 100644 index 0000000..d67f8f5 --- /dev/null +++ b/apps/web/src/components/planner/planner-panel.tsx @@ -0,0 +1,109 @@ +import { useState } from 'react'; +import { WeeklyPlanView } from './weekly-plan-view'; +import { CalendarView } from './calendar-view'; +import { PlanningDetails } from './planning-details'; +import { usePlannerData } from '@/hooks/use-planner-data'; +import { LayoutGrid, CalendarDays } from 'lucide-react'; + +type PlannerTabType = 'weekly' | 'calendar'; + +interface PlannerPanelProps { + familyId: string; + isPolling?: boolean; +} + +export function PlannerPanel({ familyId, isPolling = false }: PlannerPanelProps) { + const [activeTab, setActiveTab] = useState('weekly'); + const [selectedPlanningId, setSelectedPlanningId] = useState(); + const { plannings, events, isLoading, error } = usePlannerData(familyId, { isPolling }); + + const tabs: { id: PlannerTabType; label: string; icon: React.ReactNode }[] = [ + { id: 'weekly', label: 'Weekly Plan', icon: }, + { id: 'calendar', label: 'Calendar', icon: }, + ]; + + // Find the selected planning object + const selectedPlanning = plannings.find((p) => p.id === selectedPlanningId); + + if (error) { + return ( +
+
+

Error loading meal planning data

+

{(error as Error).message}

+
+
+ ); + } + + return ( +
+ {/* Tab Navigation */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {isLoading ? ( +
+
Loading meal plan...
+
+ ) : ( + <> + {activeTab === 'weekly' && ( +
+ +
+ )} + {activeTab === 'calendar' && ( +
+ {/* Calendar takes remaining space */} +
+ +
+ {/* Planning details at bottom with horizontal scroll */} + {selectedPlanning && ( +
+ setSelectedPlanningId(undefined)} + /> +
+ )} +
+ )} + + )} +
+
+ ); +} diff --git a/apps/web/src/components/planner/planning-details.tsx b/apps/web/src/components/planner/planning-details.tsx new file mode 100644 index 0000000..5a3c474 --- /dev/null +++ b/apps/web/src/components/planner/planning-details.tsx @@ -0,0 +1,164 @@ +import { useMemo } from 'react'; +import { format, parseISO, eachDayOfInterval, isSameDay } from 'date-fns'; +import { X, Coffee, Sun, Moon, Users, UtensilsCrossed, CalendarDays } from 'lucide-react'; +import type { MealPlanning, MealEvent } from '@/hooks/use-planner-data'; + +interface PlanningDetailsProps { + planning: MealPlanning; + events: MealEvent[]; + onClose: () => void; +} + +const MEAL_TYPE_ICONS: Record = { + breakfast: , + lunch: , + dinner: , +}; + +const MEAL_TYPE_LABELS: Record = { + breakfast: 'Breakfast', + lunch: 'Lunch', + dinner: 'Dinner', +}; + +export function PlanningDetails({ planning, events, onClose }: PlanningDetailsProps) { + const today = new Date(); + + // Get all days in the planning period + const planningDays = useMemo(() => { + return eachDayOfInterval({ + start: parseISO(planning.startDate), + end: parseISO(planning.endDate), + }); + }, [planning.startDate, planning.endDate]); + + // Filter and group events by date + const eventsByDate = useMemo(() => { + const planningEvents = events + .filter((event) => event.planningId === planning.id) + .sort((a, b) => { + const mealOrder = { breakfast: 0, lunch: 1, dinner: 2 }; + return mealOrder[a.mealType] - mealOrder[b.mealType]; + }); + + const grouped: Record = {}; + for (const event of planningEvents) { + if (!grouped[event.date]) { + grouped[event.date] = []; + } + grouped[event.date].push(event); + } + return grouped; + }, [events, planning.id]); + + const totalMeals = Object.values(eventsByDate).flat().length; + + return ( +
+ {/* Header - compact */} +
+
+
+ + + {format(parseISO(planning.startDate), 'MMM d')} -{' '} + {format(parseISO(planning.endDate), 'MMM d, yyyy')} + +
+
+ + {totalMeals} meals +
+
+ +
+ + {/* Horizontal scrolling days - styled like weekly plan */} +
+
+ {planningDays.map((day) => { + const dateStr = format(day, 'yyyy-MM-dd'); + const dayEvents = eventsByDate[dateStr] || []; + const isToday = isSameDay(day, today); + + return ( +
+ {/* Day header - matching weekly plan style */} +
+
+
+ + {format(day, 'EEE')} + + + {format(day, 'MMM d')} + +
+ {isToday && ( + + Today + + )} +
+
+ + {/* Meals - matching weekly plan style */} +
+ {dayEvents.length > 0 ? ( + dayEvents.map((event) => ) + ) : ( +
+ No meals planned +
+ )} +
+
+ ); + })} +
+
+
+ ); +} + +function MealEventCard({ event }: { event: MealEvent }) { + return ( +
+ {/* Meal type header */} +
+ {MEAL_TYPE_ICONS[event.mealType]} + {MEAL_TYPE_LABELS[event.mealType]} +
+ {/* Recipe name */} +
+ {event.recipeName ? ( + {event.recipeName} + ) : ( + No recipe + )} +
+ {/* Participants */} + {event.participants.length > 0 && ( +
+ + {event.participants.length} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/planner/weekly-plan-view.tsx b/apps/web/src/components/planner/weekly-plan-view.tsx new file mode 100644 index 0000000..96a88a6 --- /dev/null +++ b/apps/web/src/components/planner/weekly-plan-view.tsx @@ -0,0 +1,270 @@ +import { useMemo, useState, useEffect } from 'react'; +import { format, parseISO, addDays, isWithinInterval } from 'date-fns'; +import { UtensilsCrossed, Users, Coffee, Sun, Moon, ChevronLeft, ChevronRight } from 'lucide-react'; + +interface MealPlanning { + id: string; + familyId: string; + startDate: string; + endDate: string; + status: 'draft' | 'active' | 'completed'; +} + +interface MealEvent { + id: string; + familyId: string; + planningId?: string; + date: string; + mealType: 'breakfast' | 'lunch' | 'dinner'; + recipeName?: string; + participants: string[]; +} + +interface WeeklyPlanViewProps { + plannings: MealPlanning[]; + events?: MealEvent[]; +} + +const MEAL_TYPE_ICONS: Record = { + breakfast: , + lunch: , + dinner: , +}; + +const MEAL_TYPE_LABELS: Record = { + breakfast: 'Breakfast', + lunch: 'Lunch', + dinner: 'Dinner', +}; + +export function WeeklyPlanView({ plannings, events = [] }: WeeklyPlanViewProps) { + // Sort plannings by start date (chronologically for navigation) + const sortedPlannings = useMemo( + () => [...plannings].sort((a, b) => a.startDate.localeCompare(b.startDate)), + [plannings], + ); + + // Find the planning that contains today (current week) + const currentWeekPlanningIndex = useMemo(() => { + const today = new Date(); + const index = sortedPlannings.findIndex((p) => { + const start = parseISO(p.startDate); + const end = parseISO(p.endDate); + return isWithinInterval(today, { start, end }); + }); + // Default to last planning if no current week planning, or 0 if empty + return index >= 0 ? index : Math.max(0, sortedPlannings.length - 1); + }, [sortedPlannings]); + + // Track selected planning index + const [selectedIndex, setSelectedIndex] = useState(currentWeekPlanningIndex); + + // Update selected index when plannings change and we need to find current week + useEffect(() => { + setSelectedIndex(currentWeekPlanningIndex); + }, [currentWeekPlanningIndex]); + + const selectedPlanning = sortedPlannings[selectedIndex]; + + // Filter events for the selected planning + const planningEvents = useMemo(() => { + if (!selectedPlanning) return []; + return events.filter((e) => e.planningId === selectedPlanning.id); + }, [events, selectedPlanning]); + + // Group events by date + const groupedEvents = useMemo(() => { + const grouped: Record = {}; + for (const event of planningEvents) { + if (!grouped[event.date]) { + grouped[event.date] = []; + } + grouped[event.date].push(event); + } + // Sort events within each day by meal type order + const mealOrder = { breakfast: 0, lunch: 1, dinner: 2 }; + for (const date of Object.keys(grouped)) { + grouped[date].sort((a, b) => mealOrder[a.mealType] - mealOrder[b.mealType]); + } + return grouped; + }, [planningEvents]); + + // Generate dates for the selected planning's date range + const weekDates = useMemo(() => { + if (!selectedPlanning) return []; + const start = parseISO(selectedPlanning.startDate); + const end = parseISO(selectedPlanning.endDate); + const dates: string[] = []; + let current = start; + while (current <= end) { + dates.push(format(current, 'yyyy-MM-dd')); + current = addDays(current, 1); + } + return dates; + }, [selectedPlanning]); + + const canGoPrevious = selectedIndex > 0; + const canGoNext = selectedIndex < sortedPlannings.length - 1; + + const handlePrevious = () => { + if (canGoPrevious) { + setSelectedIndex(selectedIndex - 1); + } + }; + + const handleNext = () => { + if (canGoNext) { + setSelectedIndex(selectedIndex + 1); + } + }; + + // No plannings - show empty state + if (sortedPlannings.length === 0) { + return ( +
+
+ +
+

No Meal Plan Yet

+

+ Start a conversation with Auguste to create your first weekly meal plan. Auguste will + consider your family's preferences, dietary restrictions, and availability. +

+
+ ); + } + + return ( +
+ {/* Header with Navigation */} +
+
+

Weekly Meal Plan

+ {/* Navigation Controls */} +
+ + + {selectedIndex + 1} of {sortedPlannings.length} + + +
+
+ {selectedPlanning && ( +
+ + {format(parseISO(selectedPlanning.startDate), 'MMM d')} -{' '} + {format(parseISO(selectedPlanning.endDate), 'MMM d, yyyy')} + + + {selectedPlanning.status.charAt(0).toUpperCase() + selectedPlanning.status.slice(1)} + +
+ )} +
+ + {/* Week Grid */} +
+ {weekDates.map((dateStr) => { + const dayEvents = groupedEvents[dateStr] || []; + const date = parseISO(dateStr); + const isToday = format(new Date(), 'yyyy-MM-dd') === dateStr; + + return ( +
+ {/* Day Header */} +
+
+
+ {format(date, 'EEEE')} + {format(date, 'MMM d')} +
+ {isToday && ( + + Today + + )} +
+
+ + {/* Meals */} +
+ {dayEvents.length > 0 ? ( + dayEvents.map((event) => ) + ) : ( +
+ No meals planned +
+ )} +
+
+ ); + })} +
+
+ ); +} + +function MealEventCard({ event }: { event: MealEvent }) { + return ( +
+
+ {MEAL_TYPE_ICONS[event.mealType]} + {MEAL_TYPE_LABELS[event.mealType]} +
+
+ {event.recipeName ? ( + {event.recipeName} + ) : ( + No recipe selected + )} +
+ {event.participants.length > 0 && ( +
+ + {event.participants.length} +
+ )} +
+ ); +} diff --git a/apps/web/src/hooks/use-chat.ts b/apps/web/src/hooks/use-chat.ts index c531910..ff131d4 100644 --- a/apps/web/src/hooks/use-chat.ts +++ b/apps/web/src/hooks/use-chat.ts @@ -1,13 +1,33 @@ import { useState, useRef, useEffect, useCallback } from 'react'; +export interface ToolCall { + toolName: string; + args?: Record; + result?: unknown; + status: 'pending' | 'completed'; +} + export interface Message { role: 'user' | 'assistant'; content: string; + toolCalls?: ToolCall[]; +} + +type AgentType = 'onboarding' | 'meal-planner'; + +interface UseChatOptions { + agentType?: AgentType; } const POLLING_DURATION_MS = 5000; // Duration to poll after receiving a message -export function useChat() { +const AGENT_ID_MAP: Record = { + onboarding: 'onboardingAgent', + 'meal-planner': 'mealPlannerAgent', +}; + +export function useChat(options: UseChatOptions = {}) { + const { agentType = 'onboarding' } = options; const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -84,7 +104,7 @@ export function useChat() { }, body: JSON.stringify({ message: input, // Only send the current message - agentId: 'onboardingAgent', + agentId: AGENT_ID_MAP[agentType], threadId: threadIdRef.current, resourceId: familyId || resourceIdRef.current, familyId, @@ -101,7 +121,7 @@ export function useChat() { const reader = response.body.getReader(); const decoder = new TextDecoder(); - const assistantMessage: Message = { role: 'assistant', content: '' }; + const assistantMessage: Message = { role: 'assistant', content: '', toolCalls: [] }; setMessages((prev) => [...prev, assistantMessage]); @@ -133,6 +153,40 @@ export function useChat() { // Trigger polling when receiving agent messages // This resets the 5s timer on each chunk received triggerPolling(); + } else if (data.type === 'tool-call') { + // Add a new pending tool call + const toolCall: ToolCall = { + toolName: data.toolName, + args: data.args, + status: 'pending', + }; + assistantMessage.toolCalls = [...(assistantMessage.toolCalls || []), toolCall]; + setMessages((prev) => { + const newMessages = [...prev]; + newMessages[newMessages.length - 1] = { ...assistantMessage }; + return newMessages; + }); + triggerPolling(); + } else if (data.type === 'tool-result') { + // Update the corresponding tool call with the result + const toolCalls = assistantMessage.toolCalls || []; + const toolIndex = toolCalls.findIndex( + (tc) => tc.toolName === data.toolName && tc.status === 'pending', + ); + if (toolIndex !== -1) { + toolCalls[toolIndex] = { + ...toolCalls[toolIndex], + result: data.result, + status: 'completed', + }; + assistantMessage.toolCalls = [...toolCalls]; + setMessages((prev) => { + const newMessages = [...prev]; + newMessages[newMessages.length - 1] = { ...assistantMessage }; + return newMessages; + }); + } + triggerPolling(); } else if (data.type === 'error') { throw new Error(data.content); } diff --git a/apps/web/src/hooks/use-family-ready.ts b/apps/web/src/hooks/use-family-ready.ts new file mode 100644 index 0000000..1206295 --- /dev/null +++ b/apps/web/src/hooks/use-family-ready.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { useFamilyData } from './use-family-data'; + +/** + * Hook to check if a family is ready for meal planning. + * + * A family is considered "ready" when: + * 1. At least one member exists + * 2. mealTypes array has at least one value + * 3. activeDays array has at least one value + */ +export function useFamilyReady(familyId: string | null) { + const { members, settings, isLoading } = useFamilyData(familyId || '', { + isPolling: false, + }); + + const isReady = useMemo(() => { + // If still loading or no familyId, not ready + if (!familyId || isLoading) { + return false; + } + + // Check at least one member + if (!members || members.length === 0) { + return false; + } + + // Check mealTypes has at least one value + if (!settings?.mealTypes || settings.mealTypes.length === 0) { + return false; + } + + // Check activeDays has at least one value + if (!settings?.activeDays || settings.activeDays.length === 0) { + return false; + } + + return true; + }, [familyId, members, settings, isLoading]); + + return { + isReady, + isLoading, + // Provide specific readiness details for UI feedback + hasMembers: (members?.length ?? 0) > 0, + hasMealTypes: (settings?.mealTypes?.length ?? 0) > 0, + hasActiveDays: (settings?.activeDays?.length ?? 0) > 0, + }; +} + diff --git a/apps/web/src/hooks/use-planner-data.ts b/apps/web/src/hooks/use-planner-data.ts new file mode 100644 index 0000000..e7d1400 --- /dev/null +++ b/apps/web/src/hooks/use-planner-data.ts @@ -0,0 +1,61 @@ +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; + +interface UsePlannerDataOptions { + /** When true, enables polling every 1 second. When false, polling is disabled. */ + isPolling?: boolean; +} + +const POLLING_INTERVAL = 1000; // Poll every 1 second when active + +export interface MealPlanning { + id: string; + familyId: string; + startDate: string; + endDate: string; + status: 'draft' | 'active' | 'completed'; + createdAt: string; + updatedAt: string; +} + +export interface MealEvent { + id: string; + familyId: string; + planningId?: string; + date: string; + mealType: 'breakfast' | 'lunch' | 'dinner'; + recipeName?: string; + participants: string[]; +} + +export function usePlannerData(familyId: string, options: UsePlannerDataOptions = {}) { + const { isPolling = false } = options; + + // Only poll when isPolling is true, otherwise disable refetchInterval + const refetchInterval = isPolling ? POLLING_INTERVAL : false; + + const planningsQuery = useQuery({ + queryKey: ['plannings', familyId], + queryFn: () => apiClient.getAllMealPlannings(familyId), + enabled: !!familyId, + refetchInterval, + }); + + const eventsQuery = useQuery({ + queryKey: ['mealEvents', familyId], + queryFn: () => apiClient.getMealEvents(familyId), + enabled: !!familyId, + refetchInterval, + }); + + return { + plannings: planningsQuery.data ?? [], + events: eventsQuery.data ?? [], + isLoading: planningsQuery.isLoading || eventsQuery.isLoading, + error: planningsQuery.error || eventsQuery.error, + refetch: () => { + planningsQuery.refetch(); + eventsQuery.refetch(); + }, + }; +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 447f743..ddae507 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -30,4 +30,23 @@ export const apiClient = { if (!response.ok) throw new Error('Failed to create family'); return response.json(); }, + // Meal planning endpoints + getMealPlanning: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/planning`); + if (!response.ok) { + if (response.status === 404) return null; + throw new Error('Failed to fetch meal planning'); + } + return response.json(); + }, + getAllMealPlannings: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/plannings`); + if (!response.ok) throw new Error('Failed to fetch meal plannings'); + return response.json(); + }, + getMealEvents: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/events`); + if (!response.ok) throw new Error('Failed to fetch meal events'); + return response.json(); + }, }; diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 80f5ee6..8d413d9 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,7 +1,10 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import App from './App.tsx'; +import { RouterProvider } from '@tanstack/react-router'; +import { router } from './router'; +import '@auguste/ui/globals.css'; +import './components/family/family-styles.css'; const queryClient = new QueryClient({ defaultOptions: { @@ -16,7 +19,7 @@ const queryClient = new QueryClient({ createRoot(document.getElementById('root')!).render( - + , ); diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 0000000..29262cb --- /dev/null +++ b/apps/web/src/router.tsx @@ -0,0 +1,42 @@ +import { createRouter, createRootRoute, createRoute, redirect } from '@tanstack/react-router'; +import { FamilyView } from './views/family-view'; +import { PlannerView } from './views/planner-view'; + +// Create the root route +const rootRoute = createRootRoute(); + +// Create the index route that redirects to /family +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + beforeLoad: () => { + throw redirect({ to: '/family' }); + }, +}); + +// Create the family route +const familyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/family', + component: FamilyView, +}); + +// Create the planner route +const plannerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/planner', + component: PlannerView, +}); + +// Create the route tree +const routeTree = rootRoute.addChildren([indexRoute, familyRoute, plannerRoute]); + +// Create the router +export const router = createRouter({ routeTree }); + +// Register the router for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/views/family-view.tsx similarity index 85% rename from apps/web/src/App.tsx rename to apps/web/src/views/family-view.tsx index 4e6f82b..85da5c9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/views/family-view.tsx @@ -3,10 +3,9 @@ import { ChatPanel } from '@/components/chat/chat-panel'; import { FamilyPanel } from '@/components/family/family-panel'; import { CreateFamilyModal } from '@/components/family/create-family-modal'; import { useChat } from '@/hooks/use-chat'; -import '@auguste/ui/globals.css'; -import './components/family/family-styles.css'; +import { useFamilyReady } from '@/hooks/use-family-ready'; -function App() { +export function FamilyView() { const { messages, input, @@ -17,11 +16,13 @@ function App() { familyId, setFamilyId, isPolling, - } = useChat(); + } = useChat({ agentType: 'onboarding' }); + + const { isReady } = useFamilyReady(familyId); return (
-
+
{/* Left Panel: Chat (45% width) */}
@@ -48,7 +49,7 @@ function App() { Start a conversation to create your family and set up meal planning.

-
+
@@ -60,4 +61,3 @@ function App() { ); } -export default App; diff --git a/apps/web/src/views/planner-view.tsx b/apps/web/src/views/planner-view.tsx new file mode 100644 index 0000000..1e92307 --- /dev/null +++ b/apps/web/src/views/planner-view.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { Header } from '@/components/layout/header'; +import { ChatPanel } from '@/components/chat/chat-panel'; +import { PlannerPanel } from '@/components/planner/planner-panel'; +import { useChat } from '@/hooks/use-chat'; +import { useFamilyReady } from '@/hooks/use-family-ready'; + +export function PlannerView() { + const navigate = useNavigate(); + const { + messages, + input, + isLoading, + messagesEndRef, + handleInputChange, + sendMessage, + familyId, + isPolling, + } = useChat({ agentType: 'meal-planner' }); + + const { isReady } = useFamilyReady(familyId); + + // Redirect to family route if not ready + useEffect(() => { + if (!isReady && familyId) { + navigate({ to: '/family' }); + } + }, [isReady, familyId, navigate]); + + // Show loading if no family + if (!familyId) { + return ( +
+
+
+
+

No Family Selected

+

+ Please set up your family first before planning meals. +

+
+
+
+ ); + } + + return ( +
+
+
+ {/* Left Panel: Chat (45% width) */} +
+ +
+ + {/* Right Panel: Planner Data (55% width) */} +
+ +
+
+
+ ); +} diff --git a/biome.json b/biome.json index a6e2f6c..159aae0 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,8 @@ "useIgnoreFile": true }, "files": { - "ignoreUnknown": false + "ignoreUnknown": false, + "includes": ["**", "!**/migrations/meta"] }, "formatter": { "enabled": true, diff --git a/knip.json b/knip.json new file mode 100644 index 0000000..b08981a --- /dev/null +++ b/knip.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "workspaces": { + ".": { + "entry": ["scripts/*.ts"], + "project": ["scripts/**/*.ts"], + "ignore": ["coverage/**"], + "ignoreDependencies": ["mastra", "drizzle-orm", "dotenv"] + }, + "packages/core": { + "entry": ["src/ai/index.ts"], + "project": ["src/**/*.ts"], + "ignore": ["src/**/*.test.ts", "src/test/**"], + "ignoreDependencies": ["drizzle-kit", "@mastra/evals", "@mastra/server", "dotenv"] + }, + "packages/ui": { + "project": ["src/**/*.{ts,tsx}"] + }, + "apps/api": { + "project": ["src/**/*.ts"] + }, + "apps/web": { + "entry": ["src/main.tsx"], + "project": ["src/**/*.{ts,tsx}"], + "ignoreDependencies": [ + "@radix-ui/react-scroll-area", + "@radix-ui/react-slot", + "class-variance-authority", + "clsx", + "tailwind-merge" + ] + } + }, + "ignoreExportsUsedInFile": true, + "exclude": ["unresolved"] +} diff --git a/package.json b/package.json index 507a37d..00fce1f 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,8 @@ "check": "turbo check", "type-check": "turbo type-check", "format": "biome format .", - "format:fix": "biome format --write ." + "format:fix": "biome format --write .", + "knip": "knip" }, "keywords": [ "meal-planning", @@ -50,6 +51,7 @@ "@types/node": "^25.0.3", "@vitest/coverage-v8": "^4.0.16", "drizzle-kit": "^0.31.8", + "knip": "^5.81.0", "mastra": "^1.0.0-beta.12", "tsx": "^4.21.0", "turbo": "^2.7.4", diff --git a/packages/core/package.json b/packages/core/package.json index 19074c4..cd37938 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -29,6 +29,7 @@ "@mastra/observability": "^1.0.0-beta.8", "@mastra/server": "1.0.0-beta.17", "better-sqlite3": "^12.5.0", + "date-fns": "^4.1.0", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", diff --git a/packages/core/src/ai/agents/family-editor-agent.ts b/packages/core/src/ai/agents/family-editor-agent.ts index 5d71166..d0760cb 100644 --- a/packages/core/src/ai/agents/family-editor-agent.ts +++ b/packages/core/src/ai/agents/family-editor-agent.ts @@ -4,6 +4,7 @@ import { familyConfigTools, plannerConfigTools } from '../tools'; import { AGENT_INTRO, QUESTION_BUNDLING_GUIDELINES, + RESPONSE_REQUIREMENT, RESPONSE_STYLE, UUID_HANDLING, } from '../prompts/shared-instructions'; @@ -44,6 +45,8 @@ ${QUESTION_BUNDLING_GUIDELINES} ${RESPONSE_STYLE} +${RESPONSE_REQUIREMENT} + ## Examples: **User:** "Add Sophie, born in 2016, allergic to peanuts" diff --git a/packages/core/src/ai/agents/index.ts b/packages/core/src/ai/agents/index.ts index e948c26..425bbf2 100644 --- a/packages/core/src/ai/agents/index.ts +++ b/packages/core/src/ai/agents/index.ts @@ -2,4 +2,3 @@ export { onboardingAgent } from './onboarding-agent.js'; export { familyEditorAgent } from './family-editor-agent.js'; export { mealPlannerAgent } from './meal-planner-agent.js'; -export { callMealPlannerAgent, type MealPlannerOptions } from './meal-planner-helper.js'; diff --git a/packages/core/src/ai/agents/meal-planner-agent.ts b/packages/core/src/ai/agents/meal-planner-agent.ts index 029df87..ef90299 100644 --- a/packages/core/src/ai/agents/meal-planner-agent.ts +++ b/packages/core/src/ai/agents/meal-planner-agent.ts @@ -2,13 +2,21 @@ import { Agent } from '@mastra/core/agent'; import { createMealPlanning, getMealPlanning, + updateMealPlanning, createMealEvent, updateMealEvent, + deleteMealEvent, getMealEvents, + getAvailabilityForDateRangeTool, } from '../tools'; import { getFamilySummaryTool } from '../tools/family-summary-tool'; import { getCurrentDateTool } from '../tools/calendar-tools'; -import { AGENT_INTRO, RESPONSE_STYLE, UUID_HANDLING } from '../prompts/shared-instructions'; +import { + AGENT_INTRO, + RESPONSE_REQUIREMENT, + RESPONSE_STYLE, + UUID_HANDLING, +} from '../prompts/shared-instructions'; import { LANGUAGE_INSTRUCTIONS } from '../prompts/language-instructions'; import { mealPlannerMemory } from '../memory'; import type { AugusteRequestContext } from '../types/request-context.js'; @@ -25,7 +33,7 @@ export const mealPlannerAgent = new Agent({ return ` ${AGENT_INTRO} -Your goal is to create delicious, practical, and compliant meal plans for the family. +You are Auguste's Executive Chef and Meal Planner. Your goal is to create delicious, practical, and compliant meal plans for the family. ${UUID_HANDLING} @@ -39,6 +47,10 @@ CRITICAL RULES: 3. PREFERENCES: Prioritize meals that members like, avoid what they dislike. 4. LOGIC: If a member is not available for a meal, do not consider their preferences for that specific event. 5. TIME: Always use 'get-current-date' to establish "today" before making any temporal decisions. +6. NO OVERLAPPING DATES: Meal plannings for a family cannot have overlapping date ranges. + - If 'create-meal-planning' fails due to overlap, explain the conflict to the user. + - Suggest alternative dates that don't overlap with existing plannings. + - Use 'get-meal-planning' to check for existing plannings before creating new ones if needed. MEMORY & CONTEXT: - CRITICAL: The familyId for this conversation is: ${familyId} @@ -46,35 +58,68 @@ MEMORY & CONTEXT: - "family", "members", "plannerSettings", "memberAvailability": The agent system automatically manages these from tool outputs. - DO NOT attempt to "save" or "update" memory manually. There is no tool for that. - Use 'get-current-date' to establish "today". -- Always check "memberAvailability" (from context) before scheduling a meal. - Use "members" list (from context) to check for allergies and preferences. -WORKFLOW: -1. **Establish Perspective & Date**: - - ALWAYS start by calling 'get-current-date' to know what day it is today. - - Check if memory has family data. If not, call 'getFamilySummaryTool' with familyId="${familyId}". -2. **Proactive Planning (DEFAULT)**: - - **If the user asks to plan meals or if no active plan exists, DEFAULT to planning for the next 7 days (the upcoming week) starting from today.** - - You do not need to ask "for how long?" - assume one week unless they specifically say otherwise. -3. **Analyze Schedule**: Check "memberAvailability" (from context) for each slot in that 7-day period. -4. **Draft & Save Plan**: - - Use 'create-meal-planning' to start the cycle for the identified 7-day range. - - For each day and meal type (from plannerSettings), suggest a meal based on participating members' preferences and allergies. - - Use 'create-meal-event' to save each suggested meal into the database. -5. **Present & Refine**: Show the complete 7-day plan to the user clearly and ask for any adjustments. +THREE-PHASE MEAL PLANNING WORKFLOW: + +**PHASE 1: EVENT DEFINITION (Skeleton Creation)** +Identify _when_ meals are needed and _who_ is eating. +- Call 'get-current-date' to establish today. +- Call 'get-family-summary' with familyId="${familyId}" to load family, members, and plannerSettings. +- Determine the 7-day planning range (default: next 7 days starting today). +- Call 'get-availability-for-date-range' to get who is available for each meal slot. +- Create a MealPlanning cycle with 'create-meal-planning' (status: 'draft'). +- For each day in plannerSettings.activeDays and each mealType in plannerSettings.mealTypes: + - Note which members are available (participants). + - If no members available, skip that slot unless settings dictate otherwise. + +**PHASE 2: CONTENT SUGGESTION (Menu Planning)** +For each slot, select a meal considering all participant constraints. +- Filter meals/recipes based on ALL participants' constraints: + - **Hard Constraint**: Allergies (if ANY participant has an allergy, that ingredient is forbidden). + - **Hard Constraint**: Dietary restrictions (if one participant is vegan, the meal must be vegan). +- Score and select meals based on participants' likes (prioritize) and dislikes (avoid). +- Use 'create-meal-event' to save each meal with recipeName and participants. +- Handle edge cases: + - **Conflicting restrictions** (carnivore + vegan): Suggest "build your own" style (tacos, bowls). + - **Empty preferences**: Suggest generally popular, healthy meals suited to family's skill level. + +**PHASE 3: REVIEW & REFINEMENT (Interactive)** +Present the plan and iterate based on user feedback. +- Display the complete weekly plan grouped by day: + - Show date, meal type, recipe name, participants. +- Ask user for feedback: "Does this look good? Any changes?" +- Handle change requests: + - "Change Wednesday dinner to salad" -> Use 'update-meal-event' to change recipeName. + - "Mike won't be home Tuesday" -> Use 'update-meal-event' to remove participant, possibly re-suggest meal. + - "Remove Friday lunch" -> Use 'delete-meal-event'. +- When user approves, use 'update-meal-planning' to change status from 'draft' to 'active'. + +PROACTIVE PLANNING DEFAULT: +- If the user asks to plan meals or no active plan exists, DEFAULT to planning for the next 7 days. +- Do not ask "for how long?" - assume one week unless they specify otherwise. +- Be proactive: suggest a diverse menu with variety throughout the week. + +${RESPONSE_REQUIREMENT} Tone: Professional, warm, encouraging, like a Michelin-star chef who cares about family time. `; }, - model: 'openrouter/google/gemini-3-pro-preview', + model: 'openrouter/google/gemini-2.5-pro', memory: mealPlannerMemory, tools: { + // Phase 1: Event Definition + getCurrentDateTool, + getFamilySummaryTool, + getAvailabilityForDateRangeTool, createMealPlanning, getMealPlanning, + // Phase 2: Content Suggestion createMealEvent, - updateMealEvent, getMealEvents, - getFamilySummaryTool, - getCurrentDateTool, + // Phase 3: Review & Refinement + updateMealEvent, + deleteMealEvent, + updateMealPlanning, }, }); diff --git a/packages/core/src/ai/agents/meal-planner-helper.ts b/packages/core/src/ai/agents/meal-planner-helper.ts deleted file mode 100644 index 753cb19..0000000 --- a/packages/core/src/ai/agents/meal-planner-helper.ts +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Helper functions for calling the meal planner agent with familyId - */ - -import { RequestContext } from '@mastra/core/request-context'; -import { randomUUID } from 'crypto'; -import { mealPlannerAgent } from './meal-planner-agent'; -import type { AugusteRequestContext } from '../types/request-context.js'; - -/** - * Options for calling the meal planner agent - */ -export interface MealPlannerOptions { - /** - * The family ID to create a meal plan for - */ - familyId: string; - - /** - * Optional thread ID for conversation continuity - * If not provided, a new thread will be created - */ - threadId?: string; - - /** - * Optional additional request context - */ - requestContext?: RequestContext; -} - -/** - * Call the meal planner agent with a familyId - * - * This helper function sets up the proper memory scoping and request context - * to ensure the agent has access to the family's data. - * - * @param message - The message to send to the agent - * @param options - Options including familyId and optional threadId - * @returns The agent's response - * - * @example - * ```typescript - * const response = await callMealPlannerAgent( - * 'Create a meal plan for next week', - * { familyId: 'abc-123' } - * ); - * console.log(response.text); - * ``` - */ -export async function callMealPlannerAgent( - message: string, - options: MealPlannerOptions, -): Promise { - const { - familyId, - threadId = randomUUID(), - requestContext = new RequestContext(), - } = options; - - // Set familyId in request context for tools to access - requestContext.set('familyId', familyId); - - return await mealPlannerAgent.generate(message, { - memory: { - thread: threadId, - resource: familyId, // Scope memory to this family - }, - requestContext, - }); -} diff --git a/packages/core/src/ai/agents/onboarding-agent.ts b/packages/core/src/ai/agents/onboarding-agent.ts index 840a76d..09fe445 100644 --- a/packages/core/src/ai/agents/onboarding-agent.ts +++ b/packages/core/src/ai/agents/onboarding-agent.ts @@ -4,6 +4,7 @@ import { familyConfigTools, plannerConfigTools, getFamilySummaryTool } from '../ import { AGENT_INTRO, QUESTION_BUNDLING_GUIDELINES, + RESPONSE_REQUIREMENT, RESPONSE_STYLE, UUID_HANDLING, } from '../prompts/shared-instructions'; @@ -51,6 +52,8 @@ ${QUESTION_BUNDLING_GUIDELINES} ${RESPONSE_STYLE} +${RESPONSE_REQUIREMENT} + ## Context: - CRITICAL: The familyId for this conversation is: ${familyId} - ALWAYS start by calling 'getFamilySummaryTool' with familyId="${familyId}" to establish current state. diff --git a/packages/core/src/ai/memory/index.ts b/packages/core/src/ai/memory/index.ts index af05768..f216242 100644 --- a/packages/core/src/ai/memory/index.ts +++ b/packages/core/src/ai/memory/index.ts @@ -8,11 +8,9 @@ export { onboardingMemory, createOnboardingMemory, OnboardingMemorySchema, - type OnboardingMemory, } from './onboarding-memory'; export { mealPlannerMemory, createMealPlannerMemory, MealPlannerMemorySchema, - type MealPlannerMemory, } from './meal-planner-memory'; diff --git a/packages/core/src/ai/memory/meal-planner-memory.ts b/packages/core/src/ai/memory/meal-planner-memory.ts index 16f44c4..3cb7419 100644 --- a/packages/core/src/ai/memory/meal-planner-memory.ts +++ b/packages/core/src/ai/memory/meal-planner-memory.ts @@ -61,8 +61,6 @@ export const MealPlannerMemorySchema = z.object({ notes: z.array(z.string()).default([]).describe('Context notes'), }); -export type MealPlannerMemory = z.infer; - export const createMealPlannerMemory = (storage?: LibSQLStore): Memory => { return new Memory({ storage: diff --git a/packages/core/src/ai/memory/onboarding-memory.ts b/packages/core/src/ai/memory/onboarding-memory.ts index f482ca8..521dd50 100644 --- a/packages/core/src/ai/memory/onboarding-memory.ts +++ b/packages/core/src/ai/memory/onboarding-memory.ts @@ -127,8 +127,6 @@ export const OnboardingMemorySchema = z.object({ notes: z.array(z.string()).default([]).describe('Important notes or context to remember'), }); -export type OnboardingMemory = z.infer; - /** * Create a new Memory instance configured for onboarding with structured schema. * diff --git a/packages/core/src/ai/processors/index.ts b/packages/core/src/ai/processors/index.ts deleted file mode 100644 index 65dfafa..0000000 --- a/packages/core/src/ai/processors/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Auguste Processors - * - * Custom processors for the Auguste meal planning application. - */ - -// Currently no custom processors diff --git a/packages/core/src/ai/prompts/index.ts b/packages/core/src/ai/prompts/index.ts deleted file mode 100644 index dc735d5..0000000 --- a/packages/core/src/ai/prompts/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Prompt components for Auguste agents - * Centralized location for all agent instruction snippets - */ - -// Shared instruction snippets -export { - AGENT_INTRO, - QUESTION_BUNDLING_GUIDELINES, - QUESTION_GUIDELINES, - RESPONSE_STYLE, - UUID_HANDLING, -} from './shared-instructions'; - -// Language instructions -export { LANGUAGE_INSTRUCTIONS } from './language-instructions'; diff --git a/packages/core/src/ai/prompts/shared-instructions.ts b/packages/core/src/ai/prompts/shared-instructions.ts index 7844908..917821e 100644 --- a/packages/core/src/ai/prompts/shared-instructions.ts +++ b/packages/core/src/ai/prompts/shared-instructions.ts @@ -35,20 +35,6 @@ export const QUESTION_BUNDLING_GUIDELINES = ` - Complex dietary/allergy info deserves individual attention for accuracy `; -/** - * Guidelines for how to format questions to users - */ -export const QUESTION_GUIDELINES = ` -## Question Format: -${QUESTION_BUNDLING_GUIDELINES} - -**Format Rules:** -- For bundled questions: Use a numbered list (1️⃣, 2️⃣, 3️⃣) or bullet points -- For single questions: Use emoji to highlight: 👉 📋 🤔 -- Provide examples in parentheses: _(e.g., "example")_ -- Always end with a clear call-to-action -`; - /** * Response style and personality guidelines */ @@ -69,3 +55,16 @@ export const UUID_HANDLING = ` - Always reference people by NAME in user-facing messages - Store UUIDs in memory for tool calls, but use names in responses `; + +/** + * Critical requirement to always respond with text after tool calls + */ +export const RESPONSE_REQUIREMENT = ` +## Critical: Always Respond to the User +- You MUST ALWAYS provide a text response to the user after using tools. +- NEVER end your turn without writing a message to the user. +- After each tool use, summarize what you learned or did. +- After completing a task, explain the result or ask for user input. +- Always conclude with a question or call to action for the user. +- Empty responses are NOT acceptable - always acknowledge what happened. +`; diff --git a/packages/core/src/ai/tools/availability-tools.test.ts b/packages/core/src/ai/tools/availability-tools.test.ts index d09823f..ffe6aac 100644 --- a/packages/core/src/ai/tools/availability-tools.test.ts +++ b/packages/core/src/ai/tools/availability-tools.test.ts @@ -6,6 +6,7 @@ import { setMemberAvailabilityByNameTool, bulkSetMemberAvailabilityByNameTool, getFamilyAvailabilityForMealTool, + getAvailabilityForDateRangeTool, } from './availability-tools'; import { createFamilyTool } from './family-tools'; import { createMemberTool } from './member-tools'; @@ -166,4 +167,82 @@ describe('Availability Tools', () => { }); expect(result.recordsSet).toBe(0); }); + + describe('getAvailabilityForDateRangeTool', () => { + it('should return availability for a date range', async () => { + // Set Jean as unavailable for dinner on Monday (dayOfWeek = 1) + await setMemberAvailabilityTool.execute({ + memberId, + mealType: MealType.dinner as any, + dayOfWeek: 1, + isAvailable: false, + }); + + // 2026-01-12 is a Monday, 2026-01-13 is a Tuesday + const result = await getAvailabilityForDateRangeTool.execute({ + familyId, + startDate: '2026-01-12', + endDate: '2026-01-13', + mealTypes: [MealType.dinner as any], + }); + + expect(result).toHaveLength(2); // 2 days, 1 meal type each + + // Monday dinner - Jean should be unavailable + const mondayDinner = result.find((r) => r.date === '2026-01-12'); + expect(mondayDinner?.unavailableMembers).toHaveLength(1); + expect(mondayDinner?.unavailableMembers[0].memberName).toBe('Jean'); + expect(mondayDinner?.availableMembers).toHaveLength(0); + + // Tuesday dinner - Jean should be available (default) + const tuesdayDinner = result.find((r) => r.date === '2026-01-13'); + expect(tuesdayDinner?.availableMembers).toHaveLength(1); + expect(tuesdayDinner?.availableMembers[0].memberName).toBe('Jean'); + expect(tuesdayDinner?.unavailableMembers).toHaveLength(0); + }); + + it('should return all meal types when not specified', async () => { + const result = await getAvailabilityForDateRangeTool.execute({ + familyId, + startDate: '2026-01-12', + endDate: '2026-01-12', + }); + + // 1 day, 3 meal types + expect(result).toHaveLength(3); + expect(result.map((r) => r.mealType).sort()).toEqual(['breakfast', 'dinner', 'lunch']); + }); + + it('should return empty array for family with no members', async () => { + // Create a new empty family + const emptyFamily = await createFamilyTool.execute({ + name: 'Empty Family', + country: 'FR', + language: 'fr', + }); + + const result = await getAvailabilityForDateRangeTool.execute({ + familyId: emptyFamily.id, + startDate: '2026-01-12', + endDate: '2026-01-14', + }); + + expect(result).toHaveLength(0); + }); + + it('should include dayOfWeek in results', async () => { + const result = await getAvailabilityForDateRangeTool.execute({ + familyId, + startDate: '2026-01-11', // Sunday + endDate: '2026-01-12', // Monday + mealTypes: [MealType.lunch as any], + }); + + const sunday = result.find((r) => r.date === '2026-01-11'); + const monday = result.find((r) => r.date === '2026-01-12'); + + expect(sunday?.dayOfWeek).toBe(0); // Sunday + expect(monday?.dayOfWeek).toBe(1); // Monday + }); + }); }); diff --git a/packages/core/src/ai/tools/availability-tools.ts b/packages/core/src/ai/tools/availability-tools.ts index 2a0a4e3..5a174ef 100644 --- a/packages/core/src/ai/tools/availability-tools.ts +++ b/packages/core/src/ai/tools/availability-tools.ts @@ -1,5 +1,5 @@ import { createTool } from '@mastra/core/tools'; -import { eq, and, sql } from 'drizzle-orm'; +import { eq, and, sql, inArray } from 'drizzle-orm'; import { z } from 'zod'; import { db, @@ -322,3 +322,115 @@ export const getFamilyAvailabilityForMealTool = createTool({ })); }, }); + +/** + * Get availability for a family within a date range. + * Translates day-of-week availability to specific dates. + */ +export const getAvailabilityForDateRangeTool = createTool({ + id: 'get-availability-for-date-range', + description: + 'Get member availability for each date in a range. Translates recurring day-of-week availability to specific calendar dates. Essential for meal planning to know who is available for each meal slot.', + inputSchema: z.object({ + familyId: z.uuid().describe('The family ID'), + startDate: z.string().describe('Start date in YYYY-MM-DD format'), + endDate: z.string().describe('End date in YYYY-MM-DD format'), + mealTypes: z + .array(z.enum([MealType.breakfast, MealType.lunch, MealType.dinner])) + .optional() + .describe('Filter by specific meal types. If not provided, returns all meal types.'), + }), + outputSchema: z.array( + z.object({ + date: z.string(), + dayOfWeek: z.number(), + mealType: z.string(), + availableMembers: z.array( + z.object({ + memberId: z.string(), + memberName: z.string(), + }), + ), + unavailableMembers: z.array( + z.object({ + memberId: z.string(), + memberName: z.string(), + }), + ), + }), + ), + execute: async ({ familyId, startDate, endDate, mealTypes }) => { + // Get all family members + const members = await db.query.member.findMany({ + where: eq(schema.member.familyId, familyId), + columns: { id: true, name: true }, + }); + + if (members.length === 0) { + return []; + } + + // Get all availability records for these members + const memberIds = members.map((m) => m.id); + const availabilityRecords = await db.query.memberAvailability.findMany({ + where: inArray(schema.memberAvailability.memberId, memberIds), + }); + + // Create a lookup map: memberId -> mealType -> dayOfWeek -> isAvailable + const availabilityMap = new Map>>(); + for (const record of availabilityRecords) { + if (!availabilityMap.has(record.memberId)) { + availabilityMap.set(record.memberId, new Map()); + } + const memberMap = availabilityMap.get(record.memberId)!; + if (!memberMap.has(record.mealType)) { + memberMap.set(record.mealType, new Map()); + } + memberMap.get(record.mealType)!.set(record.dayOfWeek, record.isAvailable); + } + + // Generate all dates in range + const result: Array<{ + date: string; + dayOfWeek: number; + mealType: string; + availableMembers: Array<{ memberId: string; memberName: string }>; + unavailableMembers: Array<{ memberId: string; memberName: string }>; + }> = []; + + const mealTypesToCheck = mealTypes ?? [MealType.breakfast, MealType.lunch, MealType.dinner]; + const start = new Date(startDate); + const end = new Date(endDate); + + for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { + const dateStr = d.toISOString().split('T')[0]; + const dayOfWeek = d.getDay(); // 0 = Sunday, 6 = Saturday + + for (const mealType of mealTypesToCheck) { + const availableMembers: Array<{ memberId: string; memberName: string }> = []; + const unavailableMembers: Array<{ memberId: string; memberName: string }> = []; + + for (const member of members) { + // Default to available if no record exists + const isAvailable = availabilityMap.get(member.id)?.get(mealType)?.get(dayOfWeek) ?? true; + + if (isAvailable) { + availableMembers.push({ memberId: member.id, memberName: member.name }); + } else { + unavailableMembers.push({ memberId: member.id, memberName: member.name }); + } + } + + result.push({ + date: dateStr, + dayOfWeek, + mealType, + availableMembers, + unavailableMembers, + }); + } + } + + return result; + }, +}); diff --git a/packages/core/src/ai/tools/index.ts b/packages/core/src/ai/tools/index.ts index b14f1f5..d8cf7f6 100644 --- a/packages/core/src/ai/tools/index.ts +++ b/packages/core/src/ai/tools/index.ts @@ -34,6 +34,7 @@ import { getFamilyAvailabilityForMealTool, setMemberAvailabilityByNameTool, bulkSetMemberAvailabilityByNameTool, + getAvailabilityForDateRangeTool, } from './availability-tools.js'; export { setMemberAvailabilityTool, @@ -42,6 +43,7 @@ export { getFamilyAvailabilityForMealTool, setMemberAvailabilityByNameTool, bulkSetMemberAvailabilityByNameTool, + getAvailabilityForDateRangeTool, }; // Planner settings tools @@ -111,8 +113,18 @@ export const plannerConfigTools = { import { createMealPlanning, getMealPlanning, + updateMealPlanning, createMealEvent, updateMealEvent, + deleteMealEvent, getMealEvents, } from './meal-tools.js'; -export { createMealPlanning, getMealPlanning, createMealEvent, updateMealEvent, getMealEvents }; +export { + createMealPlanning, + getMealPlanning, + updateMealPlanning, + createMealEvent, + updateMealEvent, + deleteMealEvent, + getMealEvents, +}; diff --git a/packages/core/src/ai/tools/meal-tools.test.ts b/packages/core/src/ai/tools/meal-tools.test.ts index ba04d59..53b8b2f 100644 --- a/packages/core/src/ai/tools/meal-tools.test.ts +++ b/packages/core/src/ai/tools/meal-tools.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { createMealPlanning, getMealPlanning, + updateMealPlanning, createMealEvent, updateMealEvent, + deleteMealEvent, getMealEvents, } from './meal-tools'; import { createFamilyTool } from './family-tools'; @@ -105,4 +107,81 @@ describe('Meal Tools', () => { updateMealEvent.execute({ id: '00000000-0000-4000-8000-000000000000' }), ).rejects.toThrow('Failed to update meal event'); }); + + it('should update meal planning status', async () => { + const planning = await createMealPlanning.execute({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + status: 'draft', + }); + + expect(planning.status).toBe('draft'); + + const updated = await updateMealPlanning.execute({ + id: planning.id, + status: 'active', + }); + + expect(updated.status).toBe('active'); + + const completed = await updateMealPlanning.execute({ + id: planning.id, + status: 'completed', + }); + + expect(completed.status).toBe('completed'); + }); + + it('should throw error when updating non-existent meal planning', async () => { + await expect( + updateMealPlanning.execute({ + id: '00000000-0000-4000-8000-000000000000', + status: 'active', + }), + ).rejects.toThrow('not found'); + }); + + it('should delete a meal event', async () => { + const planning = await createMealPlanning.execute({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + const event = await createMealEvent.execute({ + familyId, + planningId: planning.id, + date: '2026-01-02', + mealType: MealType.dinner as any, + recipeName: 'To be deleted', + }); + + // Verify event exists + const eventsBefore = await getMealEvents.execute({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + expect(eventsBefore).toHaveLength(1); + + // Delete the event + const result = await deleteMealEvent.execute({ id: event.id }); + expect(result.success).toBe(true); + expect(result.deletedId).toBe(event.id); + + // Verify event is deleted + const eventsAfter = await getMealEvents.execute({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + expect(eventsAfter).toHaveLength(0); + }); + + it('should throw error when deleting non-existent meal event', async () => { + await expect( + deleteMealEvent.execute({ id: '00000000-0000-4000-8000-000000000000' }), + ).rejects.toThrow('Failed to delete meal event'); + }); }); diff --git a/packages/core/src/ai/tools/meal-tools.ts b/packages/core/src/ai/tools/meal-tools.ts index caac115..eadad24 100644 --- a/packages/core/src/ai/tools/meal-tools.ts +++ b/packages/core/src/ai/tools/meal-tools.ts @@ -4,36 +4,49 @@ import { z } from 'zod'; import { db, schema, generateId, now } from '../../domain'; import { CreateMealPlanningInputSchema, - CreateMealEventInputSchema, MealPlanningSchema, MealEventSchema, -} from '../../domain/schemas'; +} from '../../domain/schemas/meal-planner.schema'; +import { + createMealPlanning as createMealPlanningService, + updateMealPlanning as updateMealPlanningService, + findOrCreateMealPlanningForDate, + MealPlanningOverlapError, +} from '../../domain/services/meal-planning-service'; + +// Input schema for createMealEvent - planningId is optional because we auto-find/create it +const CreateMealEventToolInputSchema = z.object({ + familyId: z.string().uuid().describe('The family ID'), + date: z.string().describe('The date of the meal (YYYY-MM-DD)'), + mealType: z.enum(['breakfast', 'lunch', 'dinner']).describe('The type of meal'), + recipeName: z.string().optional().describe('The name of the recipe'), + participants: z.array(z.string().uuid()).optional().describe('Array of member IDs participating'), + planningId: z + .string() + .uuid() + .optional() + .describe('Optional: The meal planning ID. If not provided, will auto-find or create one.'), +}); export const createMealPlanning = createTool({ id: 'create-meal-planning', - description: 'Create a new weekly meal planning cycle', + description: + 'Create a new weekly meal planning cycle. Will fail if the date range overlaps with an existing planning for the same family.', inputSchema: CreateMealPlanningInputSchema, outputSchema: MealPlanningSchema, - execute: async ({ familyId, startDate, endDate, status }) => { - const id = generateId(); - const timestamp = now(); - + execute: async (input) => { try { - const [planning] = await db - .insert(schema.mealPlanning) - .values({ - id, - familyId, - startDate, - endDate, - status: status || 'draft', - createdAt: timestamp, - updatedAt: timestamp, - }) - .returning(); - - return planning; + return await createMealPlanningService(input); } catch (error) { + if (error instanceof MealPlanningOverlapError) { + // Provide a user-friendly error message for the AI agent + const dates = error.conflictingPlannings + .map((p) => `${p.startDate} to ${p.endDate}`) + .join(', '); + throw new Error( + `Cannot create meal planning: date range overlaps with existing planning(s): ${dates}. Please choose different dates.`, + ); + } console.error('Error creating meal planning:', error); throw new Error('Failed to create meal planning'); } @@ -60,20 +73,28 @@ export const getMealPlanning = createTool({ export const createMealEvent = createTool({ id: 'create-meal-event', - description: 'Create a single meal event', - inputSchema: CreateMealEventInputSchema, + description: + 'Create a single meal event. The event will be automatically associated with a meal planning. If no planning exists for the event date, a new weekly planning will be created.', + inputSchema: CreateMealEventToolInputSchema, outputSchema: MealEventSchema, execute: async ({ familyId, planningId, date, mealType, recipeName, participants }) => { const id = generateId(); const timestamp = now(); try { + // Find or create a meal planning for this date + let effectivePlanningId = planningId; + if (!effectivePlanningId) { + const planning = await findOrCreateMealPlanningForDate(familyId, date); + effectivePlanningId = planning.id; + } + const [event] = await db .insert(schema.mealEvent) .values({ id, familyId, - planningId, + planningId: effectivePlanningId, date, mealType, recipeName, @@ -154,3 +175,62 @@ export const getMealEvents = createTool({ } }, }); + +export const updateMealPlanning = createTool({ + id: 'update-meal-planning', + description: + 'Update a meal planning cycle. Can update status (draft/active/completed) or dates. Will fail if new dates overlap with another planning.', + inputSchema: z.object({ + id: z.string().describe('The meal planning ID'), + status: z + .enum(['draft', 'active', 'completed']) + .optional() + .describe('New status for the meal planning'), + startDate: z.string().optional().describe('New start date (YYYY-MM-DD)'), + endDate: z.string().optional().describe('New end date (YYYY-MM-DD)'), + }), + outputSchema: MealPlanningSchema, + execute: async ({ id, status, startDate, endDate }) => { + try { + return await updateMealPlanningService(id, { status, startDate, endDate }); + } catch (error) { + if (error instanceof MealPlanningOverlapError) { + const dates = error.conflictingPlannings + .map((p) => `${p.startDate} to ${p.endDate}`) + .join(', '); + throw new Error( + `Cannot update meal planning: new date range overlaps with existing planning(s): ${dates}. Please choose different dates.`, + ); + } + console.error('Error updating meal planning:', error); + throw error; + } + }, +}); + +export const deleteMealEvent = createTool({ + id: 'delete-meal-event', + description: + 'Delete a meal event. Used during the refinement phase when user wants to remove a planned meal.', + inputSchema: z.object({ + id: z.string().describe('The meal event ID to delete'), + }), + outputSchema: z.object({ + success: z.boolean(), + deletedId: z.string(), + }), + execute: async ({ id }) => { + try { + const result = await db.delete(schema.mealEvent).where(eq(schema.mealEvent.id, id)); + + if (result.changes === 0) { + throw new Error(`Meal event with id ${id} not found`); + } + + return { success: true, deletedId: id }; + } catch (error) { + console.error('Error deleting meal event:', error); + throw new Error('Failed to delete meal event'); + } + }, +}); diff --git a/packages/core/src/domain/db/migrations/0004_make_planningId_required.sql b/packages/core/src/domain/db/migrations/0004_make_planningId_required.sql new file mode 100644 index 0000000..83de370 --- /dev/null +++ b/packages/core/src/domain/db/migrations/0004_make_planningId_required.sql @@ -0,0 +1,22 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_MealEvent` ( + `id` text PRIMARY KEY NOT NULL, + `familyId` text NOT NULL, + `planningId` text NOT NULL, + `date` text NOT NULL, + `mealType` text NOT NULL, + `recipeName` text, + `participants` text DEFAULT '[]' NOT NULL, + `createdAt` text DEFAULT (datetime('now')) NOT NULL, + `updatedAt` text DEFAULT (datetime('now')) NOT NULL, + FOREIGN KEY (`familyId`) REFERENCES `Family`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`planningId`) REFERENCES `MealPlanning`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_MealEvent`("id", "familyId", "planningId", "date", "mealType", "recipeName", "participants", "createdAt", "updatedAt") SELECT "id", "familyId", "planningId", "date", "mealType", "recipeName", "participants", "createdAt", "updatedAt" FROM `MealEvent`;--> statement-breakpoint +DROP TABLE `MealEvent`;--> statement-breakpoint +ALTER TABLE `__new_MealEvent` RENAME TO `MealEvent`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE INDEX `idx_event_familyId` ON `MealEvent` (`familyId`);--> statement-breakpoint +CREATE INDEX `idx_event_planningId` ON `MealEvent` (`planningId`);--> statement-breakpoint +CREATE INDEX `idx_event_date` ON `MealEvent` (`date`); \ No newline at end of file diff --git a/packages/core/src/domain/db/migrations/meta/0004_snapshot.json b/packages/core/src/domain/db/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..f025787 --- /dev/null +++ b/packages/core/src/domain/db/migrations/meta/0004_snapshot.json @@ -0,0 +1,582 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c7fbfe40-6ddd-4f35-987e-6449cf75689c", + "prevId": "8324bfb4-67db-496a-8e34-30fd1a06ef5d", + "tables": { + "Family": { + "name": "Family", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "MealEvent": { + "name": "MealEvent", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planningId": { + "name": "planningId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mealType": { + "name": "mealType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipeName": { + "name": "recipeName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "participants": { + "name": "participants", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_event_familyId": { + "name": "idx_event_familyId", + "columns": [ + "familyId" + ], + "isUnique": false + }, + "idx_event_planningId": { + "name": "idx_event_planningId", + "columns": [ + "planningId" + ], + "isUnique": false + }, + "idx_event_date": { + "name": "idx_event_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": { + "MealEvent_familyId_Family_id_fk": { + "name": "MealEvent_familyId_Family_id_fk", + "tableFrom": "MealEvent", + "tableTo": "Family", + "columnsFrom": [ + "familyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "MealEvent_planningId_MealPlanning_id_fk": { + "name": "MealEvent_planningId_MealPlanning_id_fk", + "tableFrom": "MealEvent", + "tableTo": "MealPlanning", + "columnsFrom": [ + "planningId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "MealPlanning": { + "name": "MealPlanning", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "startDate": { + "name": "startDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endDate": { + "name": "endDate", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_planning_familyId": { + "name": "idx_planning_familyId", + "columns": [ + "familyId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "MealPlanning_familyId_Family_id_fk": { + "name": "MealPlanning_familyId_Family_id_fk", + "tableFrom": "MealPlanning", + "tableTo": "Family", + "columnsFrom": [ + "familyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "Member": { + "name": "Member", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "birthdate": { + "name": "birthdate", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dietaryRestrictions": { + "name": "dietaryRestrictions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "allergies": { + "name": "allergies", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "foodPreferencesLikes": { + "name": "foodPreferencesLikes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "foodPreferencesDislikes": { + "name": "foodPreferencesDislikes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "cookingSkillLevel": { + "name": "cookingSkillLevel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'none'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "idx_member_familyId": { + "name": "idx_member_familyId", + "columns": [ + "familyId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "Member_familyId_Family_id_fk": { + "name": "Member_familyId_Family_id_fk", + "tableFrom": "Member", + "tableTo": "Family", + "columnsFrom": [ + "familyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "MemberAvailability": { + "name": "MemberAvailability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "memberId": { + "name": "memberId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mealType": { + "name": "mealType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dayOfWeek": { + "name": "dayOfWeek", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isAvailable": { + "name": "isAvailable", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "idx_availability_memberId": { + "name": "idx_availability_memberId", + "columns": [ + "memberId" + ], + "isUnique": false + }, + "MemberAvailability_memberId_mealType_dayOfWeek_unique": { + "name": "MemberAvailability_memberId_mealType_dayOfWeek_unique", + "columns": [ + "memberId", + "mealType", + "dayOfWeek" + ], + "isUnique": true + } + }, + "foreignKeys": { + "MemberAvailability_memberId_Member_id_fk": { + "name": "MemberAvailability_memberId_Member_id_fk", + "tableFrom": "MemberAvailability", + "tableTo": "Member", + "columnsFrom": [ + "memberId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "PlannerSettings": { + "name": "PlannerSettings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "familyId": { + "name": "familyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mealTypes": { + "name": "mealTypes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"lunch\", \"dinner\"]'" + }, + "activeDays": { + "name": "activeDays", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[0, 1, 2, 3, 4, 5, 6]'" + }, + "notificationCron": { + "name": "notificationCron", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0 18 * * 0'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'UTC'" + }, + "createdAt": { + "name": "createdAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "updatedAt": { + "name": "updatedAt", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "PlannerSettings_familyId_unique": { + "name": "PlannerSettings_familyId_unique", + "columns": [ + "familyId" + ], + "isUnique": true + }, + "idx_settings_familyId": { + "name": "idx_settings_familyId", + "columns": [ + "familyId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "PlannerSettings_familyId_Family_id_fk": { + "name": "PlannerSettings_familyId_Family_id_fk", + "tableFrom": "PlannerSettings", + "tableTo": "Family", + "columnsFrom": [ + "familyId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/core/src/domain/db/migrations/meta/_journal.json b/packages/core/src/domain/db/migrations/meta/_journal.json index fb50229..043cdce 100644 --- a/packages/core/src/domain/db/migrations/meta/_journal.json +++ b/packages/core/src/domain/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1768236853999, "tag": "0003_remove_default_servings", "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1768411778986, + "tag": "0004_make_planningId_required", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/core/src/domain/db/schema.drizzle.ts b/packages/core/src/domain/db/schema.drizzle.ts index 7016703..3857f9e 100644 --- a/packages/core/src/domain/db/schema.drizzle.ts +++ b/packages/core/src/domain/db/schema.drizzle.ts @@ -124,9 +124,9 @@ export const mealEvent = sqliteTable( familyId: text('familyId') .notNull() .references(() => family.id, { onDelete: 'cascade' }), - planningId: text('planningId').references(() => mealPlanning.id, { - onDelete: 'set null', - }), + planningId: text('planningId') + .notNull() + .references(() => mealPlanning.id, { onDelete: 'cascade' }), date: text('date').notNull(), // YYYY-MM-DD mealType: text('mealType', { enum: Object.values(MealType) as [string, ...string[]], diff --git a/packages/core/src/domain/schemas/meal-planner.schema.ts b/packages/core/src/domain/schemas/meal-planner.schema.ts index 1243aad..bf922d8 100644 --- a/packages/core/src/domain/schemas/meal-planner.schema.ts +++ b/packages/core/src/domain/schemas/meal-planner.schema.ts @@ -21,7 +21,7 @@ export const CreateMealEventInputSchema = createInsertSchema(mealEvent) .extend({ participants: z.array(z.string().uuid()).optional(), recipeName: z.string().optional(), - planningId: z.string().uuid().optional(), + planningId: z.string().uuid(), // Required - events must belong to a planning }) .omit({ id: true, createdAt: true, updatedAt: true }); export type CreateMealEventInput = z.infer; diff --git a/packages/core/src/domain/services/index.ts b/packages/core/src/domain/services/index.ts index e14127a..4ec7aff 100644 --- a/packages/core/src/domain/services/index.ts +++ b/packages/core/src/domain/services/index.ts @@ -1,3 +1,4 @@ export * from './family-service.js'; export * from './member-service.js'; export * from './planner-service.js'; +export * from './meal-planning-service.js'; diff --git a/packages/core/src/domain/services/meal-planning-service.test.ts b/packages/core/src/domain/services/meal-planning-service.test.ts new file mode 100644 index 0000000..7091b38 --- /dev/null +++ b/packages/core/src/domain/services/meal-planning-service.test.ts @@ -0,0 +1,283 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createMealPlanning, + updateMealPlanning, + findOverlappingMealPlannings, + MealPlanningOverlapError, +} from './meal-planning-service'; +import { db } from '../db'; +import { family, mealPlanning } from '../db/schema.drizzle'; +import { generateId, now } from '../db'; + +describe('meal-planning-service', () => { + let familyId: string; + + beforeEach(async () => { + // Clean up tables + await db.delete(mealPlanning); + await db.delete(family); + + // Create test family + const id = generateId(); + const timestamp = now(); + await db.insert(family).values({ + id, + name: 'Test Family', + country: 'FR', + language: 'fr', + createdAt: timestamp, + updatedAt: timestamp, + }); + familyId = id; + }); + + describe('createMealPlanning', () => { + it('should create a meal planning successfully', async () => { + const planning = await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + status: 'draft', + }); + + expect(planning.id).toBeDefined(); + expect(planning.familyId).toBe(familyId); + expect(planning.startDate).toBe('2026-01-01'); + expect(planning.endDate).toBe('2026-01-07'); + expect(planning.status).toBe('draft'); + }); + + it('should create non-overlapping plannings successfully', async () => { + // Week 1: Jan 1-7 + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + // Week 2: Jan 8-14 (adjacent, no overlap) + const planning2 = await createMealPlanning({ + familyId, + startDate: '2026-01-08', + endDate: '2026-01-14', + }); + + expect(planning2.startDate).toBe('2026-01-08'); + }); + + it('should throw MealPlanningOverlapError for exact same dates', async () => { + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + await expect( + createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should throw MealPlanningOverlapError for partial overlap (new ends during existing)', async () => { + // Existing: Jan 5-12 + await createMealPlanning({ + familyId, + startDate: '2026-01-05', + endDate: '2026-01-12', + }); + + // New: Jan 1-7 (overlaps on Jan 5-7) + await expect( + createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should throw MealPlanningOverlapError for partial overlap (new starts during existing)', async () => { + // Existing: Jan 1-7 + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + // New: Jan 5-12 (overlaps on Jan 5-7) + await expect( + createMealPlanning({ + familyId, + startDate: '2026-01-05', + endDate: '2026-01-12', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should throw MealPlanningOverlapError when new is fully inside existing', async () => { + // Existing: Jan 1-14 + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-14', + }); + + // New: Jan 5-10 (fully inside) + await expect( + createMealPlanning({ + familyId, + startDate: '2026-01-05', + endDate: '2026-01-10', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should throw MealPlanningOverlapError when new fully contains existing', async () => { + // Existing: Jan 5-10 + await createMealPlanning({ + familyId, + startDate: '2026-01-05', + endDate: '2026-01-10', + }); + + // New: Jan 1-14 (fully contains) + await expect( + createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-14', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should include conflicting planning details in error', async () => { + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + try { + await createMealPlanning({ + familyId, + startDate: '2026-01-05', + endDate: '2026-01-12', + }); + expect.fail('Should have thrown MealPlanningOverlapError'); + } catch (error) { + expect(error).toBeInstanceOf(MealPlanningOverlapError); + const overlapError = error as MealPlanningOverlapError; + expect(overlapError.conflictingPlannings).toHaveLength(1); + expect(overlapError.conflictingPlannings[0].startDate).toBe('2026-01-01'); + expect(overlapError.message).toContain('2026-01-01'); + } + }); + }); + + describe('updateMealPlanning', () => { + it('should update status without overlap check', async () => { + const planning = await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + status: 'draft', + }); + + const updated = await updateMealPlanning(planning.id, { status: 'active' }); + + expect(updated.status).toBe('active'); + }); + + it('should allow updating dates to non-overlapping range', async () => { + const planning = await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + const updated = await updateMealPlanning(planning.id, { + startDate: '2026-01-08', + endDate: '2026-01-14', + }); + + expect(updated.startDate).toBe('2026-01-08'); + expect(updated.endDate).toBe('2026-01-14'); + }); + + it('should throw when updating dates to overlap with another planning', async () => { + // Create two plannings + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + const planning2 = await createMealPlanning({ + familyId, + startDate: '2026-01-15', + endDate: '2026-01-21', + }); + + // Try to move planning2 to overlap with planning1 + await expect( + updateMealPlanning(planning2.id, { + startDate: '2026-01-05', + endDate: '2026-01-10', + }), + ).rejects.toThrow(MealPlanningOverlapError); + }); + + it('should throw error when planning not found', async () => { + await expect(updateMealPlanning('non-existent-id', { status: 'active' })).rejects.toThrow( + 'not found', + ); + }); + }); + + describe('findOverlappingMealPlannings', () => { + it('should return empty array when no overlaps', async () => { + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + const overlaps = await findOverlappingMealPlannings(familyId, '2026-01-15', '2026-01-21'); + + expect(overlaps).toHaveLength(0); + }); + + it('should return overlapping plannings', async () => { + await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + const overlaps = await findOverlappingMealPlannings(familyId, '2026-01-05', '2026-01-12'); + + expect(overlaps).toHaveLength(1); + }); + + it('should exclude specified planning ID', async () => { + const planning = await createMealPlanning({ + familyId, + startDate: '2026-01-01', + endDate: '2026-01-07', + }); + + // Same dates but excluding itself - should find no overlap + const overlaps = await findOverlappingMealPlannings( + familyId, + '2026-01-01', + '2026-01-07', + planning.id, + ); + + expect(overlaps).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/src/domain/services/meal-planning-service.ts b/packages/core/src/domain/services/meal-planning-service.ts new file mode 100644 index 0000000..f053ad6 --- /dev/null +++ b/packages/core/src/domain/services/meal-planning-service.ts @@ -0,0 +1,207 @@ +import { db } from '../db'; +import { mealPlanning } from '../db/schema.drizzle'; +import { eq, and, lte, gte, ne } from 'drizzle-orm'; +import { generateId, now } from '../db'; +import { startOfWeek, endOfWeek, format } from 'date-fns'; +import type { MealPlanning, CreateMealPlanningInput } from '../schemas/meal-planner.schema'; + +/** + * Error thrown when attempting to create/update a meal planning + * with dates that overlap an existing planning for the same family. + */ +export class MealPlanningOverlapError extends Error { + constructor( + public readonly conflictingPlannings: MealPlanning[], + message?: string, + ) { + const defaultMessage = `Date range overlaps with existing meal planning(s): ${conflictingPlannings.map((p) => `${p.startDate} to ${p.endDate}`).join(', ')}`; + super(message || defaultMessage); + this.name = 'MealPlanningOverlapError'; + } +} + +/** + * Check if a date range overlaps with existing meal plannings for a family. + * Overlap condition: existingStart <= newEnd AND existingEnd >= newStart + * + * @param familyId - The family ID + * @param startDate - Start date (YYYY-MM-DD) + * @param endDate - End date (YYYY-MM-DD) + * @param excludeId - Optional planning ID to exclude (for updates) + * @returns Array of overlapping plannings, or empty array if none + */ +export async function findOverlappingMealPlannings( + familyId: string, + startDate: string, + endDate: string, + excludeId?: string, +): Promise { + // Overlap condition: existingStart <= newEnd AND existingEnd >= newStart + const conditions = [ + eq(mealPlanning.familyId, familyId), + lte(mealPlanning.startDate, endDate), + gte(mealPlanning.endDate, startDate), + ]; + + // Exclude a specific planning (used for updates) + if (excludeId) { + conditions.push(ne(mealPlanning.id, excludeId)); + } + + const overlapping = await db + .select() + .from(mealPlanning) + .where(and(...conditions)); + + return overlapping as MealPlanning[]; +} + +/** + * Create a new meal planning with overlap validation. + * Throws MealPlanningOverlapError if the date range overlaps with existing plannings. + * + * @param input - The meal planning input data + * @returns The created meal planning + * @throws MealPlanningOverlapError if dates overlap with existing planning + */ +export async function createMealPlanning(input: CreateMealPlanningInput): Promise { + const { familyId, startDate, endDate, status } = input; + + // Check for overlapping plannings + const overlapping = await findOverlappingMealPlannings(familyId, startDate, endDate); + + if (overlapping.length > 0) { + throw new MealPlanningOverlapError(overlapping); + } + + const id = generateId(); + const timestamp = now(); + + const [planning] = await db + .insert(mealPlanning) + .values({ + id, + familyId, + startDate, + endDate, + status: status || 'draft', + createdAt: timestamp, + updatedAt: timestamp, + }) + .returning(); + + return planning as MealPlanning; +} + +/** + * Update a meal planning with overlap validation (if dates are changed). + * + * @param id - The meal planning ID to update + * @param updates - The fields to update + * @returns The updated meal planning + * @throws MealPlanningOverlapError if new dates overlap with existing planning + * @throws Error if meal planning not found + */ +export async function updateMealPlanning( + id: string, + updates: Partial>, +): Promise { + // First, get the existing planning to check dates + const [existing] = await db.select().from(mealPlanning).where(eq(mealPlanning.id, id)); + + if (!existing) { + throw new Error(`Meal planning with id ${id} not found`); + } + + // If dates are being changed, check for overlaps + const newStartDate = updates.startDate || existing.startDate; + const newEndDate = updates.endDate || existing.endDate; + + if (updates.startDate || updates.endDate) { + const overlapping = await findOverlappingMealPlannings( + existing.familyId, + newStartDate, + newEndDate, + id, // Exclude current planning from overlap check + ); + + if (overlapping.length > 0) { + throw new MealPlanningOverlapError(overlapping); + } + } + + const timestamp = now(); + + const [updated] = await db + .update(mealPlanning) + .set({ + ...updates, + updatedAt: timestamp, + }) + .where(eq(mealPlanning.id, id)) + .returning(); + + return updated as MealPlanning; +} + +/** + * Find an existing meal planning that contains a specific date. + * + * @param familyId - The family ID + * @param date - The date to find a planning for (YYYY-MM-DD) + * @returns The meal planning containing the date, or null if none exists + */ +export async function findMealPlanningForDate( + familyId: string, + date: string, +): Promise { + const [planning] = await db + .select() + .from(mealPlanning) + .where( + and( + eq(mealPlanning.familyId, familyId), + lte(mealPlanning.startDate, date), + gte(mealPlanning.endDate, date), + ), + ) + .limit(1); + + return (planning as MealPlanning) || null; +} + +/** + * Find an existing meal planning for a date, or create a new weekly planning if none exists. + * The new planning will span the week (Sunday to Saturday) containing the given date. + * + * @param familyId - The family ID + * @param date - The date to find/create a planning for (YYYY-MM-DD) + * @returns The existing or newly created meal planning + */ +export async function findOrCreateMealPlanningForDate( + familyId: string, + date: string, +): Promise { + // First, try to find an existing planning for this date + const existing = await findMealPlanningForDate(familyId, date); + if (existing) { + return existing; + } + + // No planning exists - create a new weekly planning + // Calculate the week boundaries (Sunday to Saturday) + const dateObj = new Date(date); + const weekStart = startOfWeek(dateObj, { weekStartsOn: 0 }); // Sunday + const weekEnd = endOfWeek(dateObj, { weekStartsOn: 0 }); // Saturday + + const startDate = format(weekStart, 'yyyy-MM-dd'); + const endDate = format(weekEnd, 'yyyy-MM-dd'); + + // Create the new planning (uses the service function with overlap validation) + return createMealPlanning({ + familyId, + startDate, + endDate, + status: 'draft', + }); +} diff --git a/packages/core/src/domain/services/planner-service.ts b/packages/core/src/domain/services/planner-service.ts index db2f1df..82d1dfe 100644 --- a/packages/core/src/domain/services/planner-service.ts +++ b/packages/core/src/domain/services/planner-service.ts @@ -1,6 +1,6 @@ import { db } from '../db'; -import { memberAvailability, plannerSettings } from '../db/schema.drizzle'; -import { eq } from 'drizzle-orm'; +import { memberAvailability, plannerSettings, mealPlanning, mealEvent } from '../db/schema.drizzle'; +import { eq, desc } from 'drizzle-orm'; /** * Get availability for all members in a family @@ -33,3 +33,34 @@ export async function getPlannerSettingsByFamilyId(familyId: string) { .limit(1); return result[0] || null; } + +/** + * Get the most recent meal planning for a family + */ +export async function getMealPlanningByFamilyId(familyId: string) { + const result = await db + .select() + .from(mealPlanning) + .where(eq(mealPlanning.familyId, familyId)) + .orderBy(desc(mealPlanning.createdAt)) + .limit(1); + return result[0] || null; +} + +/** + * Get all meal plannings for a family, ordered by start date descending + */ +export async function getAllMealPlanningsByFamilyId(familyId: string) { + return db + .select() + .from(mealPlanning) + .where(eq(mealPlanning.familyId, familyId)) + .orderBy(desc(mealPlanning.startDate)); +} + +/** + * Get all meal events for a family + */ +export async function getMealEventsByFamilyId(familyId: string) { + return db.select().from(mealEvent).where(eq(mealEvent.familyId, familyId)); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e14f507..cc180c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: drizzle-kit: specifier: ^0.31.8 version: 0.31.8 + knip: + specifier: ^5.81.0 + version: 5.81.0(@types/node@25.0.3)(typescript@5.9.3) mastra: specifier: ^1.0.0-beta.12 version: 1.0.0-beta.13(@mastra/core@1.0.0-beta.20)(typescript@5.9.3)(zod@4.3.5) @@ -78,6 +81,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.16 version: 5.90.16(react@19.2.3) + '@tanstack/react-router': + specifier: ^1.149.3 + version: 1.149.3(react-dom@19.2.3)(react@19.2.3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -163,6 +169,9 @@ importers: better-sqlite3: specifier: ^12.5.0 version: 12.5.0 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dotenv: specifier: ^17.2.3 version: 17.2.3 @@ -740,6 +749,31 @@ packages: resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} dev: true + /@emnapi/core@1.8.1: + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + requiresBuild: true + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + dev: true + optional: true + + /@emnapi/runtime@1.8.1: + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + + /@emnapi/wasi-threads@1.1.0: + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + /@esbuild-kit/core-utils@3.3.2: resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1808,6 +1842,16 @@ packages: - hono - supports-color + /@napi-rs/wasm-runtime@1.1.1: + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + requiresBuild: true + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + dev: true + optional: true + /@neon-rs/load@0.0.4: resolution: {integrity: sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw==} dev: false @@ -1849,6 +1893,168 @@ packages: magic-string: 0.30.21 dev: true + /@oxc-resolver/binding-android-arm-eabi@11.16.3: + resolution: {integrity: sha512-CVyWHu6ACDqDcJxR4nmGiG8vDF4TISJHqRNzac5z/gPQycs/QrP/1pDsJBy0MD7jSw8nVq2E5WqeHQKabBG/Jg==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-android-arm64@11.16.3: + resolution: {integrity: sha512-tTIoB7plLeh2o6Ay7NnV5CJb6QUXdxI7Shnsp2ECrLSV81k+oVE3WXYrQSh4ltWL75i0OgU5Bj3bsuyg5SMepw==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-darwin-arm64@11.16.3: + resolution: {integrity: sha512-OXKVH7uwYd3Rbw1s2yJZd6/w+6b01iaokZubYhDAq4tOYArr+YCS+lr81q1hsTPPRZeIsWE+rJLulmf1qHdYZA==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-darwin-x64@11.16.3: + resolution: {integrity: sha512-WwjQ4WdnCxVYZYd3e3oY5XbV3JeLy9pPMK+eQQ2m8DtqUtbxnvPpAYC2Knv/2bS6q5JiktqOVJ2Hfia3OSo0/A==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-freebsd-x64@11.16.3: + resolution: {integrity: sha512-4OHKFGJBBfOnuJnelbCS4eBorI6cj54FUxcZJwEXPeoLc8yzORBoJ2w+fQbwjlQcUUZLEg92uGhKCRiUoqznjg==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-arm-gnueabihf@11.16.3: + resolution: {integrity: sha512-OM3W0NLt9u7uKwG/yZbeXABansZC0oZeDF1nKgvcZoRw4/Yak6/l4S0onBfDFeYMY94eYeAt2bl60e30lgsb5A==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-arm-musleabihf@11.16.3: + resolution: {integrity: sha512-MRs7D7i1t7ACsAdTuP81gLZES918EpBmiUyEl8fu302yQB+4L7L7z0Ui8BWnthUTQd3nAU9dXvENLK/SqRVH8A==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-arm64-gnu@11.16.3: + resolution: {integrity: sha512-0eVYZxSceNqGADzhlV4ZRqkHF0fjWxRXQOB7Qwl5y1gN/XYUDvMfip+ngtzj4dM7zQT4U97hUhJ7PUKSy/JIGQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-arm64-musl@11.16.3: + resolution: {integrity: sha512-B1BvLeZbgDdVN0FvU40l5Q7lej8310WlabCBaouk8jY7H7xbI8phtomTtk3Efmevgfy5hImaQJu6++OmcFb2NQ==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-ppc64-gnu@11.16.3: + resolution: {integrity: sha512-q7khglic3Jqak7uDgA3MFnjDeI7krQT595GDZpvFq785fmFYSx8rlTkoHzmhQtUisYtl4XG7WUscwsoidFUI4w==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-riscv64-gnu@11.16.3: + resolution: {integrity: sha512-aFRNmQNPzDgQEbw2s3c8yJYRimacSDI+u9df8rn5nSKzTVitHmbEpZqfxpwNLCKIuLSNmozHR1z1OT+oZVeYqg==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-riscv64-musl@11.16.3: + resolution: {integrity: sha512-vZI85SvSMADcEL9G1TIrV0Rlkc1fY5Mup0DdlVC5EHPysZB4hXXHpr+h09pjlK5y+5om5foIzDRxE1baUCaWOA==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-s390x-gnu@11.16.3: + resolution: {integrity: sha512-xiLBnaUlddFEzRHiHiSGEMbkg8EwZY6VD8F+3GfnFsiK3xg/4boaUV2bwXd+nUzl3UDQOMW1QcZJ4jJSb0qiJA==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-x64-gnu@11.16.3: + resolution: {integrity: sha512-6y0b05wIazJJgwu7yU/AYGFswzQQudYJBOb/otDhiDacp1+6ye8egoxx63iVo9lSpDbipL++54AJQFlcOHCB+g==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-linux-x64-musl@11.16.3: + resolution: {integrity: sha512-RmMgwuMa42c9logS7Pjprf5KCp8J1a1bFiuBFtG9/+yMu0BhY2t+0VR/um7pwtkNFvIQqAVh6gDOg/PnoKRcdQ==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-openharmony-arm64@11.16.3: + resolution: {integrity: sha512-/7AYRkjjW7xu1nrHgWUFy99Duj4/ydOBVaHtODie9/M6fFngo+8uQDFFnzmr4q//sd/cchIerISp/8CQ5TsqIA==} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-wasm32-wasi@11.16.3: + resolution: {integrity: sha512-urM6aIPbi5di4BSlnpd/TWtDJgG6RD06HvLBuNM+qOYuFtY1/xPbzQ2LanBI2ycpqIoIZwsChyplALwAMdyfCQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + dev: true + optional: true + + /@oxc-resolver/binding-win32-arm64-msvc@11.16.3: + resolution: {integrity: sha512-QuvLqGKf7frxWHQ5TnrcY0C/hJpANsaez99Q4dAk1hen7lDTD4FBPtBzPnntLFXeaVG3PnSmnVjlv0vMILwU7Q==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-win32-ia32-msvc@11.16.3: + resolution: {integrity: sha512-QR/witXK6BmYTlEP8CCjC5fxeG5U9A6a50pNpC1nLnhAcJjtzFG8KcQ5etVy/XvCLiDc7fReaAWRNWtCaIhM8Q==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@oxc-resolver/binding-win32-x64-msvc@11.16.3: + resolution: {integrity: sha512-bFuJRKOscsDAEZ/a8BezcTMAe2BQ/OBRfuMLFUuINfTR5qGVcm4a3xBIrQVepBaPxFj16SJdRjGe05vDiwZmFw==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@pinojs/redact@0.4.0: resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -2921,6 +3127,11 @@ packages: tailwindcss: 3.4.19(tsx@4.21.0) dev: true + /@tanstack/history@1.145.7: + resolution: {integrity: sha512-gMo/ReTUp0a3IOcZoI3hH6PLDC2R/5ELQ7P2yu9F6aEkA0wSQh+Q4qzMrtcKvF2ut0oE+16xWCGDo/TdYd6cEQ==} + engines: {node: '>=12'} + dev: false + /@tanstack/query-core@5.90.16: resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} dev: false @@ -2934,6 +3145,60 @@ packages: react: 19.2.3 dev: false + /@tanstack/react-router@1.149.3(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-yklZ2LSXLGfhW4PXu2N98yhGk8qtlkUbFRV42np0rx46s50wB5sXRkjdnqyGuDG/dldaBIi76M6vWg84Pmb4+A==} + engines: {node: '>=12'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + dependencies: + '@tanstack/history': 1.145.7 + '@tanstack/react-store': 0.8.0(react-dom@19.2.3)(react@19.2.3) + '@tanstack/router-core': 1.149.3 + isbot: 5.1.32 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + dev: false + + /@tanstack/react-store@0.8.0(react-dom@19.2.3)(react@19.2.3): + resolution: {integrity: sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + '@tanstack/store': 0.8.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + dev: false + + /@tanstack/router-core@1.149.3: + resolution: {integrity: sha512-obXmQ2hElxqjQ9cpABjXOvR/aQG+uG9ALEcVvyqP1ae57Fb3VhOuynmc2k/eVgx/bKKvxe2cqj4wCG04O0i5Zg==} + engines: {node: '>=12'} + dependencies: + '@tanstack/history': 1.145.7 + '@tanstack/store': 0.8.0 + cookie-es: 2.0.0 + seroval: 1.4.2 + seroval-plugins: 1.4.2(seroval@1.4.2) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + dev: false + + /@tanstack/store@0.8.0: + resolution: {integrity: sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==} + dev: false + + /@tybys/wasm-util@0.10.1: + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -3342,6 +3607,10 @@ packages: /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + /aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -3739,6 +4008,10 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + dev: false + /cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -4375,6 +4648,12 @@ packages: dependencies: reusify: 1.1.0 + /fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + dependencies: + walk-up-path: 4.0.0 + dev: true + /fdir@6.5.0(picomatch@4.0.3): resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4446,6 +4725,14 @@ packages: yaml: 2.8.2 dev: true + /formatly@0.3.0: + resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} + engines: {node: '>=18.3.0'} + hasBin: true + dependencies: + fd-package-json: 2.0.0 + dev: true + /formdata-polyfill@4.0.10: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} @@ -4809,6 +5096,11 @@ packages: is-docker: 2.2.1 dev: true + /isbot@5.1.32: + resolution: {integrity: sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ==} + engines: {node: '>=18'} + dev: false + /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -4849,6 +5141,11 @@ packages: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + /jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + dev: true + /jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -4873,6 +5170,13 @@ packages: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} dev: true + /js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + /jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -4911,6 +5215,30 @@ packages: engines: {node: '>= 0.10.0'} dev: false + /knip@5.81.0(@types/node@25.0.3)(typescript@5.9.3): + resolution: {integrity: sha512-EM9YdNg6zU2DWMJuc9zD8kPUpj0wvPspa63Qe9DPGygzL956uYThfoUQk5aNpPmMr9hs/k+Xm7FLuWFKERFkrQ==} + engines: {node: '>=18.18.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4 <7' + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@types/node': 25.0.3 + fast-glob: 3.3.3 + formatly: 0.3.0 + jiti: 2.6.1 + js-yaml: 4.1.1 + minimist: 1.2.8 + oxc-resolver: 11.16.3 + picocolors: 1.1.1 + picomatch: 4.0.3 + smol-toml: 1.6.0 + strip-json-comments: 5.0.3 + typescript: 5.9.3 + zod: 4.3.5 + dev: true + /libsql@0.5.22: resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} cpu: [x64, arm64, wasm32, arm] @@ -5543,6 +5871,31 @@ packages: /openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + /oxc-resolver@11.16.3: + resolution: {integrity: sha512-goLOJH3x69VouGWGp5CgCIHyksmOZzXr36lsRmQz1APg3SPFORrvV2q7nsUHMzLVa6ZJgNwkgUSJFsbCpAWkCA==} + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.16.3 + '@oxc-resolver/binding-android-arm64': 11.16.3 + '@oxc-resolver/binding-darwin-arm64': 11.16.3 + '@oxc-resolver/binding-darwin-x64': 11.16.3 + '@oxc-resolver/binding-freebsd-x64': 11.16.3 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.16.3 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.16.3 + '@oxc-resolver/binding-linux-arm64-gnu': 11.16.3 + '@oxc-resolver/binding-linux-arm64-musl': 11.16.3 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.16.3 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.16.3 + '@oxc-resolver/binding-linux-riscv64-musl': 11.16.3 + '@oxc-resolver/binding-linux-s390x-gnu': 11.16.3 + '@oxc-resolver/binding-linux-x64-gnu': 11.16.3 + '@oxc-resolver/binding-linux-x64-musl': 11.16.3 + '@oxc-resolver/binding-openharmony-arm64': 11.16.3 + '@oxc-resolver/binding-wasm32-wasi': 11.16.3 + '@oxc-resolver/binding-win32-arm64-msvc': 11.16.3 + '@oxc-resolver/binding-win32-ia32-msvc': 11.16.3 + '@oxc-resolver/binding-win32-x64-msvc': 11.16.3 + dev: true + /p-map@7.0.4: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} @@ -6246,6 +6599,20 @@ packages: engines: {node: '>=8.0'} dev: false + /seroval-plugins@1.4.2(seroval@1.4.2): + resolution: {integrity: sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + dependencies: + seroval: 1.4.2 + dev: false + + /seroval@1.4.2: + resolution: {integrity: sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ==} + engines: {node: '>=10'} + dev: false + /serve-handler@6.1.6: resolution: {integrity: sha512-x5RL9Y2p5+Sh3D38Fh9i/iQ5ZK+e4xuXRd/pGbM4D13tgo/MGwbttUk8emytcr1YYzBYs+apnUngBDFYfpjPuQ==} dependencies: @@ -6383,6 +6750,11 @@ packages: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true + /smol-toml@1.6.0: + resolution: {integrity: sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw==} + engines: {node: '>= 18'} + dev: true + /sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} dependencies: @@ -6614,6 +6986,14 @@ packages: dependencies: real-require: 0.2.0 + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false + + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + /tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} dev: true @@ -6658,7 +7038,6 @@ packages: /tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - dev: false /tsx@4.21.0: resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} @@ -6899,6 +7278,14 @@ packages: tslib: 2.8.1 dev: false + /use-sync-external-store@1.6.0(react@19.2.3): + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + dependencies: + react: 19.2.3 + dev: false + /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -7101,6 +7488,11 @@ packages: - yaml dev: true + /walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + dev: true + /web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} diff --git a/specs/TODO.md b/specs/TODO.md index b1840fd..3b149c3 100644 --- a/specs/TODO.md +++ b/specs/TODO.md @@ -24,8 +24,28 @@ - [ ] **Display MemberAvailability and PlannerSettings on the right** - Dynamically display MemberAvailability and PlannerSettings on the right of the screen when they are configured. - [ ] **Onboarding phase/Meal planner phase** - Dissociate the onboarding phase and the meal planner phase in the UI. Also related to saving session in database [need specifications]. - [ ] **Speech-to-text button for chat input** - Add a speech-to-text button to the chat input that allows users to speak and have their speech transcribed to text in the input field. +- [ ] **Hide empty chat messages** - When a message is empty, don't display it to avoid empty blocks in the chat UI. +- [ ] **Persistent chat history** - Make chat messages persistent (in memory store/localStorage) to keep chat history when navigating the app. Ensure separate stores for family and planner contexts. Add a clear/New chat button to reset the chat history. + +## Recipe Manager + +> New route `/recipes` with a dedicated Recipes tab + +### Core Features + +- [ ] **Create recipe with AI** - Describe a recipe in natural language and let AI generate a structured recipe with ingredients, steps, cooking time, servings, etc. +- [ ] **View recipes list** - Display all recipes in a searchable, filterable list with sorting options (by name, date created, cuisine type, etc.) +- [ ] **View recipe details** - Show full recipe details including ingredients, step-by-step instructions, nutritional info, and metadata +- [ ] **Link recipe to meal event** - Allow users to link existing recipes to meal events in their meal planning + +### Later + +- [ ] **Generate recipe image** - Use AI to generate an image for the recipe +- [ ] **Hand-drawn step illustrations** - Generate hand-drawn style illustrations for each recipe step [Optional] +- [ ] **Import recipes from external sources** - Import recipes from URLs, PDFs, or other recipe platforms ## Tech - [ ] **Use drizzle-seed for database seeding** - Replace the current manual seeding script with [drizzle-seed](https://orm.drizzle.team/docs/seed-overview) for more robust and maintainable test data generation. This will provide better data variety, relationship handling, and faker integration. - [ ] **Refactor core/db/index.ts** - Refactor the database connection to use a more modern and maintainable approach. Avoid putting everything in index.ts. +- [ ] **Agent evaluation and scoring** - Implement an evaluation framework to test agent behavior and quality. Use Mastra's built-in evaluation/scoring capabilities. First test case: verify agents always respond to the user with text after executing tool calls (no empty responses). diff --git a/specs/data-models.md b/specs/data-models.md index 91d97b0..689cac8 100644 --- a/specs/data-models.md +++ b/specs/data-models.md @@ -168,11 +168,13 @@ export type MealPlanning = z.infer; ### MealEvent Schema +Meal events must always belong to a meal planning. When creating a meal event, if no planningId is provided, the system automatically finds or creates a weekly planning for the event's date. + ```typescript export const MealEventSchema = z.object({ id: z.string().uuid(), familyId: z.string().uuid(), - planningId: z.string().uuid().optional(), + planningId: z.string().uuid(), // Required - events must belong to a planning date: z.string().date(), // YYYY-MM-DD mealType: z.enum(['breakfast', 'lunch', 'dinner']), recipeName: z.string().optional(), diff --git a/specs/database-schema.md b/specs/database-schema.md index c5d7d61..68673d6 100644 --- a/specs/database-schema.md +++ b/specs/database-schema.md @@ -70,7 +70,7 @@ erDiagram MealEvent { string id PK string familyId FK - string planningId FK + string planningId FK "required" date date string mealType "breakfast | lunch | dinner" string recipeName "optional" @@ -147,11 +147,11 @@ CREATE TABLE MealPlanning ( FOREIGN KEY (familyId) REFERENCES Family(id) ON DELETE CASCADE ); --- MealEvent table: Individual scheduled meals +-- MealEvent table: Individual scheduled meals (must belong to a planning) CREATE TABLE MealEvent ( id TEXT PRIMARY KEY, familyId TEXT NOT NULL, - planningId TEXT, -- Optional, can exist outside a planning cycle + planningId TEXT NOT NULL, -- Required - events must belong to a planning date TEXT NOT NULL, -- YYYY-MM-DD mealType TEXT NOT NULL CHECK (mealType IN ('breakfast', 'lunch', 'dinner')), recipeName TEXT, @@ -159,7 +159,7 @@ CREATE TABLE MealEvent ( createdAt TEXT NOT NULL DEFAULT (datetime('now')), updatedAt TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (familyId) REFERENCES Family(id) ON DELETE CASCADE, - FOREIGN KEY (planningId) REFERENCES MealPlanning(id) ON DELETE SET NULL + FOREIGN KEY (planningId) REFERENCES MealPlanning(id) ON DELETE CASCADE ); -- Indexes for performance diff --git a/specs/meal-planner-feature.md b/specs/meal-planner-feature.md new file mode 100644 index 0000000..b6d6e73 --- /dev/null +++ b/specs/meal-planner-feature.md @@ -0,0 +1,875 @@ +# Meal Planner Feature - Plan and Specifications + +## Overview + +This document outlines the implementation of the meal planner feature, which adds a second main view to the Auguste application alongside the existing family configuration view. + +## Requirements Summary + +- **Header Tabs**: "Family" and "Meal planner" tabs in the header +- **Family Readiness State**: Family is ready for meal planning when: + - At least one member exists + - `mealTypes` array has at least one value + - `activeDays` array has at least one value +- **Navigation CTAs**: + - "Go to Meal Planner" button when family is ready + - "Edit Family" button to return to family configuration +- **Agent Consolidation**: Merge family-editor-agent into onboarding-agent (future refactor) +- **Routing**: TanStack Router 2 with routes `/family` (default) and `/planner` +- **Component Reuse**: Maximize reuse of existing components and layouts +- **Agent Integration**: Use meal-planner-agent for the meal planner view +- **Right Panel Display**: Show meal planning data based on MealPlanning and MealEvent models + +## Architecture Overview + +```mermaid +flowchart TB + subgraph UI["User Interface"] + Header["Header with Tabs"] + FamilyTab["Family Tab"] + PlannerTab["Meal Planner Tab"] + end + + subgraph Views["Views"] + FamilyView["/family Route"] + PlannerView["/planner Route"] + end + + subgraph Components["Shared Components"] + ChatPanel["Chat Panel"] + FamilyPanel["Family Panel"] + PlannerPanel["Planner Panel"] + end + + subgraph Agents["AI Agents"] + OnboardingAgent["Onboarding Agent"] + MealPlannerAgent["Meal Planner Agent"] + end + + subgraph State["State Management"] + FamilyReady["Family Ready State"] + PlannerData["Meal Planning Data"] + end + + Header --> FamilyTab + Header --> PlannerTab + + FamilyTab --> FamilyView + PlannerTab --> PlannerView + + FamilyView --> ChatPanel + FamilyView --> FamilyPanel + + PlannerView --> ChatPanel + PlannerView --> PlannerPanel + + ChatPanel --> OnboardingAgent + PlannerPanel --> MealPlannerAgent + + FamilyReady --> PlannerTab + PlannerData --> PlannerPanel +``` + +## Detailed Implementation Plan + +### Phase 1: Infrastructure Setup + +#### 1.1 Install and Configure TanStack Router + +**Tasks:** + +- Install `@tanstack/react-router` package +- Create router configuration in `apps/web/src/router/index.tsx` +- Update `main.tsx` to use router +- Create route structure: + - `apps/web/src/routes/__root.tsx` - Root layout + - `apps/web/src/routes/family.tsx` - Family configuration route (default) + - `apps/web/src/routes/planner.tsx` - Meal planner route + +**Technical Details:** + +```typescript +// apps/web/src/router/index.tsx +import { createRouter, createRootRoute, createRoute } from '@tanstack/react-router'; + +const rootRoute = createRootRoute({ + component: RootLayout, +}); + +const familyRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/family', + component: FamilyView, +}); + +const plannerRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/planner', + component: PlannerView, +}); + +const router = createRouter({ + routeTree: rootRoute.addChildren([familyRoute, plannerRoute]), +}); +``` + +#### 1.2 Update Header Component + +**Tasks:** + +- Modify [`header.tsx`](apps/web/src/components/layout/header.tsx) to include tab navigation +- Add state for active tab +- Implement tab switching logic with TanStack Router navigation +- Add "Go to Meal Planner" CTA when family is ready +- Add "Edit Family" CTA on planner tab + +**Component Structure:** + +```tsx +// apps/web/src/components/layout/header.tsx +interface HeaderProps { + familyId?: string; + isFamilyReady?: boolean; + currentRoute: 'family' | 'planner'; +} + +export function Header({ familyId, isFamilyReady, currentRoute }: HeaderProps) { + return ( +
+ {/* Logo */} +
AUGUSTE
+ + {/* Tab Navigation */} + + + {/* CTAs */} + {currentRoute === 'family' && isFamilyReady && ( + + Go to Meal Planner + + )} + {currentRoute === 'planner' && ( + + Edit Family + + )} +
+ ); +} +``` + +### Phase 2: Family Readiness Logic + +#### 2.1 Create Family Readiness Hook + +**Tasks:** + +- Create [`apps/web/src/hooks/use-family-ready.ts`](apps/web/src/hooks/use-family-ready.ts) +- Implement logic to check family readiness: + - At least one member exists + - `mealTypes` array has at least one value + - `activeDays` array has at least one value +- Return boolean `isReady` state + +**Implementation:** + +```typescript +// apps/web/src/hooks/use-family-ready.ts +import { useMemo } from 'react'; +import { useFamilyData } from './use-family-data'; + +export function useFamilyReady(familyId: string) { + const { members, settings } = useFamilyData(familyId); + + const isReady = useMemo(() => { + // Check at least one member + if (!members || members.length === 0) { + return false; + } + + // Check mealTypes has at least one value + if (!settings || !settings.mealTypes || settings.mealTypes.length === 0) { + return false; + } + + // Check activeDays has at least one value + if (!settings.activeDays || settings.activeDays.length === 0) { + return false; + } + + return true; + }, [members, settings]); + + return { isReady }; +} +``` + +#### 2.2 Integrate Readiness Check + +**Tasks:** + +- Update [`App.tsx`](apps/web/src/App.tsx) to use `useFamilyReady` hook +- Pass readiness state to Header component +- Use readiness state to enable/disable planner tab navigation + +### Phase 3: Route Views + +#### 3.1 Family Route (`/family`) + +**Tasks:** + +- Create [`apps/web/src/routes/family.tsx`](apps/web/src/routes/family.tsx) +- Reuse existing [`FamilyPanel`](apps/web/src/components/family/family-panel.tsx) component +- Reuse existing [`ChatPanel`](apps/web/src/components/chat/chat-panel.tsx) component +- Use onboarding-agent for chat interactions +- Maintain current layout (45% chat, 55% family panel) + +**Component Structure:** + +```tsx +// apps/web/src/routes/family.tsx +import { Header } from '@/components/layout/header'; +import { ChatPanel } from '@/components/chat/chat-panel'; +import { FamilyPanel } from '@/components/family/family-panel'; +import { useChat } from '@/hooks/use-chat'; +import { useFamilyReady } from '@/hooks/use-family-ready'; + +export function FamilyView() { + const { familyId, messages, input, isLoading, ...chatProps } = useChat(); + const { isReady } = useFamilyReady(familyId); + + return ( +
+
+
+
+ +
+
+ {familyId ? ( + + ) : ( + + )} +
+
+
+ ); +} +``` + +#### 3.2 Planner Route (`/planner`) + +**Tasks:** + +- Create [`apps/web/src/routes/planner.tsx`](apps/web/src/routes/planner.tsx) +- Create new [`PlannerPanel`](apps/web/src/components/planner/planner-panel.tsx) component +- Reuse [`ChatPanel`](apps/web/src/components/chat/chat-panel.tsx) component +- Use meal-planner-agent for chat interactions +- Maintain same layout (45% chat, 55% planner panel) +- Display meal planning data based on MealPlanning and MealEvent models + +**Component Structure:** + +```tsx +// apps/web/src/routes/planner.tsx +import { Header } from '@/components/layout/header'; +import { ChatPanel } from '@/components/chat/chat-panel'; +import { PlannerPanel } from '@/components/planner/planner-panel'; +import { useChat } from '@/hooks/use-chat'; +import { useFamilyReady } from '@/hooks/use-family-ready'; + +export function PlannerView() { + const { familyId, messages, input, isLoading, ...chatProps } = useChat(); + const { isReady } = useFamilyReady(familyId); + + // Redirect to family route if not ready + if (!isReady) { + return ; + } + + return ( +
+
+
+
+ +
+
+ +
+
+
+ ); +} +``` + +### Phase 4: Planner Panel Component + +#### 4.1 Create Planner Panel Structure + +**Tasks:** + +- Create [`apps/web/src/components/planner/planner-panel.tsx`](apps/web/src/components/planner/planner-panel.tsx) +- Implement tab-based layout similar to [`FamilyPanel`](apps/web/src/components/family/family-panel.tsx) +- Create tabs for: + - "Weekly Plan" - Display 7-day meal plan + - "Meal Details" - Detailed view of selected meal + - "Calendar" - Calendar view of meals + +**Component Structure:** + +```tsx +// apps/web/src/components/planner/planner-panel.tsx +import { useState } from 'react'; +import { WeeklyPlanView } from './weekly-plan-view'; +import { MealDetailsView } from './meal-details-view'; +import { CalendarView } from './calendar-view'; +import { usePlannerData } from '@/hooks/use-planner-data'; + +type PlannerTabType = 'weekly' | 'details' | 'calendar'; + +interface PlannerPanelProps { + familyId: string; + isPolling?: boolean; +} + +export function PlannerPanel({ familyId, isPolling = false }: PlannerPanelProps) { + const [activeTab, setActiveTab] = useState('weekly'); + const { planning, events, isLoading, error } = usePlannerData(familyId, { isPolling }); + + const tabs: { id: PlannerTabType; label: string }[] = [ + { id: 'weekly', label: 'Weekly Plan' }, + { id: 'details', label: 'Meal Details' }, + { id: 'calendar', label: 'Calendar' }, + ]; + + if (error) { + return ; + } + + return ( +
+ {/* Tab Navigation */} +
+
+ {tabs.map((tab) => ( + + ))} +
+
+ + {/* Tab Content */} +
+ {isLoading ? ( + + ) : ( + <> + {activeTab === 'weekly' && } + {activeTab === 'details' && } + {activeTab === 'calendar' && } + + )} +
+
+ ); +} +``` + +#### 4.2 Create Planner Data Hook + +**Tasks:** + +- Create [`apps/web/src/hooks/use-planner-data.ts`](apps/web/src/hooks/use-planner-data.ts) +- Fetch meal planning data from API +- Include polling support for real-time updates +- Return planning and events data + +**Implementation:** + +```typescript +// apps/web/src/hooks/use-planner-data.ts +import { useQuery } from '@tanstack/react-query'; +import { apiClient } from '@/lib/api-client'; + +interface UsePlannerDataOptions { + isPolling?: boolean; +} + +const POLLING_INTERVAL = 1000; + +export function usePlannerData(familyId: string, options: UsePlannerDataOptions = {}) { + const { isPolling = false } = options; + const refetchInterval = isPolling ? POLLING_INTERVAL : false; + + const planningQuery = useQuery({ + queryKey: ['meal-planning', familyId], + queryFn: () => apiClient.getMealPlanning(familyId), + enabled: !!familyId, + refetchInterval, + }); + + const eventsQuery = useQuery({ + queryKey: ['meal-events', familyId], + queryFn: () => apiClient.getMealEvents(familyId), + enabled: !!familyId, + refetchInterval, + }); + + return { + planning: planningQuery.data, + events: eventsQuery.data, + isLoading: planningQuery.isLoading || eventsQuery.isLoading, + error: planningQuery.error || eventsQuery.error, + refetch: () => { + planningQuery.refetch(); + eventsQuery.refetch(); + }, + }; +} +``` + +#### 4.3 Create Weekly Plan View + +**Tasks:** + +- Create [`apps/web/src/components/planner/weekly-plan-view.tsx`](apps/web/src/components/planner/weekly-plan-view.tsx) +- Display 7-day meal plan in grid or list format +- Show meal events grouped by day and meal type +- Include participant information +- Allow clicking on meals to view details + +**Component Structure:** + +```tsx +// apps/web/src/components/planner/weekly-plan-view.tsx +import { MealEvent } from '@auguste/core'; +import { DayOfWeek, MealType } from '@auguste/core'; + +interface WeeklyPlanViewProps { + planning?: MealPlanning | null; + events?: MealEvent[]; +} + +export function WeeklyPlanView({ planning, events }: WeeklyPlanViewProps) { + // Group events by day and meal type + const groupedEvents = useMemo(() => { + if (!events) return {}; + return events.reduce( + (acc, event) => { + const key = `${event.date}-${event.mealType}`; + if (!acc[key]) acc[key] = []; + acc[key].push(event); + return acc; + }, + {} as Record, + ); + }, [events]); + + // Render 7-day grid + return ( +
+

Weekly Meal Plan

+ + {/* Week date range */} + {planning && ( +

+ {formatDate(planning.startDate)} - {formatDate(planning.endDate)} +

+ )} + + {/* 7-day grid */} +
+ {daysOfWeek.map((day) => ( + + ))} +
+
+ ); +} +``` + +#### 4.4 Create Meal Details View + +**Tasks:** + +- Create [`apps/web/src/components/planner/meal-details-view.tsx`](apps/web/src/components/planner/meal-details-view.tsx) +- Display detailed information about selected meal +- Show recipe name, description, participants +- Include meal type and date +- Allow editing meal details + +#### 4.5 Create Calendar View + +**Tasks:** + +- Create [`apps/web/src/components/planner/calendar-view.tsx`](apps/web/src/components/planner/calendar-view.tsx) +- Display calendar view of meals +- Show meal events on calendar grid +- Allow navigation between weeks +- Click on day to view meals + +### Phase 5: API Integration + +#### 5.1 Extend API Client + +**Tasks:** + +- Update [`api-client.ts`](apps/web/src/lib/api-client.ts) to add meal planning endpoints +- Add methods: + - `getMealPlanning(familyId: string)` - Get active meal planning + - `getMealEvents(familyId: string)` - Get meal events for current week + - `updateMealEvent(eventId: string, data)` - Update meal event + +**Implementation:** + +```typescript +// apps/web/src/lib/api-client.ts +export const apiClient = { + // ... existing methods ... + + getMealPlanning: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/meal-planning`); + if (!response.ok) throw new Error('Failed to fetch meal planning'); + return response.json(); + }, + + getMealEvents: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/meal-events`); + if (!response.ok) throw new Error('Failed to fetch meal events'); + return response.json(); + }, + + updateMealEvent: async ( + eventId: string, + data: { recipeName?: string; participants?: string[] }, + ) => { + const response = await fetch(`${API_BASE_URL}/api/meal-events/${eventId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (!response.ok) throw new Error('Failed to update meal event'); + return response.json(); + }, +}; +``` + +#### 5.2 Backend API Endpoints + +**Tasks:** + +- Create API endpoints in [`apps/api/src/index.ts`](apps/api/src/index.ts): + - `GET /api/family/:familyId/meal-planning` - Get active meal planning + - `GET /api/family/:familyId/meal-events` - Get meal events for current week + - `PATCH /api/meal-events/:eventId` - Update meal event + +**Implementation:** + +```typescript +// apps/api/src/index.ts +import { getMealPlanning, getMealEvents, updateMealEvent } from '@auguste/core'; + +app.get('/api/family/:familyId/meal-planning', async (req, res) => { + try { + const { familyId } = req.params; + const planning = await getMealPlanning({ familyId }); + res.json(planning); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch meal planning' }); + } +}); + +app.get('/api/family/:familyId/meal-events', async (req, res) => { + try { + const { familyId } = req.params; + const today = new Date().toISOString().split('T')[0]; + const nextWeek = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + const events = await getMealEvents({ familyId, startDate: today, endDate: nextWeek }); + res.json(events); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch meal events' }); + } +}); + +app.patch('/api/meal-events/:eventId', async (req, res) => { + try { + const { eventId } = req.params; + const { recipeName, participants } = req.body; + const event = await updateMealEvent({ id: eventId, recipeName, participants }); + res.json(event); + } catch (error) { + res.status(500).json({ error: 'Failed to update meal event' }); + } +}); +``` + +### Phase 6: Chat Integration + +#### 6.1 Update Chat Hook for Planner Route + +**Tasks:** + +- Update [`useChat.ts`](apps/web/src/hooks/use-chat.ts) to support different agents based on route +- Use onboarding-agent on /family route +- Use meal-planner-agent on /planner route +- Pass agent type to API calls + +**Implementation:** + +```typescript +// apps/web/src/hooks/use-chat.ts +interface UseChatOptions { + agentType?: 'onboarding' | 'meal-planner'; +} + +export function useChat(options: UseChatOptions = {}) { + const { agentType = 'onboarding' } = options; + + // Use agentType to determine which agent to call + const sendMessage = async (message: string) => { + const response = await fetch(`${API_BASE_URL}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message, + familyId, + agentType, + }), + }); + // ... handle response + }; + + // ... rest of hook +} +``` + +#### 6.2 Backend Agent Selection + +**Tasks:** + +- Update [`apps/api/src/index.ts`](apps/api/src/index.ts) to route to correct agent +- Select agent based on `agentType` parameter + +**Implementation:** + +```typescript +// apps/api/src/index.ts +import { onboardingAgent, mealPlannerAgent } from '@auguste/core'; + +app.post('/api/chat', async (req, res) => { + try { + const { message, familyId, agentType } = req.body; + + const agent = agentType === 'meal-planner' ? mealPlannerAgent : onboardingAgent; + + const response = await agent.generate({ + messages: [{ role: 'user', content: message }], + requestContext: new Map([['familyId', familyId]]), + }); + + res.json({ message: response.text }); + } catch (error) { + res.status(500).json({ error: 'Failed to process message' }); + } +}); +``` + +### Phase 7: Styling and Polish + +#### 7.1 Create Planner Styles + +**Tasks:** + +- Create [`apps/web/src/components/planner/planner-styles.css`](apps/web/src/components/planner/planner-styles.css) +- Apply consistent styling with family components +- Use existing color scheme: + - `#1B3022` - Escoffier Green (dark) + - `#D4AF37` - Champagne Gold + - `#FAF9F6` - Off-white background + +#### 7.2 Add Loading and Error States + +**Tasks:** + +- Create loading skeletons for planner views +- Create error states with retry buttons +- Ensure smooth transitions between states + +#### 7.3 Responsive Design + +**Tasks:** + +- Ensure planner panel works on smaller screens +- Consider collapsible sidebar for mobile +- Test on different screen sizes + +### Phase 8: Testing + +#### 8.1 Unit Tests + +**Tasks:** + +- Test `useFamilyReady` hook +- Test `usePlannerData` hook +- Test planner panel components +- Test API client methods + +#### 8.2 Integration Tests + +**Tasks:** + +- Test navigation between family and planner routes +- Test family readiness state transitions +- Test chat integration with meal-planner-agent +- Test API endpoints + +#### 8.3 E2E Tests + +**Tasks:** + +- Test complete flow from family setup to meal planning +- Test meal plan creation and modification +- Test navigation and state management + +## Data Models + +### MealPlanning + +```typescript +interface MealPlanning { + id: string; + familyId: string; + startDate: string; // YYYY-MM-DD + endDate: string; // YYYY-MM-DD + status: 'draft' | 'active' | 'completed'; + createdAt: string; + updatedAt: string; +} +``` + +### MealEvent + +```typescript +interface MealEvent { + id: string; + familyId: string; + planningId?: string; + date: string; // YYYY-MM-DD + mealType: 'breakfast' | 'lunch' | 'dinner'; + recipeName?: string; + description?: string; + participants: string[]; // Member IDs + createdAt: string; + updatedAt: string; +} +``` + +## Component Hierarchy + +``` +App (Router) +├── __root (RootLayout) +│ └── Header +├── /family (FamilyView) +│ ├── ChatPanel (onboarding-agent) +│ └── FamilyPanel +│ ├── FamilyOverview +│ ├── MembersList +│ ├── AvailabilityView +│ └── PlannerSettings +└── /planner (PlannerView) + ├── ChatPanel (meal-planner-agent) + └── PlannerPanel + ├── WeeklyPlanView + ├── MealDetailsView + └── CalendarView +``` + +## File Structure + +``` +apps/web/src/ +├── router/ +│ └── index.tsx # TanStack Router configuration +├── routes/ +│ ├── __root.tsx # Root layout +│ ├── family.tsx # Family route view +│ └── planner.tsx # Planner route view +├── components/ +│ ├── layout/ +│ │ └── header.tsx # Updated with tabs and CTAs +│ ├── planner/ +│ │ ├── planner-panel.tsx # Main planner panel +│ │ ├── weekly-plan-view.tsx # 7-day meal plan view +│ │ ├── meal-details-view.tsx # Meal details view +│ │ ├── calendar-view.tsx # Calendar view +│ │ └── planner-styles.css # Planner styles +│ └── chat/ +│ └── chat-panel.tsx # Reused for both routes +├── hooks/ +│ ├── use-family-ready.ts # Family readiness check +│ ├── use-planner-data.ts # Planner data fetching +│ └── use-chat.ts # Updated for agent selection +└── lib/ + └── api-client.ts # Extended with meal planning endpoints +``` + +## Future Refactor: Agent Consolidation + +**Note:** This is a future task, not part of the current implementation. + +Merge [`family-editor-agent.ts`](packages/core/src/ai/agents/family-editor-agent.ts) into [`onboarding-agent.ts`](packages/core/src/ai/agents/onboarding-agent.ts): + +**Tasks:** + +- Combine instructions from both agents +- Merge tool sets +- Update prompts to handle both onboarding and editing scenarios +- Add context awareness to determine mode (onboarding vs editing) +- Update all references to use consolidated agent +- Remove [`family-editor-agent.ts`](packages/core/src/ai/agents/family-editor-agent.ts) + +## Success Criteria + +1. ✅ Header displays "Family" and "Meal planner" tabs +2. ✅ "Meal planner" tab is disabled until family is ready +3. ✅ Family readiness is correctly determined (members + mealTypes + activeDays) +4. ✅ "Go to Meal Planner" CTA appears when family is ready +5. ✅ "Edit Family" CTA appears on planner tab +6. ✅ Navigation between `/family` and `/planner` routes works correctly +7. ✅ Planner panel displays meal planning data correctly +8. ✅ Chat integration works with meal-planner-agent +9. ✅ Components are reused where possible +10. ✅ Styling is consistent with existing design + +## Open Questions + +1. Should the planner route redirect to family route if family is not ready, or show a disabled state? + - **Decision:** Redirect to family route with a message + +2. Should meal plan creation be automatic when entering planner route, or require user initiation? + - **Decision:** Require user initiation via chat with meal-planner-agent + +3. Should the planner panel show historical meal plans or only current/active plans? + - **Decision:** Show current/active plans only for MVP + +4. Should users be able to edit meal events directly in the UI, or only through chat? + - **Decision:** Both - allow direct UI editing and chat-based modifications diff --git a/specs/meal-planner/calendar-view.md b/specs/meal-planner/calendar-view.md new file mode 100644 index 0000000..dd1df7b --- /dev/null +++ b/specs/meal-planner/calendar-view.md @@ -0,0 +1,158 @@ +# Calendar View with Meal Planning Widget + +## Goal + +Display a calendar widget showing all meal planning periods for a family. Allow users to click/select a specific meal planning to visualize its details and meal events. + +## Current State + +- **Planner Panel**: Has two tabs - "Weekly Plan" and "Calendar" +- **Calendar tab**: Currently just renders `WeeklyPlanView` (placeholder) +- **API**: Only returns the most recent meal planning (`getMealPlanningByFamilyId`) +- **usePlannerData hook**: Only fetches single planning + events + +## Requirements + +1. **Calendar Overview**: Display a calendar showing all meal planning periods +2. **Multiple Plannings**: Support viewing multiple meal planning cycles +3. **Period Selection**: Click on a planning period to view its details/events +4. **Visual Indicators**: Show planning status (draft/active/completed) with colors +5. **Navigation**: Navigate between months to see past/future plannings + +## Implementation Plan + +### Phase 1: Backend - New API Endpoint + +**File:** `packages/core/src/domain/services/planner-service.ts` + +Add function to get all meal plannings for a family: + +```typescript +export async function getAllMealPlanningsByFamilyId(familyId: string) { + return db + .select() + .from(mealPlanning) + .where(eq(mealPlanning.familyId, familyId)) + .orderBy(desc(mealPlanning.startDate)); +} +``` + +**File:** `apps/api/src/index.ts` + +Add new endpoint: + +```typescript +app.get('/api/family/:id/plannings', async (req, res) => { + const { id } = req.params; + const plannings = await getAllMealPlanningsByFamilyId(id); + res.json(plannings); +}); +``` + +### Phase 2: Frontend - API Client & Hook + +**File:** `apps/web/src/lib/api-client.ts` + +Add method to fetch all plannings: + +```typescript +getAllMealPlannings: async (familyId: string) => { + const response = await fetch(`${API_BASE_URL}/api/family/${familyId}/plannings`); + if (!response.ok) throw new Error('Failed to fetch meal plannings'); + return response.json(); +}, +``` + +**File:** `apps/web/src/hooks/use-planner-data.ts` + +Extend hook to also fetch all plannings for calendar view. + +### Phase 3: Calendar Component + +**File:** `apps/web/src/components/planner/calendar-view.tsx` (new) + +Create calendar view component: + +```typescript +interface CalendarViewProps { + familyId: string; + plannings: MealPlanning[]; + selectedPlanningId?: string; + onSelectPlanning: (planningId: string) => void; +} +``` + +Features: +- Monthly calendar grid +- Planning periods displayed as colored bars across date ranges +- Color coding by status: + - Draft: Gray/dashed + - Active: Green/solid + - Completed: Blue/faded +- Click on planning to select and view details +- Navigation arrows for previous/next month + +### Phase 4: Update Planner Panel + +**File:** `apps/web/src/components/planner/planner-panel.tsx` + +- Add state for selected planning +- When in calendar tab, show calendar + selected planning details +- When planning is selected, show its events in a side panel or below + +### Phase 5: Planning Details Component + +**File:** `apps/web/src/components/planner/planning-details.tsx` (new) + +Display selected planning information: +- Date range +- Status badge +- Number of meal events +- List of meal events grouped by day + +## UI Mockup + +``` +┌─────────────────────────────────────────────────────────┐ +│ ◀ January 2026 ▶ │ +├───────┬───────┬───────┬───────┬───────┬───────┬───────┤ +│ Sun │ Mon │ Tue │ Wed │ Thu │ Fri │ Sat │ +├───────┼───────┼───────┼───────┼───────┼───────┼───────┤ +│ │ │ │ 1 │ 2 │ 3 │ 4 │ +│ │ │ │ ████████████████████████████ │ ← Week 1 (Jan 1-7) +├───────┼───────┼───────┼───────┼───────┼───────┼───────┤ +│ 5 │ 6 │ 7 │ 8 │ 9 │ 10 │ 11 │ +│ ████ │ │ │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ← Week 2 (Jan 8-14) +├───────┼───────┼───────┼───────┼───────┼───────┼───────┤ +│ 12 │ 13 │ 14 │ 15 │ 16 │ 17 │ 18 │ +│ ░░░░░ │ │ │ │ +└───────┴───────┴───────┴───────┴───────┴───────┴───────┘ + +Legend: ████ = Active ░░░░ = Draft ▒▒▒▒ = Completed +``` + +## Files to Create/Modify + +| File | Action | Description | +|------|--------|-------------| +| `packages/core/src/domain/services/planner-service.ts` | Modify | Add `getAllMealPlanningsByFamilyId` | +| `apps/api/src/index.ts` | Modify | Add `/api/family/:id/plannings` endpoint | +| `apps/web/src/lib/api-client.ts` | Modify | Add `getAllMealPlannings` method | +| `apps/web/src/hooks/use-planner-data.ts` | Modify | Add plannings query | +| `apps/web/src/components/planner/calendar-view.tsx` | Create | Calendar component | +| `apps/web/src/components/planner/planning-details.tsx` | Create | Planning details component | +| `apps/web/src/components/planner/planner-panel.tsx` | Modify | Integrate calendar view | + +## Dependencies + +Consider using: +- `date-fns` (already installed) for date manipulation +- No external calendar library needed - build simple month grid + +## Edge Cases + +1. **No plannings**: Show empty calendar with message "No meal plans yet" +2. **Overlapping plannings**: With our new overlap prevention, this shouldn't happen +3. **Long date ranges**: Planning spans multiple months - show partial bars at month edges +4. **Current month indicator**: Highlight today's date + diff --git a/specs/meal-planner/non-overlapping-dates.md b/specs/meal-planner/non-overlapping-dates.md new file mode 100644 index 0000000..610af83 --- /dev/null +++ b/specs/meal-planner/non-overlapping-dates.md @@ -0,0 +1,133 @@ +# Non-Overlapping Meal Planning Dates + +## Goal + +Ensure a Family can have multiple meal planning entries but `startDate` & `endDate` should be unique and non-overlapping. + +## Current State + +- `MealPlanning` table has `familyId`, `startDate`, `endDate`, `status` +- No constraint preventing overlapping date ranges for the same family +- `createMealPlanning` tool directly inserts without validation +- Business logic is currently in tools, should be in domain services + +## Implementation Plan + +### 1. Create Domain Service Function + +**File:** `packages/core/src/domain/services/meal-planning-service.ts` (new file) + +Create a dedicated service for meal planning business logic: + +```typescript +/** + * Check if a date range overlaps with existing meal plannings for a family. + * Overlap condition: existingStart <= newEnd AND existingEnd >= newStart + * + * @param familyId - The family ID + * @param startDate - Start date (YYYY-MM-DD) + * @param endDate - End date (YYYY-MM-DD) + * @param excludeId - Optional planning ID to exclude (for updates) + * @returns Array of overlapping plannings, or empty array if none + */ +export async function findOverlappingMealPlannings( + familyId: string, + startDate: string, + endDate: string, + excludeId?: string +): Promise + +/** + * Create a new meal planning with overlap validation. + * Throws an error if the date range overlaps with existing plannings. + */ +export async function createMealPlanningWithValidation( + input: CreateMealPlanningInput +): Promise + +/** + * Update a meal planning with overlap validation (if dates are changed). + */ +export async function updateMealPlanningWithValidation( + id: string, + input: Partial +): Promise +``` + +### 2. Update AI Tools to Use Domain Service + +**File:** `packages/core/src/ai/tools/meal-tools.ts` + +Refactor tools to be thin wrappers around domain services: + +```typescript +export const createMealPlanning = createTool({ + id: 'create-meal-planning', + description: 'Create a new weekly meal planning cycle', + inputSchema: CreateMealPlanningInputSchema, + outputSchema: MealPlanningSchema, + execute: async (input) => { + return createMealPlanningWithValidation(input); + }, +}); +``` + +### 3. Update Agent Instructions + +**File:** `packages/core/src/ai/agents/meal-planner-agent.ts` + +Add instruction about the overlap rule so the agent can explain errors to users: + +``` +OVERLAP RULE: +- Meal plannings for a family cannot have overlapping date ranges. +- If a user tries to create a planning that overlaps with an existing one, + explain the conflict and suggest alternative dates. +- Use 'get-meal-planning' to check for existing plannings before creating new ones. +``` + +### 4. Unit Tests + +**File:** `packages/core/src/domain/services/meal-planning-service.test.ts` (new file) + +Test cases: +- ✅ Creating non-overlapping plannings should succeed +- ❌ Creating overlapping plannings should fail with descriptive error +- ❌ Creating planning with same exact dates should fail +- ✅ Creating adjacent plannings (end = next start - 1 day) should succeed +- ❌ Updating planning dates to create overlap should fail +- ✅ Updating planning dates to non-overlapping should succeed +- ✅ `excludeId` parameter should exclude the planning from overlap check (for updates) + +## Design Decisions + +### Why Application-Level Validation? + +SQLite doesn't support complex constraints like "no overlapping ranges" natively. While we could use triggers, application-level validation in the domain service provides: +- Better error messages +- Easier testing +- More flexibility for future business rules + +### Should Drafts Be Checked? + +**Yes** - All plannings regardless of status are checked for overlap. Rationale: +- Drafts represent intent to plan for those dates +- Prevents confusion when activating a draft +- Simpler mental model for users + +### Edge Cases + +| Scenario | Overlap? | +|----------|----------| +| Same exact dates | ✅ Yes | +| Partial overlap (A ends during B) | ✅ Yes | +| B fully inside A | ✅ Yes | +| Adjacent (A ends Jan 7, B starts Jan 8) | ❌ No | +| Same start, different end | ✅ Yes | + +## Questions Resolved + +1. **Draft vs Active plannings?** → All plannings are checked +2. **Exact same dates?** → Considered overlapping (yes) +3. **Error message format?** → Include conflicting planning dates for clarity +