feat: initial meal planner app with Gemini AI integration

This commit is contained in:
Bake-Ware 2025-05-31 12:14:42 -05:00
commit 9cb5f888d4
28 changed files with 20922 additions and 0 deletions

37
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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
View 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
View 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
View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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;

View 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;
}
}

View 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;

View 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;
}

View 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;

View 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;
}
}

View 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
View 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
View 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
View 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;

View 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;

View 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;