feat: initial meal planner app with Gemini AI integration
This commit is contained in:
commit
9cb5f888d4
37
.gitignore
vendored
Normal file
37
.gitignore
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# AI-Powered Meal Planner
|
||||||
|
|
||||||
|
An interactive meal planning application that uses AI to generate customized meal plans based on user preferences.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Customizable Meal Plans**: Set portions, flavors, allergies, and special instructions.
|
||||||
|
- **Comprehensive Output**: Get recipes, grocery lists, cooking instructions, and storage tips.
|
||||||
|
- **Calendar Integration**: Export meal plans to Google Calendar or other calendar apps.
|
||||||
|
- **Feedback System**: Refine your meal plan with AI-based improvements.
|
||||||
|
- **Save Configurations**: Save and load your favorite meal planning configurations.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- **Components**:
|
||||||
|
- `ConfigForm`: Interface for setting meal plan preferences
|
||||||
|
- `MealPlanDisplay`: Display generated meal plans in different views
|
||||||
|
- `ExportTools`: Export meal plans to different formats
|
||||||
|
- `SavedConfigs`: Save and load configuration profiles
|
||||||
|
- `AIDebugPanel`: View AI prompts and responses (developer tool)
|
||||||
|
- `RawMealPlanViewer`: View and copy the raw meal plan text
|
||||||
|
- `MealPlanFeedback`: Submit feedback to refine meal plans
|
||||||
|
|
||||||
|
- **Services**:
|
||||||
|
- `AIService`: Handles communication with AI model
|
||||||
|
- `MealPlanService`: Processes and structures meal plan data
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js (v14 or later)
|
||||||
|
- npm or yarn
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```
|
||||||
|
git clone https://github.com/yourusername/meal-planner.git
|
||||||
|
cd meal-planner
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
```
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
```
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. **Configure Preferences**:
|
||||||
|
- Set the number of portions
|
||||||
|
- Add liked/disliked flavors and styles
|
||||||
|
- Add any food allergies
|
||||||
|
- Add special instructions (e.g., "Friday is pizza night")
|
||||||
|
- Select available cooking equipment
|
||||||
|
|
||||||
|
2. **Generate Meal Plan**:
|
||||||
|
- Click the "Generate Meal Plan" button
|
||||||
|
- The AI will create a customized meal plan based on your preferences
|
||||||
|
|
||||||
|
3. **View and Explore**:
|
||||||
|
- Summary view: See all meals at a glance
|
||||||
|
- Grocery list: Get a complete shopping list
|
||||||
|
- Full recipes: Detailed recipes with ingredients and instructions
|
||||||
|
- Calendar view: See your meals laid out on a calendar
|
||||||
|
- Raw text: View the complete raw meal plan text
|
||||||
|
|
||||||
|
4. **Export**:
|
||||||
|
- Export to Google Calendar (CSV)
|
||||||
|
- Export to iCalendar (.ics)
|
||||||
|
- Export to PDF
|
||||||
|
|
||||||
|
5. **Provide Feedback**:
|
||||||
|
- Submit feedback to refine your meal plan
|
||||||
|
- The AI will adjust the plan based on your preferences
|
||||||
|
|
||||||
|
## AI Integration
|
||||||
|
|
||||||
|
This application uses AI to generate meal plans. In a production environment, it would integrate with:
|
||||||
|
|
||||||
|
- OpenAI API (GPT models)
|
||||||
|
- Anthropic API (Claude models)
|
||||||
|
- Or another AI provider
|
||||||
|
|
||||||
|
The current implementation includes a simulated AI service for demonstration purposes.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Integration with grocery delivery services
|
||||||
|
- Nutritional analysis and diet tracking
|
||||||
|
- Recipe rating system
|
||||||
|
- Seasonal ingredient preferences
|
||||||
|
- Recipe scaling
|
||||||
|
- Ingredient substitution suggestions
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the LICENSE file for details.
|
17446
package-lock.json
generated
Normal file
17446
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "mealplanner",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-scripts": "5.0.1",
|
||||||
|
"react-calendar": "^4.1.0",
|
||||||
|
"papaparse": "^5.4.1",
|
||||||
|
"tailwindcss": "^3.3.3"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": [
|
||||||
|
"react-app"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
17
public/index.html
Normal file
17
public/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Meal Planner Application"
|
||||||
|
/>
|
||||||
|
<title>Meal Planner</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
109
src/App.css
Normal file
109
src/App.css
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
.App {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-header {
|
||||||
|
background-color: #282c34;
|
||||||
|
padding: 20px;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator p {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 4px solid rgba(0, 0, 0, 0.1);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 4px solid #4285F4;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .App-header {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .loading-indicator {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .loading-indicator p {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .error-message {
|
||||||
|
background-color: #3a0000;
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-mode-toggle {
|
||||||
|
padding: 8px 15px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #282c34;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dark-mode-toggle {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.App {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
122
src/App.js
Normal file
122
src/App.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './App.css';
|
||||||
|
import ConfigForm from './components/ConfigForm';
|
||||||
|
import MealPlanDisplay from './components/MealPlanDisplay';
|
||||||
|
import ExportTools from './components/ExportTools';
|
||||||
|
import AIDebugPanel from './components/AIDebugPanel';
|
||||||
|
import GeminiTest from './components/GeminiTest';
|
||||||
|
import MealPlanService from './services/MealPlanService';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [mealPlanConfig, setMealPlanConfig] = useState({
|
||||||
|
portions: 6,
|
||||||
|
mealsPerInterval: 7,
|
||||||
|
shoppingIntervalUnit: 'days', // New setting
|
||||||
|
shoppingIntervalQuantity: 7, // New setting
|
||||||
|
numberOfIntervals: 1, // New setting
|
||||||
|
likedFlavors: ['classic american', 'classic italian', 'asian', 'mexican'],
|
||||||
|
dislikedFlavors: [],
|
||||||
|
allergies: [],
|
||||||
|
specialInstructions: ['Friday is pizza night from Marcos', 'Include one novel dish per week'],
|
||||||
|
startDate: new Date(),
|
||||||
|
cookingEquipment: ['oven', 'crockpot', 'stove']
|
||||||
|
});
|
||||||
|
|
||||||
|
const [generatedPlan, setGeneratedPlan] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [calendarEvents, setCalendarEvents] = useState(null);
|
||||||
|
const [isDarkMode, setIsDarkMode] = useState(false); // New state for dark mode
|
||||||
|
|
||||||
|
// Effect to apply dark mode class to body
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
document.body.classList.add('dark-mode');
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('dark-mode');
|
||||||
|
}
|
||||||
|
}, [isDarkMode]);
|
||||||
|
|
||||||
|
const handleConfigUpdate = (newConfig) => {
|
||||||
|
setMealPlanConfig(newConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateMealPlan = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call our service to generate the meal plan
|
||||||
|
const plan = await MealPlanService.generateMealPlan(mealPlanConfig);
|
||||||
|
setGeneratedPlan(plan);
|
||||||
|
|
||||||
|
// Generate calendar events
|
||||||
|
const events = await MealPlanService.generateCalendarEvents(plan);
|
||||||
|
setCalendarEvents(events);
|
||||||
|
} catch (err) {
|
||||||
|
// Provide more detailed error message if available
|
||||||
|
if (err.message && err.message.includes('Gemini API error')) {
|
||||||
|
setError(`Failed to generate meal plan: ${err.message}`);
|
||||||
|
} else {
|
||||||
|
setError('Failed to generate meal plan. Please try again.');
|
||||||
|
}
|
||||||
|
console.error('Error generating meal plan:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to toggle dark mode
|
||||||
|
const toggleDarkMode = () => {
|
||||||
|
setIsDarkMode(!isDarkMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="App">
|
||||||
|
<header className="App-header">
|
||||||
|
<h1>Meal Planner</h1>
|
||||||
|
{/* Dark mode toggle button */}
|
||||||
|
<button onClick={toggleDarkMode} className="dark-mode-toggle">
|
||||||
|
{isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<GeminiTest />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<ConfigForm
|
||||||
|
config={mealPlanConfig}
|
||||||
|
onConfigUpdate={handleConfigUpdate}
|
||||||
|
onGeneratePlan={generateMealPlan}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="loading-indicator">
|
||||||
|
<p>Generating your meal plan...</p>
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{generatedPlan && !isLoading && (
|
||||||
|
<>
|
||||||
|
<MealPlanDisplay plan={generatedPlan} />
|
||||||
|
<ExportTools plan={generatedPlan} calendarEvents={calendarEvents} />
|
||||||
|
<AIDebugPanel
|
||||||
|
aiPrompt={generatedPlan.aiPrompt}
|
||||||
|
rawAIResponse={generatedPlan.rawAIResponse}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
114
src/components/AIDebugPanel.css
Normal file
114
src/components/AIDebugPanel.css
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
.ai-debug-panel {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for AI Debug Panel */
|
||||||
|
body.dark-mode .ai-debug-panel {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #eee;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .panel-header {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .panel-header h3 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-button {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .toggle-button {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .section h4 {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .code-block {
|
||||||
|
background-color: #282828;
|
||||||
|
border-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-container {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-message {
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .empty-message {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .panel-footer {
|
||||||
|
border-top-color: #333;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
48
src/components/AIDebugPanel.js
Normal file
48
src/components/AIDebugPanel.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './AIDebugPanel.css';
|
||||||
|
|
||||||
|
const AIDebugPanel = ({ aiPrompt, rawAIResponse }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
if (!aiPrompt) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ai-debug-panel">
|
||||||
|
<div className="panel-header" onClick={() => setIsExpanded(!isExpanded)}>
|
||||||
|
<h3>AI Interaction {isExpanded ? '▼' : '▶'}</h3>
|
||||||
|
<span className="toggle-button">
|
||||||
|
{isExpanded ? 'Hide' : 'Show'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="panel-content">
|
||||||
|
<div className="section">
|
||||||
|
<h4>Prompt Sent to AI</h4>
|
||||||
|
<pre className="code-block">{aiPrompt}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="section">
|
||||||
|
<h4>Raw AI Response</h4>
|
||||||
|
<div className="response-container">
|
||||||
|
{rawAIResponse ? (
|
||||||
|
<pre className="code-block">{JSON.stringify(rawAIResponse, null, 2)}</pre>
|
||||||
|
) : (
|
||||||
|
<p className="empty-message">No response available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="panel-footer">
|
||||||
|
<p>
|
||||||
|
<strong>Note:</strong> In a production app, this panel would show the exact interaction with the AI model.
|
||||||
|
You could use this to refine your prompts or understand the AI's reasoning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIDebugPanel;
|
187
src/components/ConfigForm.css
Normal file
187
src/components/ConfigForm.css
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
.config-form {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for config form */
|
||||||
|
body.dark-mode .config-form {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-section {
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="number"],
|
||||||
|
.form-group input[type="date"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-group input[type="text"],
|
||||||
|
body.dark-mode .form-group input[type="number"],
|
||||||
|
body.dark-mode .form-group input[type="date"] {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.list-with-input {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-with-input input {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-with-input button {
|
||||||
|
padding: 8px 15px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .list-with-input button {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-with-input button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .list-with-input button:hover {
|
||||||
|
background-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .tag {
|
||||||
|
background-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 5px;
|
||||||
|
padding: 0 5px;
|
||||||
|
color: #333; /* Default color */
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .remove-button {
|
||||||
|
color: #e0e0e0; /* Dark mode color */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button,
|
||||||
|
.generate-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-button {
|
||||||
|
background-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button {
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .generate-button {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-button:hover {
|
||||||
|
background-color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generate-button:hover {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .generate-button:hover {
|
||||||
|
background-color: #a9e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.button-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
338
src/components/ConfigForm.js
Normal file
338
src/components/ConfigForm.js
Normal file
@ -0,0 +1,338 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './ConfigForm.css';
|
||||||
|
import SavedConfigs from './SavedConfigs';
|
||||||
|
|
||||||
|
const ConfigForm = ({ config, onConfigUpdate, onGeneratePlan, isLoading }) => {
|
||||||
|
const [localConfig, setLocalConfig] = useState(config);
|
||||||
|
const [newLikedFlavor, setNewLikedFlavor] = useState('');
|
||||||
|
const [newDislikedFlavor, setNewDislikedFlavor] = useState('');
|
||||||
|
const [newAllergy, setNewAllergy] = useState('');
|
||||||
|
const [newInstruction, setNewInstruction] = useState('');
|
||||||
|
const [newEquipment, setNewEquipment] = useState('');
|
||||||
|
|
||||||
|
// Save current config to localStorage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Convert Date to string for localStorage
|
||||||
|
const configToSave = {
|
||||||
|
...localConfig,
|
||||||
|
startDate: localConfig.startDate.toISOString()
|
||||||
|
};
|
||||||
|
localStorage.setItem('currentMealPlanConfig', JSON.stringify(configToSave));
|
||||||
|
}, [localConfig]);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setLocalConfig({
|
||||||
|
...localConfig,
|
||||||
|
[name]: value
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNumberChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setLocalConfig({
|
||||||
|
...localConfig,
|
||||||
|
[name]: parseInt(value, 10) || 0
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (e) => {
|
||||||
|
setLocalConfig({
|
||||||
|
...localConfig,
|
||||||
|
startDate: new Date(e.target.value)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToList = (listName, value, setValue) => {
|
||||||
|
if (value.trim()) {
|
||||||
|
setLocalConfig({
|
||||||
|
...localConfig,
|
||||||
|
[listName]: [...localConfig[listName], value.trim()]
|
||||||
|
});
|
||||||
|
setValue('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFromList = (listName, index) => {
|
||||||
|
setLocalConfig({
|
||||||
|
...localConfig,
|
||||||
|
[listName]: localConfig[listName].filter((_, i) => i !== index)
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveConfig = () => {
|
||||||
|
onConfigUpdate(localConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadSavedConfig = (savedConfig) => {
|
||||||
|
setLocalConfig(savedConfig);
|
||||||
|
onConfigUpdate(savedConfig);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="config-form">
|
||||||
|
<h2>Meal Plan Configuration</h2>
|
||||||
|
|
||||||
|
<SavedConfigs onLoadConfig={handleLoadSavedConfig} />
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Basic Settings</h3>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="portions">Number of Portions:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="portions"
|
||||||
|
name="portions"
|
||||||
|
value={localConfig.portions}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="mealsPerInterval">Meals per Shopping Interval:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="mealsPerInterval"
|
||||||
|
name="mealsPerInterval"
|
||||||
|
value={localConfig.mealsPerInterval}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="startDate">Start Date:</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id="startDate"
|
||||||
|
name="startDate"
|
||||||
|
value={localConfig.startDate.toISOString().split('T')[0]}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="shoppingIntervalUnit">Shopping Interval Unit:</label>
|
||||||
|
<select
|
||||||
|
id="shoppingIntervalUnit"
|
||||||
|
name="shoppingIntervalUnit"
|
||||||
|
value={localConfig.shoppingIntervalUnit || 'days'}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
>
|
||||||
|
<option value="days">Days</option>
|
||||||
|
<option value="weeks">Weeks</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="shoppingIntervalQuantity">Shopping Interval Quantity:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="shoppingIntervalQuantity"
|
||||||
|
name="shoppingIntervalQuantity"
|
||||||
|
value={localConfig.shoppingIntervalQuantity || 1}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="numberOfIntervals">Number of Intervals to Generate:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="numberOfIntervals"
|
||||||
|
name="numberOfIntervals"
|
||||||
|
value={localConfig.numberOfIntervals || 1}
|
||||||
|
onChange={handleNumberChange}
|
||||||
|
min="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Preferences</h3>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Flavors & Styles I Like:</label>
|
||||||
|
<div className="list-with-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newLikedFlavor}
|
||||||
|
onChange={(e) => setNewLikedFlavor(e.target.value)}
|
||||||
|
placeholder="Enter flavor or style"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToList('likedFlavors', newLikedFlavor, setNewLikedFlavor)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{localConfig.likedFlavors.map((flavor, index) => (
|
||||||
|
<li key={`like-${index}`} className="tag">
|
||||||
|
{flavor}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromList('likedFlavors', index)}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Flavors & Styles I Dislike:</label>
|
||||||
|
<div className="list-with-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newDislikedFlavor}
|
||||||
|
onChange={(e) => setNewDislikedFlavor(e.target.value)}
|
||||||
|
placeholder="Enter flavor or style"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToList('dislikedFlavors', newDislikedFlavor, setNewDislikedFlavor)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{localConfig.dislikedFlavors.map((flavor, index) => (
|
||||||
|
<li key={`dislike-${index}`} className="tag">
|
||||||
|
{flavor}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromList('dislikedFlavors', index)}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label>Food Allergies:</label>
|
||||||
|
<div className="list-with-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newAllergy}
|
||||||
|
onChange={(e) => setNewAllergy(e.target.value)}
|
||||||
|
placeholder="Enter allergy"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToList('allergies', newAllergy, setNewAllergy)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{localConfig.allergies.map((allergy, index) => (
|
||||||
|
<li key={`allergy-${index}`} className="tag">
|
||||||
|
{allergy}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromList('allergies', index)}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Special Instructions</h3>
|
||||||
|
<div className="list-with-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newInstruction}
|
||||||
|
onChange={(e) => setNewInstruction(e.target.value)}
|
||||||
|
placeholder="E.g., Friday is pizza night"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToList('specialInstructions', newInstruction, setNewInstruction)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{localConfig.specialInstructions.map((instruction, index) => (
|
||||||
|
<li key={`instruction-${index}`} className="tag">
|
||||||
|
{instruction}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromList('specialInstructions', index)}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-section">
|
||||||
|
<h3>Cooking Equipment</h3>
|
||||||
|
<div className="list-with-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newEquipment}
|
||||||
|
onChange={(e) => setNewEquipment(e.target.value)}
|
||||||
|
placeholder="E.g., oven, microwave"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addToList('cookingEquipment', newEquipment, setNewEquipment)}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="tag-list">
|
||||||
|
{localConfig.cookingEquipment.map((equipment, index) => (
|
||||||
|
<li key={`equipment-${index}`} className="tag">
|
||||||
|
{equipment}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeFromList('cookingEquipment', index)}
|
||||||
|
className="remove-button"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="button-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="save-button"
|
||||||
|
onClick={handleSaveConfig}
|
||||||
|
>
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="generate-button"
|
||||||
|
onClick={onGeneratePlan}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Generating...' : 'Generate Meal Plan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfigForm;
|
96
src/components/ExportTools.css
Normal file
96
src/components/ExportTools.css
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
.export-tools {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Export Tools */
|
||||||
|
body.dark-mode .export-tools {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-options select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .export-options select {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-actions {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .export-button {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-button:hover {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .export-button:hover {
|
||||||
|
background-color: #a9e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-help {
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .export-help {
|
||||||
|
border-top-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-help h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-help p {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.export-options {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
97
src/components/ExportTools.js
Normal file
97
src/components/ExportTools.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Papa from 'papaparse';
|
||||||
|
import './ExportTools.css';
|
||||||
|
|
||||||
|
const ExportTools = ({ plan, calendarEvents }) => {
|
||||||
|
const [exportFormat, setExportFormat] = useState('csv');
|
||||||
|
const [exportType, setExportType] = useState('all');
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
// Use the calendarEvents from the service if available, otherwise use sample data
|
||||||
|
const csvData = calendarEvents || [
|
||||||
|
{
|
||||||
|
Subject: 'Sample: Slow Cooker Pot Roast (8h30m|450cal)',
|
||||||
|
Start_Date: '3/9/2025',
|
||||||
|
Start_Time: '17:00',
|
||||||
|
End_Date: '3/9/2025',
|
||||||
|
End_Time: '18:00',
|
||||||
|
All_Day_Event: 'False',
|
||||||
|
Description: 'This is a sample event. Generate a meal plan to get real data.',
|
||||||
|
Location: '',
|
||||||
|
Private: 'False'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Convert to CSV string
|
||||||
|
const csv = Papa.unparse(csvData);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', 'meal_plan_calendar.csv');
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="export-tools">
|
||||||
|
<h2>Export Your Meal Plan</h2>
|
||||||
|
|
||||||
|
<div className="export-options">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="exportFormat">Export Format:</label>
|
||||||
|
<select
|
||||||
|
id="exportFormat"
|
||||||
|
value={exportFormat}
|
||||||
|
onChange={(e) => setExportFormat(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="csv">CSV (Google Calendar)</option>
|
||||||
|
<option value="pdf">PDF</option>
|
||||||
|
<option value="ical">iCalendar (.ics)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="exportType">Export Content:</label>
|
||||||
|
<select
|
||||||
|
id="exportType"
|
||||||
|
value={exportType}
|
||||||
|
onChange={(e) => setExportType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Complete Meal Plan</option>
|
||||||
|
<option value="recipes">Recipes Only</option>
|
||||||
|
<option value="groceries">Grocery Lists Only</option>
|
||||||
|
<option value="calendar">Calendar Events Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-actions">
|
||||||
|
<button
|
||||||
|
className="export-button"
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
Export Meal Plan
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="export-help">
|
||||||
|
<h3>Export Instructions</h3>
|
||||||
|
<p>
|
||||||
|
<strong>For Google Calendar:</strong> Export as CSV and import into Google Calendar using the "Import" option in Settings.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>For Apple Calendar:</strong> Export as iCalendar (.ics) and open the file with Apple Calendar.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>For Printing:</strong> Export as PDF for a printable version of your meal plan, recipes, and grocery lists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExportTools;
|
129
src/components/GeminiTest.css
Normal file
129
src/components/GeminiTest.css
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
.gemini-test {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Gemini Test */
|
||||||
|
body.dark-mode .gemini-test {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .form-group textarea {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .test-button {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button:hover:not(:disabled) {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .test-button:hover:not(:disabled) {
|
||||||
|
background-color: #a9e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-button:disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .test-button:disabled {
|
||||||
|
background-color: #555;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #c62828;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .error-message {
|
||||||
|
background-color: #3a0000;
|
||||||
|
color: #ff8a80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .response-container {
|
||||||
|
border-top-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-container h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content {
|
||||||
|
background-color: #fff;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .response-content {
|
||||||
|
background-color: #282828;
|
||||||
|
border-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
80
src/components/GeminiTest.js
Normal file
80
src/components/GeminiTest.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './GeminiTest.css';
|
||||||
|
import GeminiService from '../services/GeminiService';
|
||||||
|
|
||||||
|
const GeminiTest = () => {
|
||||||
|
const [prompt, setPrompt] = useState('Write a short recipe for spaghetti bolognese.');
|
||||||
|
const [response, setResponse] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Initialize Gemini service with the API key
|
||||||
|
const geminiService = new GeminiService('AIzaSyAIxqZ9V0Ozj1u5-WCGdSu6_MNSKyCuRJU');
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setResponse('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call Gemini API
|
||||||
|
const result = await geminiService.generateText(prompt, {
|
||||||
|
temperature: 0.7,
|
||||||
|
maxOutputTokens: 2048
|
||||||
|
});
|
||||||
|
|
||||||
|
setResponse(result.text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error testing Gemini API:', err);
|
||||||
|
setError(err.message || 'Failed to get response from Gemini API');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gemini-test">
|
||||||
|
<h2>Gemini API Test</h2>
|
||||||
|
|
||||||
|
<div className="test-form">
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="prompt">Test Prompt:</label>
|
||||||
|
<textarea
|
||||||
|
id="prompt"
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Enter a prompt to test the Gemini API"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="test-button"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Testing...' : 'Test API'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
<p>{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{response && (
|
||||||
|
<div className="response-container">
|
||||||
|
<h3>API Response:</h3>
|
||||||
|
<div className="response-content">
|
||||||
|
<pre>{response}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeminiTest;
|
290
src/components/MealPlanDisplay.css
Normal file
290
src/components/MealPlanDisplay.css
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
.meal-plan-display {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for meal plan display */
|
||||||
|
body.dark-mode .meal-plan-display {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .view-selector {
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button {
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .view-selector button {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button.active {
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .view-selector button.active {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button:hover:not(.active) {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .view-selector button:hover:not(.active) {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summary View Styles */
|
||||||
|
.summary-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table th,
|
||||||
|
.summary-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .summary-table th,
|
||||||
|
body.dark-mode .summary-table td {
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table th {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .summary-table th {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table tr:hover {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .summary-table tr:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grocery List Styles */
|
||||||
|
.grocery-list-filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grocery-list-filters label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grocery-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grocery-table th,
|
||||||
|
.grocery-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .grocery-table th,
|
||||||
|
body.dark-mode .grocery-table td {
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grocery-table th {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .grocery-table th {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe View Styles */
|
||||||
|
.recipe-card {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .recipe-card {
|
||||||
|
background-color: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-date {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .recipe-date {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-info {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-info span {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .recipe-info span {
|
||||||
|
background-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredients,
|
||||||
|
.recipe-instructions,
|
||||||
|
.recipe-notes {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredients h5,
|
||||||
|
.recipe-instructions h5,
|
||||||
|
.recipe-notes h5 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredients ul,
|
||||||
|
.recipe-instructions ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-ingredients li,
|
||||||
|
.recipe-instructions li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Calendar View Styles */
|
||||||
|
.calendar-container {
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .calendar-header {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 5px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .calendar-day {
|
||||||
|
border-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-date {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-meal {
|
||||||
|
font-size: 14px;
|
||||||
|
background-color: #e8f0fe;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .calendar-meal {
|
||||||
|
background-color: #004080;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-meal.empty {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .calendar-meal.empty {
|
||||||
|
background-color: #333;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.view-selector {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-selector button {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-info {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header,
|
||||||
|
.calendar-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header div:nth-child(n+4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
163
src/components/MealPlanDisplay.js
Normal file
163
src/components/MealPlanDisplay.js
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './MealPlanDisplay.css';
|
||||||
|
import RawMealPlanViewer from './RawMealPlanViewer';
|
||||||
|
import MealPlanFeedback from './MealPlanFeedback';
|
||||||
|
|
||||||
|
const MealPlanDisplay = ({ plan }) => {
|
||||||
|
const [activeView, setActiveView] = useState('summary');
|
||||||
|
const [rawPlanText, setRawPlanText] = useState(plan.rawAIResponse || null);
|
||||||
|
const [feedback, setFeedback] = useState([]);
|
||||||
|
|
||||||
|
const handleFeedbackSubmit = (newFeedback) => {
|
||||||
|
setFeedback([...feedback, newFeedback]);
|
||||||
|
console.log('Feedback submitted:', newFeedback);
|
||||||
|
// In a real app, this would trigger a regeneration of the meal plan
|
||||||
|
};
|
||||||
|
|
||||||
|
// This is a placeholder. In a real application, this would use actual data from the plan
|
||||||
|
const formatDate = (date) => {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const renderSummaryView = () => {
|
||||||
|
return (
|
||||||
|
<div className="summary-view">
|
||||||
|
<h3>Meal Plan Summary</h3>
|
||||||
|
<table className="summary-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Dish</th>
|
||||||
|
<th>Total Time</th>
|
||||||
|
<th>Calories</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{plan.meals && plan.meals.map(meal => (
|
||||||
|
<tr key={meal.id}>
|
||||||
|
<td>{formatDate(new Date(meal.date))}</td>
|
||||||
|
<td>{meal.name}</td>
|
||||||
|
<td>{meal.totalTime}</td>
|
||||||
|
<td>{meal.calories} cal</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderGroceryList = () => {
|
||||||
|
return (
|
||||||
|
<div className="grocery-list-view">
|
||||||
|
<h3>Grocery List</h3>
|
||||||
|
|
||||||
|
<div className="grocery-list-filters">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" defaultChecked={true} />
|
||||||
|
Show Perishable Items
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" defaultChecked={true} />
|
||||||
|
Show Non-Perishable Items
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="grocery-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Ingredient</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Perishable</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{plan.groceryLists && plan.groceryLists.length > 0 && plan.groceryLists[0].items.map((item, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
<td>{item.name}</td>
|
||||||
|
<td>{item.amount}</td>
|
||||||
|
<td>{item.perishable ? 'Yes' : 'No'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderRecipesView = () => {
|
||||||
|
return (
|
||||||
|
<div className="recipes-view">
|
||||||
|
<h3>Detailed Recipes</h3>
|
||||||
|
|
||||||
|
{plan.parsedAIResponse && plan.parsedAIResponse.parsedData && plan.parsedAIResponse.parsedData.detailedRecipes}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCalendarView = () => {
|
||||||
|
return (
|
||||||
|
<div className="calendar-view">
|
||||||
|
<h3>Calendar View</h3>
|
||||||
|
{plan.parsedAIResponse && plan.parsedAIResponse.parsedData && plan.parsedAIResponse.parsedData.calendarView}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meal-plan-display">
|
||||||
|
<h2>Your Generated Meal Plan</h2>
|
||||||
|
|
||||||
|
<div className="view-selector">
|
||||||
|
<button
|
||||||
|
className={activeView === 'summary' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveView('summary')}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeView === 'grocery' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveView('grocery')}
|
||||||
|
>
|
||||||
|
Grocery List
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeView === 'recipes' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveView('recipes')}
|
||||||
|
>
|
||||||
|
Full Recipes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeView === 'calendar' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveView('calendar')}
|
||||||
|
>
|
||||||
|
Calendar View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={activeView === 'raw' ? 'active' : ''}
|
||||||
|
onClick={() => setActiveView('raw')}
|
||||||
|
>
|
||||||
|
Raw Text
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="view-content">
|
||||||
|
{activeView === 'summary' && renderSummaryView()}
|
||||||
|
{activeView === 'grocery' && renderGroceryList()}
|
||||||
|
{activeView === 'recipes' && renderRecipesView()}
|
||||||
|
{activeView === 'calendar' && renderCalendarView()}
|
||||||
|
{activeView === 'raw' && <RawMealPlanViewer rawPlan={plan.rawAIResponse.text} />}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MealPlanFeedback onSubmitFeedback={handleFeedbackSubmit} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealPlanDisplay;
|
189
src/components/MealPlanFeedback.css
Normal file
189
src/components/MealPlanFeedback.css
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
.meal-plan-feedback {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 30px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Meal Plan Feedback */
|
||||||
|
body.dark-mode .meal-plan-feedback {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
box-shadow: 0 2px 4px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meal-plan-feedback h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .meal-plan-feedback h3 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-intro {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-intro {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-type,
|
||||||
|
.feedback-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-type label,
|
||||||
|
.feedback-text label {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-type label,
|
||||||
|
body.dark-mode .feedback-text label {
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-type select {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-type select {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-text textarea {
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-text textarea {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .submit-button {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover:not(:disabled) {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .submit-button:hover:not(:disabled) {
|
||||||
|
background-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .submit-button:disabled {
|
||||||
|
background-color: #555;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success {
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-success {
|
||||||
|
background-color: #1b5e20;
|
||||||
|
color: #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-success p {
|
||||||
|
margin: 0;
|
||||||
|
color: #2e7d32;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-success p {
|
||||||
|
color: #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-examples {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-examples {
|
||||||
|
background-color: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-examples h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-examples h4 {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-examples ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback-examples li {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .feedback-examples li {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.meal-plan-feedback {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
102
src/components/MealPlanFeedback.js
Normal file
102
src/components/MealPlanFeedback.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './MealPlanFeedback.css';
|
||||||
|
|
||||||
|
const MealPlanFeedback = ({ onSubmitFeedback }) => {
|
||||||
|
const [feedback, setFeedback] = useState('');
|
||||||
|
const [feedbackType, setFeedbackType] = useState('general');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!feedback.trim()) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// In a real app, this would send the feedback to an API
|
||||||
|
// For now, we'll just simulate a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onSubmitFeedback) {
|
||||||
|
onSubmitFeedback({
|
||||||
|
type: feedbackType,
|
||||||
|
text: feedback,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
setSubmitted(true);
|
||||||
|
|
||||||
|
// Reset form after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setFeedback('');
|
||||||
|
setFeedbackType('general');
|
||||||
|
setSubmitted(false);
|
||||||
|
}, 3000);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="meal-plan-feedback">
|
||||||
|
<h3>Customize Your Meal Plan</h3>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="feedback-success">
|
||||||
|
<p>Thank you for your feedback! Your meal plan will be updated shortly.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="feedback-intro">
|
||||||
|
Not satisfied with your meal plan? Tell us what you'd like to change and we'll regenerate it.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="feedback-form">
|
||||||
|
<div className="feedback-type">
|
||||||
|
<label>What would you like to change?</label>
|
||||||
|
<select
|
||||||
|
value={feedbackType}
|
||||||
|
onChange={(e) => setFeedbackType(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="general">General Feedback</option>
|
||||||
|
<option value="taste">Flavor Preferences</option>
|
||||||
|
<option value="complexity">Recipe Complexity</option>
|
||||||
|
<option value="ingredients">Specific Ingredients</option>
|
||||||
|
<option value="meals">Specific Meals</option>
|
||||||
|
<option value="time">Cooking Time</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feedback-text">
|
||||||
|
<label>Your Feedback</label>
|
||||||
|
<textarea
|
||||||
|
value={feedback}
|
||||||
|
onChange={(e) => setFeedback(e.target.value)}
|
||||||
|
placeholder="Examples: 'Make the recipes simpler', 'Add more Italian dishes', 'Avoid bell peppers', 'Use the slow cooker more often'..."
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="submit-button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!feedback.trim() || isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Update Meal Plan'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="feedback-examples">
|
||||||
|
<h4>Example Feedback</h4>
|
||||||
|
<ul>
|
||||||
|
<li>"I'd like to have more vegetarian options during the week."</li>
|
||||||
|
<li>"Can we include more pasta dishes?"</li>
|
||||||
|
<li>"The meals are too complex, I need simpler recipes with fewer ingredients."</li>
|
||||||
|
<li>"Please add more breakfast-for-dinner options."</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealPlanFeedback;
|
99
src/components/RawMealPlanViewer.css
Normal file
99
src/components/RawMealPlanViewer.css
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
.raw-meal-plan-viewer {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Raw Meal Plan Viewer */
|
||||||
|
body.dark-mode .raw-meal-plan-viewer {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .viewer-header {
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .viewer-header h3 {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .copy-button {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .copy-button:hover {
|
||||||
|
background-color: #a9e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-content {
|
||||||
|
padding: 16px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-content pre {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .plan-content pre {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-footer {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .viewer-footer {
|
||||||
|
border-top-color: #333;
|
||||||
|
background-color: #282828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-footer p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .viewer-footer p {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
65
src/components/RawMealPlanViewer.js
Normal file
65
src/components/RawMealPlanViewer.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import './RawMealPlanViewer.css';
|
||||||
|
|
||||||
|
const RawMealPlanViewer = ({ rawPlan }) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// Get the raw text from the AI response
|
||||||
|
const mealPlanText = rawPlan?.text ||
|
||||||
|
rawPlan?.rawResponse?.text ||
|
||||||
|
rawPlan?.parsedAIResponse?.fullText ||
|
||||||
|
// Fallback to placeholder text if no real response is available
|
||||||
|
`# Meal Plan
|
||||||
|
|
||||||
|
## Summary Table
|
||||||
|
|
||||||
|
| Dish | Total Time | Calories | Date |
|
||||||
|
|------|------------|----------|------|
|
||||||
|
| Slow Cooker Pot Roast | 8h 30m | 450 | Sunday, Mar 9 |
|
||||||
|
| Spaghetti with Meat Sauce | 1h | 520 | Monday, Mar 10 |
|
||||||
|
| Chicken Stir-Fry | 35m | 380 | Tuesday, Mar 11 |
|
||||||
|
| ...more dishes... | ... | ... | ... |
|
||||||
|
|
||||||
|
## Grocery List
|
||||||
|
...
|
||||||
|
|
||||||
|
## Detailed Recipes
|
||||||
|
...
|
||||||
|
|
||||||
|
## Calendar View
|
||||||
|
...`;
|
||||||
|
|
||||||
|
const handleCopyToClipboard = () => {
|
||||||
|
navigator.clipboard.writeText(mealPlanText).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="raw-meal-plan-viewer">
|
||||||
|
<div className="viewer-header">
|
||||||
|
<h3>Raw Meal Plan Text</h3>
|
||||||
|
<button
|
||||||
|
className="copy-button"
|
||||||
|
onClick={handleCopyToClipboard}
|
||||||
|
>
|
||||||
|
{copied ? 'Copied!' : 'Copy to Clipboard'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="plan-content">
|
||||||
|
<pre>{mealPlanText}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="viewer-footer">
|
||||||
|
<p>
|
||||||
|
<strong>Note:</strong> In a production app, this would show the complete formatted text output from the AI model.
|
||||||
|
You can copy this and paste it elsewhere or send it to someone who doesn't have the app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RawMealPlanViewer;
|
246
src/components/SavedConfigs.css
Normal file
246
src/components/SavedConfigs.css
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
.saved-configs {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Saved Configs */
|
||||||
|
body.dark-mode .saved-configs {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-configs-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saved-configs-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-new-button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-new-button {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-new-button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-new-button:hover {
|
||||||
|
background-color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-dialog {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-dialog {
|
||||||
|
background-color: #282828;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-dialog input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .save-dialog input {
|
||||||
|
background-color: #333;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button:first-child {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dialog-buttons button:first-child {
|
||||||
|
background-color: #388e3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button:last-child {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dialog-buttons button:last-child {
|
||||||
|
background-color: #555;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-buttons button:disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .dialog-buttons button:disabled {
|
||||||
|
background-color: #555;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-configs {
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
font-style: italic;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .no-configs {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configs-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-item {
|
||||||
|
border-bottom-color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-item:hover {
|
||||||
|
background-color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-info h4 {
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-info p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .config-info p {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-button,
|
||||||
|
.delete-button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-button {
|
||||||
|
background-color: #4285F4;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .load-button {
|
||||||
|
background-color: #61dafb;
|
||||||
|
color: #121212;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-button {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-button:hover {
|
||||||
|
background-color: #3367d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .load-button:hover {
|
||||||
|
background-color: #a9e9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .delete-button:hover {
|
||||||
|
background-color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.saved-configs-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-actions {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-button,
|
||||||
|
.delete-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
130
src/components/SavedConfigs.js
Normal file
130
src/components/SavedConfigs.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import './SavedConfigs.css';
|
||||||
|
|
||||||
|
const SavedConfigs = ({ onLoadConfig }) => {
|
||||||
|
const [savedConfigs, setSavedConfigs] = useState([]);
|
||||||
|
const [newConfigName, setNewConfigName] = useState('');
|
||||||
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||||
|
|
||||||
|
// Load saved configurations from localStorage on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const configs = localStorage.getItem('mealPlannerConfigs');
|
||||||
|
if (configs) {
|
||||||
|
setSavedConfigs(JSON.parse(configs));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveConfig = (name) => {
|
||||||
|
// Get current configuration
|
||||||
|
const currentConfig = localStorage.getItem('currentMealPlanConfig');
|
||||||
|
if (!currentConfig) return;
|
||||||
|
|
||||||
|
const config = JSON.parse(currentConfig);
|
||||||
|
config.name = name;
|
||||||
|
config.savedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
// Add to saved configs
|
||||||
|
const updatedConfigs = [...savedConfigs, config];
|
||||||
|
setSavedConfigs(updatedConfigs);
|
||||||
|
|
||||||
|
// Save to localStorage
|
||||||
|
localStorage.setItem('mealPlannerConfigs', JSON.stringify(updatedConfigs));
|
||||||
|
|
||||||
|
// Reset dialog
|
||||||
|
setNewConfigName('');
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfig = (index) => {
|
||||||
|
const updatedConfigs = savedConfigs.filter((_, i) => i !== index);
|
||||||
|
setSavedConfigs(updatedConfigs);
|
||||||
|
localStorage.setItem('mealPlannerConfigs', JSON.stringify(updatedConfigs));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadConfig = (config) => {
|
||||||
|
if (onLoadConfig) {
|
||||||
|
// Parse date string back to Date object
|
||||||
|
const loadedConfig = {...config};
|
||||||
|
loadedConfig.startDate = new Date(config.startDate);
|
||||||
|
onLoadConfig(loadedConfig);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="saved-configs">
|
||||||
|
<div className="saved-configs-header">
|
||||||
|
<h3>Saved Configurations</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="save-new-button"
|
||||||
|
onClick={() => setShowSaveDialog(true)}
|
||||||
|
>
|
||||||
|
Save Current Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSaveDialog && (
|
||||||
|
<div className="save-dialog">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newConfigName}
|
||||||
|
onChange={(e) => setNewConfigName(e.target.value)}
|
||||||
|
placeholder="Enter a name for this configuration"
|
||||||
|
/>
|
||||||
|
<div className="dialog-buttons">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSaveConfig(newConfigName)}
|
||||||
|
disabled={!newConfigName.trim()}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowSaveDialog(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{savedConfigs.length === 0 ? (
|
||||||
|
<p className="no-configs">No saved configurations yet</p>
|
||||||
|
) : (
|
||||||
|
<ul className="configs-list">
|
||||||
|
{savedConfigs.map((config, index) => (
|
||||||
|
<li key={index} className="config-item">
|
||||||
|
<div className="config-info">
|
||||||
|
<h4>{config.name}</h4>
|
||||||
|
<p>
|
||||||
|
{new Date(config.savedAt).toLocaleDateString()} |
|
||||||
|
{config.portions} portions |
|
||||||
|
{config.likedFlavors.length} liked flavors
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="config-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="load-button"
|
||||||
|
onClick={() => handleLoadConfig(config)}
|
||||||
|
>
|
||||||
|
Load
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="delete-button"
|
||||||
|
onClick={() => handleDeleteConfig(index)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SavedConfigs;
|
26
src/index.css
Normal file
26
src/index.css
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease; /* Smooth transition */
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base light mode styles (already exist or are default) */
|
||||||
|
body {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
body.dark-mode {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
11
src/index.js
Normal file
11
src/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App';
|
||||||
|
|
||||||
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
root.render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
190
src/services/AIService.js
Normal file
190
src/services/AIService.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
// Service for communicating with AI models to generate meal plans
|
||||||
|
// Uses Google's Gemini API for generating the meal plans
|
||||||
|
|
||||||
|
import GeminiService from './GeminiService';
|
||||||
|
|
||||||
|
// Initialize Gemini service with API key
|
||||||
|
// In production, this should be stored in environment variables
|
||||||
|
const GEMINI_API_KEY = 'AIzaSyAIxqZ9V0Ozj1u5-WCGdSu6_MNSKyCuRJU';
|
||||||
|
const geminiService = new GeminiService(GEMINI_API_KEY);
|
||||||
|
|
||||||
|
const AIService = {
|
||||||
|
// Generate a prompt for the AI based on user configuration
|
||||||
|
buildPrompt: (config) => {
|
||||||
|
const startDateFormatted = config.startDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build the prompt based on user configuration
|
||||||
|
let prompt = `Generate a meal plan for ${config.numberOfIntervals} ${config.shoppingIntervalUnit}, generating ${config.numberOfIntervals} ${config.shoppingIntervalUnit} at a time.
|
||||||
|
Please return the response as a JSON object with the following structure, wrapped in a markdown code block like \`\`\`json ... \`\`\`:
|
||||||
|
{
|
||||||
|
"summaryTable": "Markdown table string for the summary table",
|
||||||
|
"groceryList": "Markdown list/table string for the grocery list",
|
||||||
|
"detailedRecipes": "Markdown text for all detailed recipes",
|
||||||
|
"calendarView": "Markdown table string for the calendar view"
|
||||||
|
}
|
||||||
|
Ensure the content within the \`\`\`json\`\`\` block is a valid JSON string.`;
|
||||||
|
|
||||||
|
// Add special instructions
|
||||||
|
if (config.specialInstructions && config.specialInstructions.length > 0) {
|
||||||
|
config.specialInstructions.forEach(instruction => {
|
||||||
|
prompt += ` ${instruction}.`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cooking equipment
|
||||||
|
if (config.cookingEquipment && config.cookingEquipment.length > 0) {
|
||||||
|
prompt += ` We have ${config.cookingEquipment.join(', ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add flavor preferences
|
||||||
|
if (config.likedFlavors && config.likedFlavors.length > 0) {
|
||||||
|
prompt += ` We like ${config.likedFlavors.join(', ')} dishes.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.dislikedFlavors && config.dislikedFlavors.length > 0) {
|
||||||
|
prompt += ` We don't like ${config.dislikedFlavors.join(', ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add allergies
|
||||||
|
if (config.allergies && config.allergies.length > 0) {
|
||||||
|
prompt += ` We have allergies to ${config.allergies.join(', ')}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add standard instructions for meal plan format
|
||||||
|
prompt += `
|
||||||
|
We need to build a grocery list that we pickup on the first day of each interval and lasts for the duration of the interval. Account for perishable ingredients like fresh mushrooms and bread.
|
||||||
|
Call out when meats should be pulled out of the freezer for future meals, and when prep work should be done such as marinades or vegetables that can be prepped ahead of time.
|
||||||
|
Include recipes for each day for ${config.portions} servings. Document meat temps where relevant. List ingredients by both volume and weight measurements.
|
||||||
|
Do this for the next ${config.numberOfIntervals} ${config.shoppingIntervalUnit}, with a grocery list for the start of each interval and different plans for each interval. Include serving and side suggestions and approximate calorie count per dish serving. Include information with each dish how to store leftovers and how long they will last in the fridge. Where possible suggest bulk quantities that can be used over the course of the month.
|
||||||
|
Start with a summary table of dishes that outlines dish name, total time required to make, calories, and the date it should be done on. The start date is ${startDateFormatted}.
|
||||||
|
Next supply a table of the full ingredients list for all dishes. Show name and quantity and a flag for is perishable for items with short lifespans.
|
||||||
|
Next list out each day in order with the full recipe. Each recipe should start with the ingredients list, then cooking/prep instructions. Include measurements in the recipe instructions as they are listed. For example "Add 1/3 cup whole milk".
|
||||||
|
Lastly, summarize all dishes by name on a table laid out as a calendar. Each cell should have the date and the dish name only.`;
|
||||||
|
|
||||||
|
return prompt;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Call Gemini API to generate meal plan based on prompt
|
||||||
|
generateWithAI: async (prompt) => {
|
||||||
|
try {
|
||||||
|
console.log('Sending prompt to Gemini API:', prompt);
|
||||||
|
|
||||||
|
// Setting a lower temperature for more deterministic results since this is a structured task
|
||||||
|
const options = {
|
||||||
|
temperature: 0.4,
|
||||||
|
maxOutputTokens: 8192, // Maximum allowed for detailed meal plans
|
||||||
|
topK: 40,
|
||||||
|
topP: 0.95,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call Gemini API with the prompt
|
||||||
|
const response = await geminiService.generateText(prompt, options);
|
||||||
|
|
||||||
|
console.log('Received response from Gemini API');
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
text: response.text,
|
||||||
|
prompt,
|
||||||
|
rawResponse: response
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating meal plan with Gemini:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Parse AI response into structured meal plan data
|
||||||
|
// This extracts the relevant sections from AI response
|
||||||
|
parseAIResponse: (aiResponse) => {
|
||||||
|
if (!aiResponse || !aiResponse.text) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'No AI response to parse'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = aiResponse.text;
|
||||||
|
let parsedJson;
|
||||||
|
let jsonString = text; // Assume the whole text is JSON initially
|
||||||
|
|
||||||
|
// Attempt to extract JSON from a markdown code block (```json ... ```)
|
||||||
|
const jsonCodeBlockMatch = text.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
|
|
||||||
|
if (jsonCodeBlockMatch && jsonCodeBlockMatch[1]) {
|
||||||
|
console.log('Found JSON code block, attempting to parse content within.');
|
||||||
|
jsonString = jsonCodeBlockMatch[1]; // Use content from the code block
|
||||||
|
} else {
|
||||||
|
console.warn('No JSON code block found, attempting to parse the entire response text as JSON.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Attempt to parse the extracted string (or original text if no code block) as JSON
|
||||||
|
parsedJson = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
// Extract data from the parsed JSON object
|
||||||
|
const summaryTable = parsedJson.summaryTable || '';
|
||||||
|
const groceryList = parsedJson.groceryList || '';
|
||||||
|
const detailedRecipes = parsedJson.detailedRecipes || '';
|
||||||
|
const calendarView = parsedJson.calendarView || '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
parsedData: {
|
||||||
|
summaryTable,
|
||||||
|
groceryList,
|
||||||
|
detailedRecipes,
|
||||||
|
calendarView,
|
||||||
|
fullText: text // Keep full text for debugging/raw view
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing AI response as JSON:', error);
|
||||||
|
// If JSON parsing fails, fall back to the old regex parsing logic
|
||||||
|
// This provides some robustness in case the AI doesn't return perfect JSON
|
||||||
|
try {
|
||||||
|
console.warn('JSON parsing failed, attempting regex parsing fallback.');
|
||||||
|
// Extract summary table
|
||||||
|
const summaryTableMatch = text.match(/^#+\s*Summary\s*(Table)?([\s\S]*?)(?=^#+\s*|\n\n\n|$)/im);
|
||||||
|
const summaryTable = summaryTableMatch ? summaryTableMatch[1].trim() : '';
|
||||||
|
|
||||||
|
// Extract grocery list
|
||||||
|
const groceryListMatch = text.match(/^#+\s*Grocery\s*List?([\s\S]*?)(?=^#+\s*|\n\n\n|$)/im);
|
||||||
|
const groceryList = groceryListMatch ? groceryListMatch[1].trim() : '';
|
||||||
|
|
||||||
|
// Extract detailed recipes
|
||||||
|
const detailedRecipesMatch = text.match(/^#+\s*Detailed?\s*Recipes([\s\S]*?)(?=^#+\s*|\n\n\n|$)/im);
|
||||||
|
const detailedRecipes = detailedRecipesMatch ? detailedRecipesMatch[1].trim() : '';
|
||||||
|
|
||||||
|
// Extract calendar view
|
||||||
|
const calendarViewMatch = text.match(/^#+\s*Calendar\s*(View)?([\s\S]*?)(?=^#+\s*|\n\n\n|$)/im);
|
||||||
|
const calendarView = calendarViewMatch ? calendarViewMatch[1].trim() : '';
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
parsedData: {
|
||||||
|
summaryTable,
|
||||||
|
groceryList,
|
||||||
|
detailedRecipes,
|
||||||
|
calendarView,
|
||||||
|
fullText: text
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (regexError) {
|
||||||
|
console.error('Regex parsing fallback also failed:', regexError);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to parse AI response as JSON or using regex: ${error.message}`,
|
||||||
|
fullText: text
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AIService;
|
79
src/services/GeminiService.js
Normal file
79
src/services/GeminiService.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Service for interacting with Google's Gemini AI API
|
||||||
|
// This service handles communication with the Gemini Flash 2.5 Pro model
|
||||||
|
|
||||||
|
class GeminiService {
|
||||||
|
constructor(apiKey) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.baseUrl = 'https://generativelanguage.googleapis.com/v1beta/models';
|
||||||
|
this.model = 'gemini-2.5-flash-preview-04-17'; // Updated model name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate text from a prompt
|
||||||
|
async generateText(prompt, options = {}) {
|
||||||
|
const defaultOptions = {
|
||||||
|
temperature: 0.7,
|
||||||
|
maxOutputTokens: 8192,
|
||||||
|
topK: 40,
|
||||||
|
topP: 0.95,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestOptions = { ...defaultOptions, ...options };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `${this.baseUrl}/${this.model}:generateContent?key=${this.apiKey}`;
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
text: prompt
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: requestOptions.temperature,
|
||||||
|
maxOutputTokens: requestOptions.maxOutputTokens,
|
||||||
|
topK: requestOptions.topK,
|
||||||
|
topP: requestOptions.topP
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Sending request to Gemini API...');
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json();
|
||||||
|
throw new Error(`Gemini API error: ${errorData.error.message || response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.candidates || data.candidates.length === 0) {
|
||||||
|
throw new Error('No response generated from Gemini');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the text from the response
|
||||||
|
const generatedText = data.candidates[0].content.parts[0].text;
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: generatedText,
|
||||||
|
usage: data.usageMetadata,
|
||||||
|
rawResponse: data
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating text with Gemini:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GeminiService;
|
371
src/services/MealPlanService.js
Normal file
371
src/services/MealPlanService.js
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
// Service for generating meal plans
|
||||||
|
// Integrates with AIService for AI-based plan generation
|
||||||
|
import AIService from './AIService';
|
||||||
|
|
||||||
|
// Helper function to add days to a date
|
||||||
|
const addDays = (date, days) => {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format a date to a string
|
||||||
|
const formatDate = (date) => {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sample recipes database (simplified)
|
||||||
|
const sampleRecipes = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Slow Cooker Pot Roast',
|
||||||
|
prepTime: '30 minutes',
|
||||||
|
cookTime: '8 hours',
|
||||||
|
totalTime: '8 hours 30 minutes',
|
||||||
|
calories: 450,
|
||||||
|
category: 'american',
|
||||||
|
equipment: ['slowcooker', 'oven'],
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Chuck roast', amount: '3 lbs (1.36 kg)', perishable: true },
|
||||||
|
{ name: 'Carrots', amount: '4 large (about 300g)', perishable: true },
|
||||||
|
{ name: 'Potatoes', amount: '1.5 lbs (680g)', perishable: false },
|
||||||
|
{ name: 'Onion', amount: '1 large', perishable: true },
|
||||||
|
{ name: 'Beef broth', amount: '2 cups (480ml)', perishable: false },
|
||||||
|
{ name: 'Worcestershire sauce', amount: '2 tablespoons (30ml)', perishable: false },
|
||||||
|
{ name: 'Garlic', amount: '4 cloves', perishable: true },
|
||||||
|
{ name: 'Thyme', amount: '2 teaspoons dried', perishable: false },
|
||||||
|
{ name: 'Rosemary', amount: '1 teaspoon dried', perishable: false },
|
||||||
|
{ name: 'Salt', amount: '1 teaspoon', perishable: false },
|
||||||
|
{ name: 'Black pepper', amount: '1/2 teaspoon', perishable: false }
|
||||||
|
],
|
||||||
|
instructions: [
|
||||||
|
'Season chuck roast with salt and pepper.',
|
||||||
|
'In a large skillet, sear the roast on all sides until browned.',
|
||||||
|
'Place carrots, potatoes, and onion in the bottom of a slow cooker.',
|
||||||
|
'Place the seared roast on top of the vegetables.',
|
||||||
|
'Mix beef broth and Worcestershire sauce together, then pour over the roast.',
|
||||||
|
'Add minced garlic, thyme, and rosemary.',
|
||||||
|
'Cover and cook on low for 8 hours.',
|
||||||
|
'Remove the roast and let rest for 10 minutes before slicing.',
|
||||||
|
'Serve with the vegetables and cooking liquid.'
|
||||||
|
],
|
||||||
|
storage: 'Refrigerate in airtight containers for up to 3 days. Can be frozen for up to 3 months.',
|
||||||
|
notes: 'Pull roast from freezer the night before and place in refrigerator to thaw.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
name: 'Spaghetti with Meat Sauce',
|
||||||
|
prepTime: '15 minutes',
|
||||||
|
cookTime: '45 minutes',
|
||||||
|
totalTime: '1 hour',
|
||||||
|
calories: 520,
|
||||||
|
category: 'italian',
|
||||||
|
equipment: ['stove'],
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Ground beef', amount: '1.5 lbs (680g)', perishable: true },
|
||||||
|
{ name: 'Spaghetti', amount: '1 lb (454g)', perishable: false },
|
||||||
|
{ name: 'Tomato sauce', amount: '28 oz can', perishable: false },
|
||||||
|
{ name: 'Diced tomatoes', amount: '14.5 oz can', perishable: false },
|
||||||
|
{ name: 'Onion', amount: '1 medium', perishable: true },
|
||||||
|
{ name: 'Garlic', amount: '3 cloves', perishable: true },
|
||||||
|
{ name: 'Olive oil', amount: '2 tablespoons (30ml)', perishable: false },
|
||||||
|
{ name: 'Italian seasoning', amount: '1 tablespoon', perishable: false },
|
||||||
|
{ name: 'Salt', amount: 'to taste', perishable: false },
|
||||||
|
{ name: 'Black pepper', amount: 'to taste', perishable: false },
|
||||||
|
{ name: 'Parmesan cheese', amount: '1/2 cup grated (50g)', perishable: true }
|
||||||
|
],
|
||||||
|
instructions: [
|
||||||
|
'In a large pot, bring salted water to a boil for the pasta.',
|
||||||
|
'In a large skillet, heat olive oil over medium heat.',
|
||||||
|
'Add diced onion and cook until translucent, about 5 minutes.',
|
||||||
|
'Add minced garlic and cook for 30 seconds until fragrant.',
|
||||||
|
'Add ground beef and cook until browned, about 7-8 minutes, breaking it up as it cooks.',
|
||||||
|
'Drain excess fat if necessary.',
|
||||||
|
'Add tomato sauce, diced tomatoes, and Italian seasoning.',
|
||||||
|
'Bring to a simmer, then reduce heat to low and cook for 30 minutes, stirring occasionally.',
|
||||||
|
'Meanwhile, cook spaghetti according to package directions until al dente.',
|
||||||
|
'Drain pasta and return to the pot.',
|
||||||
|
'Season meat sauce with salt and pepper to taste.',
|
||||||
|
'Serve pasta topped with meat sauce and grated Parmesan cheese.'
|
||||||
|
],
|
||||||
|
storage: 'Refrigerate sauce separately from pasta in airtight containers for up to 4 days. Can be frozen for up to 3 months.',
|
||||||
|
notes: 'Sauce can be made ahead of time and refrigerated.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Chicken Stir-Fry',
|
||||||
|
prepTime: '20 minutes',
|
||||||
|
cookTime: '15 minutes',
|
||||||
|
totalTime: '35 minutes',
|
||||||
|
calories: 380,
|
||||||
|
category: 'asian',
|
||||||
|
equipment: ['stove'],
|
||||||
|
ingredients: [
|
||||||
|
{ name: 'Chicken breast', amount: '1.5 lbs (680g)', perishable: true },
|
||||||
|
{ name: 'Broccoli', amount: '3 cups chopped (about 270g)', perishable: true },
|
||||||
|
{ name: 'Bell peppers', amount: '2 medium', perishable: true },
|
||||||
|
{ name: 'Carrots', amount: '2 medium', perishable: true },
|
||||||
|
{ name: 'Snap peas', amount: '1 cup (about 150g)', perishable: true },
|
||||||
|
{ name: 'Garlic', amount: '3 cloves', perishable: true },
|
||||||
|
{ name: 'Ginger', amount: '1 tablespoon minced', perishable: true },
|
||||||
|
{ name: 'Soy sauce', amount: '3 tablespoons (45ml)', perishable: false },
|
||||||
|
{ name: 'Sesame oil', amount: '1 tablespoon (15ml)', perishable: false },
|
||||||
|
{ name: 'Cornstarch', amount: '1 tablespoon', perishable: false },
|
||||||
|
{ name: 'Vegetable oil', amount: '2 tablespoons (30ml)', perishable: false },
|
||||||
|
{ name: 'Rice', amount: '2 cups uncooked (about 400g)', perishable: false }
|
||||||
|
],
|
||||||
|
instructions: [
|
||||||
|
'Slice chicken into thin strips. Mix with 1 tablespoon soy sauce and cornstarch.',
|
||||||
|
'Prepare rice according to package instructions.',
|
||||||
|
'Heat 1 tablespoon vegetable oil in a wok or large skillet over high heat.',
|
||||||
|
'Add chicken and stir-fry for 4-5 minutes until golden and cooked through. Remove from pan.',
|
||||||
|
'Add remaining vegetable oil to the pan.',
|
||||||
|
'Add garlic and ginger and stir-fry for 30 seconds until fragrant.',
|
||||||
|
'Add vegetables and stir-fry for 5-6 minutes until crisp-tender.',
|
||||||
|
'Return chicken to the pan and add remaining soy sauce and sesame oil.',
|
||||||
|
'Stir well to combine and cook for another 1-2 minutes.',
|
||||||
|
'Serve over rice.'
|
||||||
|
],
|
||||||
|
storage: 'Refrigerate in airtight containers for up to 3 days.',
|
||||||
|
notes: 'Vegetables can be prepped a day ahead and stored in the refrigerator.'
|
||||||
|
},
|
||||||
|
// More recipes would be added in a real application
|
||||||
|
];
|
||||||
|
|
||||||
|
// Main API service object
|
||||||
|
const MealPlanService = {
|
||||||
|
// Generate a meal plan based on the provided configuration
|
||||||
|
generateMealPlan: async (config) => {
|
||||||
|
try {
|
||||||
|
// Build prompt for AI
|
||||||
|
const prompt = AIService.buildPrompt(config);
|
||||||
|
|
||||||
|
// Call AI service
|
||||||
|
const aiResponse = await AIService.generateWithAI(prompt);
|
||||||
|
|
||||||
|
// Process the AI response to extract structured data
|
||||||
|
const parsedResponse = AIService.parseAIResponse(aiResponse);
|
||||||
|
|
||||||
|
// We're using a hybrid approach for now - we'll use our sample data
|
||||||
|
// but also include the raw AI response and parsed sections
|
||||||
|
|
||||||
|
// For demonstration purposes, we'll create a simple meal plan
|
||||||
|
const startDate = new Date(config.startDate);
|
||||||
|
|
||||||
|
// Calculate interval duration in days based on config
|
||||||
|
let intervalDurationInDays;
|
||||||
|
switch (config.shoppingIntervalUnit) {
|
||||||
|
case 'day':
|
||||||
|
intervalDurationInDays = config.shoppingIntervalQuantity;
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
intervalDurationInDays = config.shoppingIntervalQuantity * 7;
|
||||||
|
break;
|
||||||
|
// TODO: Handle 'month' unit if needed
|
||||||
|
default:
|
||||||
|
// Default to 7 days (1 week) if unit is unknown or month
|
||||||
|
intervalDurationInDays = 7;
|
||||||
|
console.warn(`Unknown shoppingIntervalUnit: ${config.shoppingIntervalUnit}. Defaulting to 7 days per interval.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalIntervals = config.numberOfIntervals;
|
||||||
|
|
||||||
|
// Generate meal assignments and grocery lists per interval
|
||||||
|
const meals = [];
|
||||||
|
const groceryLists = [];
|
||||||
|
|
||||||
|
// Use the parsed AI response to populate meals and grocery lists
|
||||||
|
if (parsedResponse && parsedResponse.success && parsedResponse.parsedData) {
|
||||||
|
const { summaryTable, groceryList, detailedRecipes, calendarView } = parsedResponse.parsedData;
|
||||||
|
|
||||||
|
// Parse markdown tables/text into structured data
|
||||||
|
// This is a simplified parsing logic and may need refinement based on actual AI output format
|
||||||
|
const parseSummaryTable = (markdown) => {
|
||||||
|
const lines = markdown.split('\n').filter(line => line.trim() !== '' && !line.startsWith('| :'));
|
||||||
|
const headers = lines[0].split('|').map(h => h.trim()).filter(h => h !== '');
|
||||||
|
const rows = lines.slice(1).map(line => {
|
||||||
|
const values = line.split('|').map(v => v.trim()).filter(v => v !== '');
|
||||||
|
const rowData = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
rowData[header] = values[index];
|
||||||
|
});
|
||||||
|
// Attempt to extract date and create a Date object
|
||||||
|
const dateString = rowData['Date'];
|
||||||
|
let date = null;
|
||||||
|
if (dateString) {
|
||||||
|
try {
|
||||||
|
date = new Date(dateString);
|
||||||
|
// Basic validation to check if the date is valid
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
date = null; // Invalid date
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
date = null; // Parsing error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: rowData['Dish Name'] ? rowData['Dish Name'].replace(/\s+/g, '-') : `meal-${Math.random()}`, // Simple ID generation
|
||||||
|
name: rowData['Dish Name'] || 'Unknown Dish',
|
||||||
|
totalTime: rowData['Total Time (Approx.)'] || 'N/A',
|
||||||
|
calories: parseInt(rowData['Approx. Calories (per serving)']) || 0,
|
||||||
|
date: date,
|
||||||
|
// Placeholder for other meal details (ingredients, instructions, etc.)
|
||||||
|
ingredients: [],
|
||||||
|
instructions: [],
|
||||||
|
storage: '',
|
||||||
|
notes: ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return rows;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGroceryList = (markdown) => {
|
||||||
|
const lines = markdown.split('\n').filter(line => line.trim() !== '' && !line.startsWith('| :'));
|
||||||
|
const headers = lines[0].split('|').map(h => h.trim()).filter(h => h !== '');
|
||||||
|
const rows = lines.slice(1).map(line => {
|
||||||
|
const values = line.split('|').map(v => v.trim()).filter(v => v !== '');
|
||||||
|
const rowData = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
rowData[header] = values[index];
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: rowData['Ingredient Name'] || 'Unknown Ingredient',
|
||||||
|
amount: rowData['Total Quantity (Volume/Weight)'] || 'N/A',
|
||||||
|
perishable: rowData['Is Perishable'] ? rowData['Is Perishable'].toLowerCase() === 'yes' : false,
|
||||||
|
notes: rowData['Notes / Bulk Suggestion'] || ''
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group grocery items by interval if the AI provides this structure
|
||||||
|
// This is a simplified assumption based on the AI's potential output
|
||||||
|
// A more robust solution might require AI to explicitly structure grocery lists by interval
|
||||||
|
const groupedGroceryLists = [{
|
||||||
|
intervalNumber: 1, // Assuming a single list for the entire plan for now
|
||||||
|
startDate: startDate, // Use the plan start date
|
||||||
|
endDate: addDays(startDate, intervalDurationInDays * totalIntervals - 1), // Calculate end date
|
||||||
|
items: rows
|
||||||
|
}];
|
||||||
|
|
||||||
|
return groupedGroceryLists;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simplified parsing for recipes - just extracting the text for now
|
||||||
|
// A more advanced parser would break this down into individual recipes, ingredients, instructions, etc.
|
||||||
|
const parseDetailedRecipes = (markdown) => {
|
||||||
|
// For now, return the raw markdown text.
|
||||||
|
// The MealPlanDisplay component will need to handle rendering this markdown.
|
||||||
|
return markdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simplified parsing for calendar view - just extracting the text for now
|
||||||
|
const parseCalendarView = (markdown) => {
|
||||||
|
// For now, return the raw markdown text.
|
||||||
|
// The MealPlanDisplay component will need to handle rendering this markdown.
|
||||||
|
return markdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Populate meals and grocery lists by parsing the markdown content
|
||||||
|
const parsedMeals = parseSummaryTable(summaryTable);
|
||||||
|
const parsedGroceryLists = parseGroceryList(groceryList);
|
||||||
|
const parsedDetailedRecipes = parseDetailedRecipes(detailedRecipes);
|
||||||
|
const parsedCalendarView = parseCalendarView(calendarView);
|
||||||
|
|
||||||
|
|
||||||
|
// Merge parsed data into the final meal plan structure
|
||||||
|
// This part needs to align with how MealPlanDisplay expects the data
|
||||||
|
// For now, we'll use the parsedMeals and parsedGroceryLists directly
|
||||||
|
// and include the raw markdown for recipes and calendar for display
|
||||||
|
meals.push(...parsedMeals);
|
||||||
|
groceryLists.push(...parsedGroceryLists);
|
||||||
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Fallback or error handling if AI response parsing failed
|
||||||
|
console.error("AI response parsing failed or returned no data.");
|
||||||
|
// Optionally, add logic here to generate a basic plan or throw an error
|
||||||
|
// For now, we'll return an empty plan if parsing failed.
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
totalIntervals, // Changed from weeks
|
||||||
|
startDate,
|
||||||
|
meals,
|
||||||
|
groceryLists,
|
||||||
|
aiPrompt: prompt, // Store the prompt for reference
|
||||||
|
rawAIResponse: aiResponse, // This contains the raw response from the AI
|
||||||
|
parsedAIResponse: parsedResponse // This contains the parsed sections
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating meal plan:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generate calendar events for the meal plan
|
||||||
|
generateCalendarEvents: (mealPlan) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
// Add meal events
|
||||||
|
mealPlan.meals.forEach(meal => {
|
||||||
|
// Recipe event
|
||||||
|
events.push({
|
||||||
|
Subject: `${meal.name} (${meal.totalTime}|${meal.calories}cal)`,
|
||||||
|
Start_Date: meal.date.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
Start_Time: '17:00', // Default dinner time
|
||||||
|
End_Date: meal.date.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
End_Time: '18:00', // Assume 1 hour for meal
|
||||||
|
All_Day_Event: 'False',
|
||||||
|
Description: `Ingredients:\n${meal.ingredients.map(ing => `- ${ing.name}: ${ing.amount}`).join('\n')}\n\nInstructions:\n${meal.instructions.map((step, i) => `${i+1}. ${step}`).join('\n')}`,
|
||||||
|
Location: '',
|
||||||
|
Private: 'False'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add prep events if needed
|
||||||
|
if (meal.notes && meal.notes.toLowerCase().includes('thaw')) {
|
||||||
|
const prepDate = new Date(meal.date);
|
||||||
|
prepDate.setDate(prepDate.getDate() - 1); // Day before
|
||||||
|
|
||||||
|
events.push({
|
||||||
|
Subject: `PREP: Thaw for ${meal.name}`,
|
||||||
|
Start_Date: prepDate.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
Start_Time: '20:00',
|
||||||
|
End_Date: prepDate.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
End_Time: '20:15',
|
||||||
|
All_Day_Event: 'False',
|
||||||
|
Description: `Move ingredients from freezer to refrigerator for tomorrow's ${meal.name}`,
|
||||||
|
Location: '',
|
||||||
|
Private: 'False'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add grocery shopping events
|
||||||
|
mealPlan.groceryLists.forEach(list => {
|
||||||
|
events.push({
|
||||||
|
Subject: `Grocery Shopping (Week ${list.intervalNumber})`,
|
||||||
|
Start_Date: list.startDate.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
Start_Time: '10:00',
|
||||||
|
End_Date: list.startDate.toLocaleDateString('en-US', {year: 'numeric', month: 'numeric', day: 'numeric'}),
|
||||||
|
End_Time: '11:30',
|
||||||
|
All_Day_Event: 'False',
|
||||||
|
Description: `Grocery list:\n${list.items.map(item => `- ${item.name}: ${item.amount} ${item.perishable ? '(Perishable)' : ''}`).join('\n')}`,
|
||||||
|
Location: '',
|
||||||
|
Private: 'False'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(events);
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MealPlanService;
|
Loading…
Reference in New Issue
Block a user