first commit
This commit is contained in:
8
calorie_tracker_app/.dockerignore
Normal file
8
calorie_tracker_app/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
instance/
|
||||
.env
|
||||
.git
|
||||
.gitignore
|
||||
*.db
|
||||
*.pyc
|
||||
7
calorie_tracker_app/.env.example
Normal file
7
calorie_tracker_app/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# API Ninjas API Key
|
||||
# Get your free API key from: https://api-ninjas.com/api
|
||||
API_NINJAS_KEY=your_api_key_here
|
||||
|
||||
# Flask Configuration
|
||||
SECRET_KEY=your_secret_key_here_change_in_production
|
||||
FLASK_ENV=development
|
||||
24
calorie_tracker_app/Dockerfile
Normal file
24
calorie_tracker_app/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
||||
FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV FLASK_APP=app.py
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5001
|
||||
|
||||
# Run commands to init db, seed data, and start server
|
||||
CMD ["sh", "-c", "flask init-db && flask seed-db && gunicorn -w 4 -b 0.0.0.0:5001 app:app"]
|
||||
384
calorie_tracker_app/README.md
Normal file
384
calorie_tracker_app/README.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Calorie Tracker - Filipino Food Edition 🍽️
|
||||
|
||||
A web application for tracking calories, macros, and water intake with special focus on Filipino foods. Perfect for weight loss and muscle gain goals!
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Macro Tracking**: Track calories, protein, carbs, and fat
|
||||
✅ **Filipino Food Database**: Pre-loaded with 25+ common Filipino foods
|
||||
✅ **Water Intake Tracking**: Quick-add water logging
|
||||
✅ **Weight Tracking**: Daily weight logs with trend analysis
|
||||
✅ **Meal Planning**: Plan meals ahead (coming soon!)
|
||||
✅ **Smart Suggestions**: Get food recommendations based on remaining macros
|
||||
✅ **API Integration**: Search international foods via API Ninjas
|
||||
✅ **Beautiful UI**: Red/blue color scheme inspired by Filipino flag
|
||||
✅ **Charts & Trends**: Visualize your progress with Chart.js
|
||||
|
||||
## Filipino Foods Included
|
||||
|
||||
- **Kanin (Rice)**: White rice, Sinangag
|
||||
- **Ulam (Main Dishes)**: Adobo, Sinigang, Sisig, Bicol Express, Kare-kare, Menudo, Lechon Kawali
|
||||
- **Sabaw (Soups)**: Tinola, Nilaga, Bulalo
|
||||
- **Gulay (Vegetables)**: Pinakbet, Laing, Ginisang Monggo
|
||||
- **Almusal (Breakfast)**: Tapsilog, Longsilog, Tocilog
|
||||
- **Meryenda (Snacks)**: Pandesal, Turon, Bibingka, Puto, Lumpia
|
||||
|
||||
## 🐳 Docker Support
|
||||
|
||||
Want to self-host this on CasaOS or your own server?
|
||||
👉 **[Read the Docker Guide](../README_DOCKER.md)**
|
||||
|
||||
## Tech Stack
|
||||
|
||||
|
||||
- **Backend**: Flask (Python)
|
||||
- **Database**: SQLite
|
||||
- **Frontend**: HTML, Tailwind CSS, Vanilla JavaScript
|
||||
- **Charts**: Chart.js
|
||||
- **API**: API Ninjas Nutrition API
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.10 or higher
|
||||
- pip (Python package manager)
|
||||
|
||||
### Step 1: Clone or Download
|
||||
|
||||
```bash
|
||||
cd calorie_tracker_app
|
||||
```
|
||||
|
||||
### Step 2: Create Virtual Environment
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
|
||||
# On Windows:
|
||||
venv\Scripts\activate
|
||||
|
||||
# On Mac/Linux:
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 3: Install Dependencies
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Step 4: Get API Key (Optional but Recommended)
|
||||
|
||||
1. Go to [API Ninjas](https://api-ninjas.com/api/nutrition)
|
||||
2. Sign up for a free account
|
||||
3. Get your API key (50 requests/day free tier)
|
||||
4. Create `.env` file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and add your API key:
|
||||
```
|
||||
API_NINJAS_KEY=your_api_key_here
|
||||
SECRET_KEY=your_secret_key_here
|
||||
```
|
||||
|
||||
### Step 5: Initialize Database
|
||||
|
||||
Run the app for the first time:
|
||||
|
||||
```bash
|
||||
# Windows (Double click run_app.bat OR run):
|
||||
venv\Scripts\python app.py
|
||||
|
||||
# Mac/Linux:
|
||||
./venv/bin/python app.py
|
||||
```
|
||||
|
||||
This will create the database. Then in another terminal, seed Filipino foods:
|
||||
|
||||
```bash
|
||||
# Windows (Double click seed_db.bat OR run):
|
||||
venv\Scripts\python seed_data.py
|
||||
|
||||
# Mac/Linux:
|
||||
./venv/bin/python seed_data.py
|
||||
```
|
||||
|
||||
### Step 6: Run the Application
|
||||
|
||||
```bash
|
||||
# Windows (Double click run_app.bat OR run):
|
||||
venv\Scripts\python app.py
|
||||
|
||||
# Mac/Linux:
|
||||
./venv/bin/python app.py
|
||||
```
|
||||
|
||||
Open your browser and go to: `http://localhost:5001`
|
||||
|
||||
## Usage
|
||||
|
||||
### First Time Setup
|
||||
|
||||
1. **Register**: Create an account at `/register`
|
||||
2. **Login**: Sign in with your credentials
|
||||
3. **Set Goals**: Go to Goals page and enter:
|
||||
- Age, gender, height, weight
|
||||
- Activity level
|
||||
- Goal type (weight loss, muscle gain, or recomp)
|
||||
- Target weight
|
||||
|
||||
The app will automatically calculate your:
|
||||
- BMR (Basal Metabolic Rate)
|
||||
- TDEE (Total Daily Energy Expenditure)
|
||||
- Calorie target
|
||||
- Macro targets
|
||||
|
||||
### Daily Tracking
|
||||
|
||||
#### Add a Meal
|
||||
1. Click "Add Meal" button
|
||||
2. Select date, meal type, and time
|
||||
3. Search for foods (Filipino or international)
|
||||
4. Add foods and adjust servings
|
||||
5. See real-time nutrition summary
|
||||
6. Save meal
|
||||
|
||||
#### Log Water
|
||||
- Quick buttons on dashboard: +250ml, +500ml
|
||||
- Or use custom amount
|
||||
|
||||
#### Log Weight
|
||||
- Enter weight on dashboard
|
||||
- Track daily to see trends
|
||||
|
||||
### Food Search
|
||||
|
||||
The app searches in this order:
|
||||
1. **Filipino foods** (local database) - fastest
|
||||
2. **Cached foods** (previously searched)
|
||||
3. **API Ninjas** (if API key configured)
|
||||
4. **Manual entry** (if food not found)
|
||||
|
||||
Search supports both English and Tagalog:
|
||||
- "Adobo" or "Chicken Adobo"
|
||||
- "Kanin" or "Rice"
|
||||
- "Sinigang" works
|
||||
|
||||
### Understanding the Dashboard
|
||||
|
||||
#### Top Cards
|
||||
- **Calories**: Shows consumed vs target with progress bar
|
||||
- **Protein**: Your protein intake vs target
|
||||
- **Carbs & Fat**: Dual progress bars
|
||||
- **Water & Weight**: Quick water logging + today's weight
|
||||
|
||||
#### Macro Distribution
|
||||
- Pie chart showing protein/carbs/fat percentages
|
||||
- Ideal for recomp: 30-35% protein, 40-45% carbs, 20-25% fat
|
||||
|
||||
#### Smart Suggestions
|
||||
Based on remaining macros, suggests:
|
||||
- High protein foods if protein is low
|
||||
- Carb sources if carbs are low
|
||||
- Balanced meals if everything is balanced
|
||||
|
||||
#### Today's Meals
|
||||
- Shows all logged meals
|
||||
- Color-coded by meal type
|
||||
- Displays nutrition breakdown
|
||||
|
||||
#### Weekly Trends
|
||||
- 7-day calorie trend vs target
|
||||
- 7-day weight trend
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
- `users` - User accounts
|
||||
- `food_items` - All foods (Filipino + API)
|
||||
- `meals` - Meal entries
|
||||
- `meal_foods` - Foods in each meal
|
||||
- `water_logs` - Water intake
|
||||
- `weight_logs` - Weight tracking
|
||||
- `meal_plans` - Future meal planning
|
||||
- `user_goals` - Macro targets
|
||||
- `daily_summary` - Daily totals
|
||||
- `api_cache` - API response caching
|
||||
|
||||
## API Rate Limits
|
||||
|
||||
**Free Tier (API Ninjas)**:
|
||||
- 50 requests per day
|
||||
- App caches all results
|
||||
- Once cached, no API calls needed
|
||||
|
||||
**Tips to conserve API calls**:
|
||||
- Use Filipino foods when possible (no API calls)
|
||||
- Search once, use favorites
|
||||
- API searches are cached for 30 days
|
||||
|
||||
## Customization
|
||||
|
||||
### Adding Custom Foods
|
||||
|
||||
1. Go to Foods page
|
||||
2. Click "Add Custom Food"
|
||||
3. Enter nutrition information
|
||||
4. Save
|
||||
|
||||
### Adjusting Targets
|
||||
|
||||
Go to Goals page to adjust:
|
||||
- Daily calorie target
|
||||
- Macro targets (protein/carbs/fat)
|
||||
- Water intake goal
|
||||
- Weight goal
|
||||
|
||||
### Meal Templates (Coming Soon!)
|
||||
|
||||
Save common meal combinations:
|
||||
- "My typical breakfast"
|
||||
- "Post-workout meal"
|
||||
- "Quick lunch"
|
||||
|
||||
## Tips for Body Recomposition
|
||||
|
||||
### Protein Priority
|
||||
- 2.0-2.4g per kg body weight
|
||||
- Example: 70kg person = 140-168g daily
|
||||
- Spread across all meals
|
||||
|
||||
### Carb Timing
|
||||
- Higher carbs on training days
|
||||
- Lower carbs on rest days
|
||||
|
||||
### Calorie Cycling
|
||||
- Training days: Maintenance (+100 cal)
|
||||
- Rest days: Deficit (-300 to -500 cal)
|
||||
|
||||
### Track Weight Daily
|
||||
- Weigh same time each day (morning, after bathroom)
|
||||
- Look at weekly average, not daily fluctuations
|
||||
- Aim for 0.5-1% body weight loss per week
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Database Locked Error
|
||||
```bash
|
||||
# Stop the app, then:
|
||||
rm calorie_tracker.db
|
||||
python app.py
|
||||
python seed_data.py
|
||||
```
|
||||
|
||||
### API Not Working
|
||||
- Check if API key is in `.env` file
|
||||
- Verify API key is valid
|
||||
- App works without API (Filipino foods + manual entry)
|
||||
|
||||
### Foods Not Showing
|
||||
```bash
|
||||
python seed_data.py
|
||||
```
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Change port in app.py:
|
||||
app.run(debug=True, host='0.0.0.0', port=5001)
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Meal planner with calendar view
|
||||
- [ ] Barcode scanning (Open Food Facts API)
|
||||
- [ ] Recipe builder
|
||||
- [ ] Meal templates
|
||||
- [ ] Export to CSV/PDF
|
||||
- [ ] Photo food logging
|
||||
- [ ] Workout integration
|
||||
- [ ] Body measurements tracking
|
||||
- [ ] Progress photos
|
||||
- [ ] Mobile app version
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
calorie_tracker_app/
|
||||
├── app.py # Main Flask application
|
||||
├── models.py # Database models
|
||||
├── config.py # Configuration
|
||||
├── api_client.py # API integration
|
||||
├── utils.py # Helper functions
|
||||
├── seed_data.py # Filipino foods data
|
||||
├── requirements.txt # Python dependencies
|
||||
├── .env.example # Environment variables template
|
||||
├── templates/ # HTML templates
|
||||
│ ├── base.html
|
||||
│ ├── dashboard.html
|
||||
│ ├── add_meal.html
|
||||
│ ├── login.html
|
||||
│ ├── register.html
|
||||
│ ├── foods.html
|
||||
│ ├── meal_planner.html
|
||||
│ ├── progress.html
|
||||
│ └── goals.html
|
||||
└── static/ # CSS, JS, images
|
||||
├── css/
|
||||
├── js/
|
||||
└── images/
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a personal project, but suggestions are welcome!
|
||||
|
||||
## License
|
||||
|
||||
MIT License - Feel free to use and modify for your own needs.
|
||||
|
||||
## Credits
|
||||
|
||||
- **API Ninjas** for nutrition data
|
||||
- **Tailwind CSS** for styling
|
||||
- **Chart.js** for visualizations
|
||||
- **Filipino food data** compiled from various nutrition sources
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check this README
|
||||
2. Check the troubleshooting section
|
||||
3. Review the code comments
|
||||
|
||||
---
|
||||
|
||||
**Made with ❤️ for Filipino food lovers and fitness enthusiasts!**
|
||||
|
||||
## Quick Start Summary
|
||||
|
||||
```bash
|
||||
# 1. Setup
|
||||
python -m venv venv
|
||||
source venv/bin/activate # or venv\Scripts\activate on Windows
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 2. Configure (optional)
|
||||
cp .env.example .env
|
||||
# Add your API key to .env
|
||||
|
||||
# 3. Initialize
|
||||
python app.py # Creates database
|
||||
python seed_data.py # Adds Filipino foods
|
||||
|
||||
# 4. Run
|
||||
python app.py
|
||||
|
||||
# 5. Open browser
|
||||
# Go to http://localhost:5000
|
||||
```
|
||||
|
||||
Enjoy tracking your nutrition! 🎉
|
||||
BIN
calorie_tracker_app/__pycache__/api_client.cpython-314.pyc
Normal file
BIN
calorie_tracker_app/__pycache__/api_client.cpython-314.pyc
Normal file
Binary file not shown.
BIN
calorie_tracker_app/__pycache__/app.cpython-314.pyc
Normal file
BIN
calorie_tracker_app/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
BIN
calorie_tracker_app/__pycache__/config.cpython-314.pyc
Normal file
BIN
calorie_tracker_app/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
calorie_tracker_app/__pycache__/models.cpython-314.pyc
Normal file
BIN
calorie_tracker_app/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
calorie_tracker_app/__pycache__/utils.cpython-314.pyc
Normal file
BIN
calorie_tracker_app/__pycache__/utils.cpython-314.pyc
Normal file
Binary file not shown.
209
calorie_tracker_app/api_client.py
Normal file
209
calorie_tracker_app/api_client.py
Normal file
@@ -0,0 +1,209 @@
|
||||
import requests
|
||||
import json
|
||||
from models import db, APICache, FoodItem
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class NutritionAPI:
|
||||
"""API client for nutrition data with caching"""
|
||||
|
||||
def __init__(self, api_key):
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://api.api-ninjas.com/v1/nutrition"
|
||||
self.headers = {'X-Api-Key': api_key}
|
||||
self.cache_duration_days = 30
|
||||
|
||||
def search_food(self, query):
|
||||
"""
|
||||
Search for food nutrition data
|
||||
Returns list of food items with nutrition info
|
||||
"""
|
||||
# Check cache first
|
||||
cached = self._get_from_cache(query)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Make API request
|
||||
try:
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
headers=self.headers,
|
||||
params={'query': query},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# Cache the response
|
||||
self._save_to_cache(query, 'api_ninjas', data)
|
||||
|
||||
# Parse and return standardized format
|
||||
return self._parse_api_response(data)
|
||||
else:
|
||||
print(f"API Error: {response.status_code}")
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"API Request failed: {e}")
|
||||
return []
|
||||
|
||||
def _get_from_cache(self, query):
|
||||
"""Check if query exists in cache and is not expired"""
|
||||
cache_entry = APICache.query.filter_by(query=query.lower()).first()
|
||||
|
||||
if cache_entry:
|
||||
# Check if cache is still valid (30 days)
|
||||
age = datetime.utcnow() - cache_entry.cached_at
|
||||
if age.days < self.cache_duration_days:
|
||||
data = json.loads(cache_entry.response_json)
|
||||
return self._parse_api_response(data)
|
||||
|
||||
return None
|
||||
|
||||
def _save_to_cache(self, query, source, data):
|
||||
"""Save API response to cache"""
|
||||
try:
|
||||
cache_entry = APICache.query.filter_by(query=query.lower()).first()
|
||||
|
||||
if cache_entry:
|
||||
# Update existing cache
|
||||
cache_entry.response_json = json.dumps(data)
|
||||
cache_entry.cached_at = datetime.utcnow()
|
||||
else:
|
||||
# Create new cache entry
|
||||
cache_entry = APICache(
|
||||
query=query.lower(),
|
||||
api_source=source,
|
||||
response_json=json.dumps(data),
|
||||
cached_at=datetime.utcnow()
|
||||
)
|
||||
db.session.add(cache_entry)
|
||||
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
print(f"Cache save failed: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
def _parse_api_response(self, data):
|
||||
"""Parse API response into standardized format"""
|
||||
foods = []
|
||||
|
||||
for item in data:
|
||||
food = {
|
||||
'name': item.get('name', '').title(),
|
||||
'calories': item.get('calories', 0),
|
||||
'protein_g': item.get('protein_g', 0),
|
||||
'carbs_g': item.get('carbohydrates_total_g', 0),
|
||||
'fat_g': item.get('fat_total_g', 0),
|
||||
'fiber_g': item.get('fiber_g', 0),
|
||||
'sugar_g': item.get('sugar_g', 0),
|
||||
'sodium_mg': item.get('sodium_mg', 0),
|
||||
'serving_size_g': item.get('serving_size_g', 100),
|
||||
'source': 'api_ninjas'
|
||||
}
|
||||
foods.append(food)
|
||||
|
||||
return foods
|
||||
|
||||
def save_food_to_db(self, food_data):
|
||||
"""Save a food item to the database"""
|
||||
try:
|
||||
# Check if food already exists
|
||||
existing = FoodItem.query.filter_by(
|
||||
name=food_data['name'],
|
||||
source=food_data.get('source', 'api')
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new food item
|
||||
food = FoodItem(
|
||||
name=food_data['name'],
|
||||
calories=food_data['calories'],
|
||||
protein_g=food_data.get('protein_g', 0),
|
||||
carbs_g=food_data.get('carbs_g', 0),
|
||||
fat_g=food_data.get('fat_g', 0),
|
||||
fiber_g=food_data.get('fiber_g', 0),
|
||||
sugar_g=food_data.get('sugar_g', 0),
|
||||
sodium_mg=food_data.get('sodium_mg', 0),
|
||||
serving_size_g=food_data.get('serving_size_g', 100),
|
||||
serving_description=food_data.get('serving_description', '1 serving'),
|
||||
source=food_data.get('source', 'api'),
|
||||
api_data=json.dumps(food_data)
|
||||
)
|
||||
|
||||
db.session.add(food)
|
||||
db.session.commit()
|
||||
|
||||
return food
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error saving food to DB: {e}")
|
||||
db.session.rollback()
|
||||
return None
|
||||
|
||||
def search_all_sources(query, api_client):
|
||||
"""
|
||||
Search for food in all available sources
|
||||
Priority: Local DB (Filipino) -> Local DB (cached) -> API
|
||||
"""
|
||||
results = []
|
||||
|
||||
# 1. Search Filipino foods first
|
||||
filipino_foods = FoodItem.query.filter(
|
||||
FoodItem.is_filipino == True,
|
||||
db.or_(
|
||||
FoodItem.name.ilike(f'%{query}%'),
|
||||
FoodItem.name_tagalog.ilike(f'%{query}%')
|
||||
)
|
||||
).limit(5).all()
|
||||
|
||||
for food in filipino_foods:
|
||||
results.append({
|
||||
'id': food.id,
|
||||
'name': food.name,
|
||||
'name_tagalog': food.name_tagalog,
|
||||
'calories': food.calories,
|
||||
'protein_g': food.protein_g,
|
||||
'carbs_g': food.carbs_g,
|
||||
'fat_g': food.fat_g,
|
||||
'serving_description': food.serving_description,
|
||||
'source': 'filipino',
|
||||
'category': food.category
|
||||
})
|
||||
|
||||
# 2. Search other local foods
|
||||
other_foods = FoodItem.query.filter(
|
||||
FoodItem.is_filipino == False,
|
||||
FoodItem.name.ilike(f'%{query}%')
|
||||
).limit(5).all()
|
||||
|
||||
for food in other_foods:
|
||||
results.append({
|
||||
'id': food.id,
|
||||
'name': food.name,
|
||||
'calories': food.calories,
|
||||
'protein_g': food.protein_g,
|
||||
'carbs_g': food.carbs_g,
|
||||
'fat_g': food.fat_g,
|
||||
'serving_description': food.serving_description,
|
||||
'source': food.source
|
||||
})
|
||||
|
||||
# 3. If not enough results, search API
|
||||
if len(results) < 3 and api_client.api_key:
|
||||
api_results = api_client.search_food(query)
|
||||
for food_data in api_results[:5]:
|
||||
results.append({
|
||||
'name': food_data['name'],
|
||||
'calories': food_data['calories'],
|
||||
'protein_g': food_data['protein_g'],
|
||||
'carbs_g': food_data['carbs_g'],
|
||||
'fat_g': food_data['fat_g'],
|
||||
'serving_size_g': food_data['serving_size_g'],
|
||||
'source': 'api',
|
||||
'api_data': food_data
|
||||
})
|
||||
|
||||
return results
|
||||
494
calorie_tracker_app/app.py
Normal file
494
calorie_tracker_app/app.py
Normal file
@@ -0,0 +1,494 @@
|
||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session
|
||||
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
from datetime import date, datetime, timedelta
|
||||
import os
|
||||
|
||||
from config import Config
|
||||
from models import db, User, FoodItem, Meal, MealFood, WaterLog, WeightLog, MealPlan, PlannedFood, UserGoal
|
||||
from api_client import NutritionAPI, search_all_sources
|
||||
from utils import (
|
||||
calculate_daily_totals, calculate_water_total, get_weight_trend,
|
||||
get_calorie_trend, update_daily_summary, calculate_bmr, calculate_tdee,
|
||||
calculate_macro_targets, get_macro_percentages, suggest_foods_for_macros
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# Initialize extensions
|
||||
db.init_app(app)
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'login'
|
||||
|
||||
# Initialize API client
|
||||
api_client = NutritionAPI(app.config['API_NINJAS_KEY'])
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# ==================== ROUTES ====================
|
||||
|
||||
@app.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
"""Dashboard - Today's summary"""
|
||||
today = date.today()
|
||||
|
||||
# Get daily totals
|
||||
nutrition = calculate_daily_totals(current_user.id, today)
|
||||
water = calculate_water_total(current_user.id, today)
|
||||
|
||||
# Get user goals
|
||||
goals = UserGoal.query.filter_by(user_id=current_user.id).first()
|
||||
if not goals:
|
||||
goals = UserGoal(
|
||||
user_id=current_user.id,
|
||||
target_protein_g=150,
|
||||
target_carbs_g=200,
|
||||
target_fat_g=60,
|
||||
target_water_ml=2000
|
||||
)
|
||||
db.session.add(goals)
|
||||
db.session.commit()
|
||||
|
||||
# Get weight info
|
||||
weight_log_today = WeightLog.query.filter_by(user_id=current_user.id, date=today).first()
|
||||
weight_log_yesterday = WeightLog.query.filter_by(
|
||||
user_id=current_user.id,
|
||||
date=today - timedelta(days=1)
|
||||
).first()
|
||||
|
||||
weight_change = None
|
||||
if weight_log_today and weight_log_yesterday:
|
||||
weight_change = weight_log_today.weight_kg - weight_log_yesterday.weight_kg
|
||||
|
||||
# Calculate remaining
|
||||
remaining = {
|
||||
'calories': current_user.target_daily_calories - nutrition['calories'],
|
||||
'protein': goals.target_protein_g - nutrition['protein'],
|
||||
'carbs': goals.target_carbs_g - nutrition['carbs'],
|
||||
'fat': goals.target_fat_g - nutrition['fat'],
|
||||
'water': goals.target_water_ml - water['total_ml']
|
||||
}
|
||||
|
||||
# Get macro percentages
|
||||
macro_percentages = get_macro_percentages(
|
||||
nutrition['protein'],
|
||||
nutrition['carbs'],
|
||||
nutrition['fat']
|
||||
)
|
||||
|
||||
# Get trends
|
||||
weight_trend = get_weight_trend(current_user.id, 7)
|
||||
calorie_trend = get_calorie_trend(current_user.id, 7)
|
||||
|
||||
# Suggestions
|
||||
suggestions = suggest_foods_for_macros(
|
||||
remaining['protein'],
|
||||
remaining['carbs'],
|
||||
remaining['fat']
|
||||
)
|
||||
|
||||
return render_template('dashboard.html',
|
||||
nutrition=nutrition,
|
||||
water=water,
|
||||
goals=goals,
|
||||
remaining=remaining,
|
||||
macro_percentages=macro_percentages,
|
||||
weight_today=weight_log_today,
|
||||
weight_change=weight_change,
|
||||
weight_trend=weight_trend,
|
||||
calorie_trend=calorie_trend,
|
||||
suggestions=suggestions,
|
||||
today=today)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
"""User login"""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and check_password_hash(user.password, password):
|
||||
login_user(user)
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('Invalid username or password', 'error')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
"""User registration"""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
name = request.form.get('name')
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists', 'error')
|
||||
return redirect(url_for('register'))
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
password=generate_password_hash(password),
|
||||
name=name
|
||||
)
|
||||
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
flash('Registration successful! Please login.', 'success')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@app.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
"""User logout"""
|
||||
logout_user()
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/add-meal', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def add_meal():
|
||||
"""Add a meal"""
|
||||
if request.method == 'POST':
|
||||
meal_date = request.form.get('date', date.today())
|
||||
meal_type = request.form.get('meal_type')
|
||||
meal_time = request.form.get('time')
|
||||
|
||||
# Convert date string to date object
|
||||
if isinstance(meal_date, str):
|
||||
meal_date = datetime.strptime(meal_date, '%Y-%m-%d').date()
|
||||
|
||||
# Convert time string to time object
|
||||
meal_time_obj = None
|
||||
if meal_time:
|
||||
meal_time_obj = datetime.strptime(meal_time, '%H:%M').time()
|
||||
|
||||
# Create meal
|
||||
meal = Meal(
|
||||
user_id=current_user.id,
|
||||
date=meal_date,
|
||||
meal_type=meal_type,
|
||||
time=meal_time_obj
|
||||
)
|
||||
db.session.add(meal)
|
||||
db.session.flush() # Get meal ID
|
||||
|
||||
# Add foods to meal
|
||||
food_ids = request.form.getlist('food_id[]')
|
||||
quantities = request.form.getlist('quantity[]')
|
||||
|
||||
for food_id, quantity in zip(food_ids, quantities):
|
||||
if food_id and quantity:
|
||||
meal_food = MealFood(
|
||||
meal_id=meal.id,
|
||||
food_id=int(food_id),
|
||||
quantity=float(quantity)
|
||||
)
|
||||
meal_food.calculate_nutrition()
|
||||
db.session.add(meal_food)
|
||||
|
||||
db.session.commit()
|
||||
update_daily_summary(current_user.id, meal_date)
|
||||
|
||||
flash('Meal added successfully!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# GET request - show form
|
||||
return render_template('add_meal.html', today=date.today())
|
||||
|
||||
@app.route('/api/search-food')
|
||||
@login_required
|
||||
def api_search_food():
|
||||
"""API endpoint for food search (AJAX)"""
|
||||
query = request.args.get('q', '')
|
||||
|
||||
if len(query) < 2:
|
||||
return jsonify([])
|
||||
|
||||
results = search_all_sources(query, api_client)
|
||||
return jsonify(results)
|
||||
|
||||
@app.route('/api/add-food', methods=['POST'])
|
||||
@login_required
|
||||
def api_add_food():
|
||||
"""API endpoint to add a food from API to database"""
|
||||
data = request.json
|
||||
|
||||
# Save food to database
|
||||
food = FoodItem(
|
||||
name=data['name'],
|
||||
calories=data['calories'],
|
||||
protein_g=data.get('protein_g', 0),
|
||||
carbs_g=data.get('carbs_g', 0),
|
||||
fat_g=data.get('fat_g', 0),
|
||||
serving_size_g=data.get('serving_size_g', 100),
|
||||
serving_description=data.get('serving_description', '1 serving'),
|
||||
source='api'
|
||||
)
|
||||
|
||||
db.session.add(food)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'food_id': food.id,
|
||||
'name': food.name
|
||||
})
|
||||
|
||||
@app.route('/add-water', methods=['POST'])
|
||||
@login_required
|
||||
def add_water():
|
||||
"""Add water intake"""
|
||||
amount_ml = int(request.form.get('amount_ml', 250))
|
||||
log_date = request.form.get('date', date.today())
|
||||
|
||||
if isinstance(log_date, str):
|
||||
log_date = datetime.strptime(log_date, '%Y-%m-%d').date()
|
||||
|
||||
water_log = WaterLog(
|
||||
user_id=current_user.id,
|
||||
date=log_date,
|
||||
amount_ml=amount_ml,
|
||||
time=datetime.now().time()
|
||||
)
|
||||
|
||||
db.session.add(water_log)
|
||||
db.session.commit()
|
||||
|
||||
update_daily_summary(current_user.id, log_date)
|
||||
|
||||
flash(f'Added {amount_ml}ml of water!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/add-weight', methods=['POST'])
|
||||
@login_required
|
||||
def add_weight():
|
||||
"""Add weight log"""
|
||||
weight_kg = float(request.form.get('weight_kg'))
|
||||
log_date = request.form.get('date', date.today())
|
||||
|
||||
if isinstance(log_date, str):
|
||||
log_date = datetime.strptime(log_date, '%Y-%m-%d').date()
|
||||
|
||||
# Check if weight log already exists for today
|
||||
existing = WeightLog.query.filter_by(user_id=current_user.id, date=log_date).first()
|
||||
|
||||
if existing:
|
||||
existing.weight_kg = weight_kg
|
||||
existing.time = datetime.now().time()
|
||||
else:
|
||||
weight_log = WeightLog(
|
||||
user_id=current_user.id,
|
||||
date=log_date,
|
||||
weight_kg=weight_kg,
|
||||
time=datetime.now().time()
|
||||
)
|
||||
db.session.add(weight_log)
|
||||
|
||||
db.session.commit()
|
||||
update_daily_summary(current_user.id, log_date)
|
||||
|
||||
flash(f'Weight logged: {weight_kg}kg', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/meal-planner')
|
||||
@login_required
|
||||
def meal_planner():
|
||||
"""Meal planner page"""
|
||||
# Get date range (current week)
|
||||
today = date.today()
|
||||
start_date = today - timedelta(days=today.weekday()) # Monday
|
||||
dates = [start_date + timedelta(days=i) for i in range(7)]
|
||||
|
||||
# Get meal plans for the week
|
||||
meal_plans = {}
|
||||
for d in dates:
|
||||
plans = MealPlan.query.filter_by(user_id=current_user.id, date=d).all()
|
||||
meal_plans[d.isoformat()] = [
|
||||
{
|
||||
'id': p.id,
|
||||
'meal_type': p.meal_type,
|
||||
'is_completed': p.is_completed,
|
||||
'foods': [
|
||||
{
|
||||
'name': pf.food.name,
|
||||
'quantity': pf.quantity
|
||||
}
|
||||
for pf in p.foods
|
||||
],
|
||||
'totals': p.calculate_totals()
|
||||
}
|
||||
for p in plans
|
||||
]
|
||||
|
||||
return render_template('meal_planner.html', dates=dates, meal_plans=meal_plans, today=today)
|
||||
|
||||
@app.route('/foods')
|
||||
@login_required
|
||||
def foods():
|
||||
"""Food database page"""
|
||||
category = request.args.get('category', 'all')
|
||||
search_query = request.args.get('q', '')
|
||||
|
||||
query = FoodItem.query
|
||||
|
||||
if category != 'all':
|
||||
query = query.filter_by(category=category)
|
||||
|
||||
if search_query:
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
FoodItem.name.ilike(f'%{search_query}%'),
|
||||
FoodItem.name_tagalog.ilike(f'%{search_query}%')
|
||||
)
|
||||
)
|
||||
|
||||
# Filipino foods first
|
||||
filipino_foods = query.filter_by(is_filipino=True).all()
|
||||
other_foods = query.filter_by(is_filipino=False).limit(20).all()
|
||||
|
||||
categories = ['all', 'kanin', 'ulam', 'sabaw', 'gulay', 'meryenda', 'almusal']
|
||||
|
||||
return render_template('foods.html',
|
||||
filipino_foods=filipino_foods,
|
||||
other_foods=other_foods,
|
||||
categories=categories,
|
||||
current_category=category,
|
||||
search_query=search_query)
|
||||
|
||||
@app.route('/progress')
|
||||
@login_required
|
||||
def progress():
|
||||
"""Progress tracking page"""
|
||||
days = int(request.args.get('days', 30))
|
||||
|
||||
weight_trend = get_weight_trend(current_user.id, days)
|
||||
calorie_trend = get_calorie_trend(current_user.id, days)
|
||||
|
||||
# Calculate averages
|
||||
if calorie_trend:
|
||||
avg_calories = sum(d['calories'] for d in calorie_trend) / len(calorie_trend)
|
||||
avg_protein = sum(d['protein'] for d in calorie_trend) / len(calorie_trend)
|
||||
avg_carbs = sum(d['carbs'] for d in calorie_trend) / len(calorie_trend)
|
||||
avg_fat = sum(d['fat'] for d in calorie_trend) / len(calorie_trend)
|
||||
else:
|
||||
avg_calories = avg_protein = avg_carbs = avg_fat = 0
|
||||
|
||||
# Weight change
|
||||
weight_change = None
|
||||
if len(weight_trend) >= 2:
|
||||
weight_change = weight_trend[-1]['weight_kg'] - weight_trend[0]['weight_kg']
|
||||
|
||||
return render_template('progress.html',
|
||||
weight_trend=weight_trend,
|
||||
calorie_trend=calorie_trend,
|
||||
avg_calories=avg_calories,
|
||||
avg_protein=avg_protein,
|
||||
avg_carbs=avg_carbs,
|
||||
avg_fat=avg_fat,
|
||||
weight_change=weight_change,
|
||||
days=days)
|
||||
|
||||
@app.route('/goals', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def goals():
|
||||
"""Goals and settings page"""
|
||||
if request.method == 'POST':
|
||||
# Update user info
|
||||
current_user.age = int(request.form.get('age', 25))
|
||||
current_user.gender = request.form.get('gender', 'male')
|
||||
current_user.height_cm = float(request.form.get('height_cm', 170))
|
||||
current_user.weight_kg = float(request.form.get('weight_kg', 70))
|
||||
current_user.activity_level = request.form.get('activity_level', 'moderate')
|
||||
|
||||
# Calculate targets
|
||||
bmr = calculate_bmr(
|
||||
current_user.weight_kg,
|
||||
current_user.height_cm,
|
||||
current_user.age,
|
||||
current_user.gender
|
||||
)
|
||||
tdee = calculate_tdee(bmr, current_user.activity_level)
|
||||
|
||||
# Get goal type
|
||||
goal_type = request.form.get('goal_type', 'recomp')
|
||||
|
||||
# Adjust calories based on goal
|
||||
if goal_type == 'weight_loss':
|
||||
target_calories = tdee - 500
|
||||
elif goal_type == 'muscle_gain':
|
||||
target_calories = tdee + 300
|
||||
else: # recomp
|
||||
target_calories = tdee
|
||||
|
||||
current_user.target_daily_calories = int(target_calories)
|
||||
|
||||
# Update or create goals
|
||||
user_goals = UserGoal.query.filter_by(user_id=current_user.id).first()
|
||||
if not user_goals:
|
||||
user_goals = UserGoal(user_id=current_user.id)
|
||||
db.session.add(user_goals)
|
||||
|
||||
user_goals.goal_type = goal_type
|
||||
user_goals.target_weight_kg = float(request.form.get('target_weight_kg', 70))
|
||||
|
||||
# Calculate macros
|
||||
macros = calculate_macro_targets(current_user.weight_kg, goal_type)
|
||||
user_goals.target_protein_g = macros['protein_g']
|
||||
user_goals.target_carbs_g = macros['carbs_g']
|
||||
user_goals.target_fat_g = macros['fat_g']
|
||||
user_goals.target_water_ml = int(request.form.get('target_water_ml', 2000))
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash('Goals updated successfully!', 'success')
|
||||
return redirect(url_for('goals'))
|
||||
|
||||
# GET request
|
||||
user_goals = UserGoal.query.filter_by(user_id=current_user.id).first()
|
||||
|
||||
# Calculate current BMR and TDEE
|
||||
bmr = tdee = None
|
||||
if current_user.weight_kg and current_user.height_cm and current_user.age:
|
||||
bmr = calculate_bmr(
|
||||
current_user.weight_kg,
|
||||
current_user.height_cm,
|
||||
current_user.age,
|
||||
current_user.gender or 'male'
|
||||
)
|
||||
tdee = calculate_tdee(bmr, current_user.activity_level or 'moderate')
|
||||
|
||||
return render_template('goals.html',
|
||||
user=current_user,
|
||||
goals=user_goals,
|
||||
bmr=bmr,
|
||||
tdee=tdee)
|
||||
|
||||
# ==================== DATABASE INITIALIZATION ====================
|
||||
|
||||
@app.cli.command()
|
||||
def init_db():
|
||||
"""Initialize the database"""
|
||||
db.create_all()
|
||||
print("Database initialized!")
|
||||
|
||||
@app.cli.command()
|
||||
def seed_db():
|
||||
"""Seed the database with Filipino foods"""
|
||||
from seed_data import seed_filipino_foods
|
||||
seed_filipino_foods()
|
||||
|
||||
if __name__ == '__main__':
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
app.run(debug=True, host='127.0.0.1', port=5001)
|
||||
16
calorie_tracker_app/config.py
Normal file
16
calorie_tracker_app/config.py
Normal file
@@ -0,0 +1,16 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||
# Use environment variable for DB URI if available, otherwise default to local file
|
||||
# For Docker, we'll map a volume to /app/data and use sqlite:////app/data/calorie_tracker.db
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///calorie_tracker.db')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
API_NINJAS_KEY = os.getenv('API_NINJAS_KEY', '')
|
||||
|
||||
# User defaults
|
||||
DEFAULT_WATER_GOAL_ML = 2000
|
||||
DEFAULT_CALORIE_TARGET = 2000
|
||||
186
calorie_tracker_app/models.py
Normal file
186
calorie_tracker_app/models.py
Normal file
@@ -0,0 +1,186 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime, date
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
password = db.Column(db.String(200), nullable=False)
|
||||
name = db.Column(db.String(100))
|
||||
age = db.Column(db.Integer)
|
||||
gender = db.Column(db.String(10))
|
||||
height_cm = db.Column(db.Float)
|
||||
weight_kg = db.Column(db.Float)
|
||||
activity_level = db.Column(db.String(20), default='moderate')
|
||||
target_daily_calories = db.Column(db.Integer, default=2000)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
meals = db.relationship('Meal', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
weight_logs = db.relationship('WeightLog', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
water_logs = db.relationship('WaterLog', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
meal_plans = db.relationship('MealPlan', backref='user', lazy=True, cascade='all, delete-orphan')
|
||||
goals = db.relationship('UserGoal', backref='user', uselist=False, cascade='all, delete-orphan')
|
||||
|
||||
class FoodItem(db.Model):
|
||||
__tablename__ = 'food_items'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False)
|
||||
name_tagalog = db.Column(db.String(200))
|
||||
category = db.Column(db.String(50))
|
||||
calories = db.Column(db.Float, nullable=False)
|
||||
protein_g = db.Column(db.Float, default=0)
|
||||
carbs_g = db.Column(db.Float, default=0)
|
||||
fat_g = db.Column(db.Float, default=0)
|
||||
fiber_g = db.Column(db.Float, default=0)
|
||||
sugar_g = db.Column(db.Float, default=0)
|
||||
sodium_mg = db.Column(db.Float, default=0)
|
||||
serving_size_g = db.Column(db.Float, default=100)
|
||||
serving_description = db.Column(db.String(100))
|
||||
source = db.Column(db.String(20), default='manual') # 'manual', 'api', 'filipino'
|
||||
is_filipino = db.Column(db.Boolean, default=False)
|
||||
is_favorite = db.Column(db.Boolean, default=False)
|
||||
api_data = db.Column(db.Text)
|
||||
last_updated = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
meal_foods = db.relationship('MealFood', backref='food', lazy=True)
|
||||
planned_foods = db.relationship('PlannedFood', backref='food', lazy=True)
|
||||
|
||||
class Meal(db.Model):
|
||||
__tablename__ = 'meals'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, default=date.today)
|
||||
meal_type = db.Column(db.String(20), nullable=False) # breakfast, lunch, dinner, snack
|
||||
time = db.Column(db.Time)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
foods = db.relationship('MealFood', backref='meal', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def calculate_totals(self):
|
||||
"""Calculate total nutrition for this meal"""
|
||||
totals = {
|
||||
'calories': 0,
|
||||
'protein': 0,
|
||||
'carbs': 0,
|
||||
'fat': 0
|
||||
}
|
||||
for mf in self.foods:
|
||||
totals['calories'] += mf.calories_consumed
|
||||
totals['protein'] += mf.protein_consumed
|
||||
totals['carbs'] += mf.carbs_consumed
|
||||
totals['fat'] += mf.fat_consumed
|
||||
return totals
|
||||
|
||||
class MealFood(db.Model):
|
||||
__tablename__ = 'meal_foods'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meal_id = db.Column(db.Integer, db.ForeignKey('meals.id'), nullable=False)
|
||||
food_id = db.Column(db.Integer, db.ForeignKey('food_items.id'), nullable=False)
|
||||
quantity = db.Column(db.Float, nullable=False, default=1.0) # servings
|
||||
quantity_grams = db.Column(db.Float)
|
||||
calories_consumed = db.Column(db.Float)
|
||||
protein_consumed = db.Column(db.Float)
|
||||
carbs_consumed = db.Column(db.Float)
|
||||
fat_consumed = db.Column(db.Float)
|
||||
|
||||
def calculate_nutrition(self):
|
||||
"""Calculate nutrition based on quantity"""
|
||||
self.calories_consumed = self.food.calories * self.quantity
|
||||
self.protein_consumed = self.food.protein_g * self.quantity
|
||||
self.carbs_consumed = self.food.carbs_g * self.quantity
|
||||
self.fat_consumed = self.food.fat_g * self.quantity
|
||||
if self.food.serving_size_g:
|
||||
self.quantity_grams = self.food.serving_size_g * self.quantity
|
||||
|
||||
class WaterLog(db.Model):
|
||||
__tablename__ = 'water_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, default=date.today)
|
||||
amount_ml = db.Column(db.Integer, nullable=False)
|
||||
time = db.Column(db.Time, default=datetime.now().time)
|
||||
|
||||
class WeightLog(db.Model):
|
||||
__tablename__ = 'weight_logs'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, unique=True, default=date.today)
|
||||
weight_kg = db.Column(db.Float, nullable=False)
|
||||
body_fat_percentage = db.Column(db.Float)
|
||||
notes = db.Column(db.Text)
|
||||
time = db.Column(db.Time, default=datetime.now().time)
|
||||
|
||||
class MealPlan(db.Model):
|
||||
__tablename__ = 'meal_plans'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
meal_type = db.Column(db.String(20), nullable=False)
|
||||
is_completed = db.Column(db.Boolean, default=False)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
# Relationships
|
||||
foods = db.relationship('PlannedFood', backref='meal_plan', lazy=True, cascade='all, delete-orphan')
|
||||
|
||||
def calculate_totals(self):
|
||||
"""Calculate total nutrition for this planned meal"""
|
||||
totals = {
|
||||
'calories': 0,
|
||||
'protein': 0,
|
||||
'carbs': 0,
|
||||
'fat': 0
|
||||
}
|
||||
for pf in self.foods:
|
||||
totals['calories'] += pf.food.calories * pf.quantity
|
||||
totals['protein'] += pf.food.protein_g * pf.quantity
|
||||
totals['carbs'] += pf.food.carbs_g * pf.quantity
|
||||
totals['fat'] += pf.food.fat_g * pf.quantity
|
||||
return totals
|
||||
|
||||
class PlannedFood(db.Model):
|
||||
__tablename__ = 'planned_foods'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
meal_plan_id = db.Column(db.Integer, db.ForeignKey('meal_plans.id'), nullable=False)
|
||||
food_id = db.Column(db.Integer, db.ForeignKey('food_items.id'), nullable=False)
|
||||
quantity = db.Column(db.Float, nullable=False, default=1.0)
|
||||
|
||||
class UserGoal(db.Model):
|
||||
__tablename__ = 'user_goals'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, unique=True)
|
||||
goal_type = db.Column(db.String(20), default='recomp') # weight_loss, muscle_gain, recomp
|
||||
target_weight_kg = db.Column(db.Float)
|
||||
weekly_goal_kg = db.Column(db.Float, default=0.5)
|
||||
target_protein_g = db.Column(db.Integer, default=150)
|
||||
target_carbs_g = db.Column(db.Integer, default=200)
|
||||
target_fat_g = db.Column(db.Integer, default=60)
|
||||
target_water_ml = db.Column(db.Integer, default=2000)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
class DailySummary(db.Model):
|
||||
__tablename__ = 'daily_summary'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False, unique=True)
|
||||
total_calories = db.Column(db.Float, default=0)
|
||||
total_protein_g = db.Column(db.Float, default=0)
|
||||
total_carbs_g = db.Column(db.Float, default=0)
|
||||
total_fat_g = db.Column(db.Float, default=0)
|
||||
total_water_ml = db.Column(db.Integer, default=0)
|
||||
calories_remaining = db.Column(db.Float)
|
||||
weight_kg = db.Column(db.Float)
|
||||
notes = db.Column(db.Text)
|
||||
|
||||
class APICache(db.Model):
|
||||
__tablename__ = 'api_cache'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
query = db.Column(db.String(200), nullable=False, unique=True)
|
||||
api_source = db.Column(db.String(50))
|
||||
response_json = db.Column(db.Text)
|
||||
cached_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
7
calorie_tracker_app/requirements.txt
Normal file
7
calorie_tracker_app/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
Flask==3.0.0
|
||||
Flask-SQLAlchemy==3.1.1
|
||||
Flask-Login==0.6.3
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
Werkzeug==3.0.1
|
||||
gunicorn==21.2.0
|
||||
9
calorie_tracker_app/run_app.bat
Normal file
9
calorie_tracker_app/run_app.bat
Normal file
@@ -0,0 +1,9 @@
|
||||
@echo off
|
||||
echo Starting Calorie Tracker App...
|
||||
echo.
|
||||
echo If you see "ModuleNotFoundError", make sure you ran:
|
||||
echo pip install -r requirements.txt
|
||||
echo.
|
||||
echo Opening app on http://127.0.0.1:5001
|
||||
venv\Scripts\python app.py
|
||||
pause
|
||||
368
calorie_tracker_app/seed_data.py
Normal file
368
calorie_tracker_app/seed_data.py
Normal file
@@ -0,0 +1,368 @@
|
||||
from models import FoodItem, db
|
||||
|
||||
def seed_filipino_foods():
|
||||
"""Populate database with common Filipino foods"""
|
||||
|
||||
filipino_foods = [
|
||||
# Rice (Kanin)
|
||||
{
|
||||
'name': 'White Rice',
|
||||
'name_tagalog': 'Kanin',
|
||||
'category': 'kanin',
|
||||
'calories': 206,
|
||||
'protein_g': 4.3,
|
||||
'carbs_g': 45,
|
||||
'fat_g': 0.4,
|
||||
'serving_description': '1 cup cooked',
|
||||
'serving_size_g': 158
|
||||
},
|
||||
{
|
||||
'name': 'Fried Rice',
|
||||
'name_tagalog': 'Sinangag',
|
||||
'category': 'kanin',
|
||||
'calories': 280,
|
||||
'protein_g': 5,
|
||||
'carbs_g': 42,
|
||||
'fat_g': 10,
|
||||
'serving_description': '1 cup',
|
||||
'serving_size_g': 170
|
||||
},
|
||||
|
||||
# Main Dishes (Ulam)
|
||||
{
|
||||
'name': 'Chicken Adobo',
|
||||
'name_tagalog': 'Adobong Manok',
|
||||
'category': 'ulam',
|
||||
'calories': 350,
|
||||
'protein_g': 35,
|
||||
'carbs_g': 5,
|
||||
'fat_g': 20,
|
||||
'serving_description': '1 serving (2 pieces)',
|
||||
'serving_size_g': 200
|
||||
},
|
||||
{
|
||||
'name': 'Pork Sinigang',
|
||||
'name_tagalog': 'Sinigang na Baboy',
|
||||
'category': 'sabaw',
|
||||
'calories': 280,
|
||||
'protein_g': 25,
|
||||
'carbs_g': 10,
|
||||
'fat_g': 15,
|
||||
'serving_description': '1 bowl',
|
||||
'serving_size_g': 350
|
||||
},
|
||||
{
|
||||
'name': 'Chicken Tinola',
|
||||
'name_tagalog': 'Tinolang Manok',
|
||||
'category': 'sabaw',
|
||||
'calories': 200,
|
||||
'protein_g': 28,
|
||||
'carbs_g': 8,
|
||||
'fat_g': 6,
|
||||
'serving_description': '1 bowl',
|
||||
'serving_size_g': 350
|
||||
},
|
||||
{
|
||||
'name': 'Bicol Express',
|
||||
'name_tagalog': 'Bicol Express',
|
||||
'category': 'ulam',
|
||||
'calories': 400,
|
||||
'protein_g': 20,
|
||||
'carbs_g': 10,
|
||||
'fat_g': 32,
|
||||
'serving_description': '1 serving',
|
||||
'serving_size_g': 200
|
||||
},
|
||||
{
|
||||
'name': 'Pork Sisig',
|
||||
'name_tagalog': 'Sisig',
|
||||
'category': 'ulam',
|
||||
'calories': 450,
|
||||
'protein_g': 25,
|
||||
'carbs_g': 8,
|
||||
'fat_g': 35,
|
||||
'serving_description': '1 serving',
|
||||
'serving_size_g': 180
|
||||
},
|
||||
{
|
||||
'name': 'Menudo',
|
||||
'name_tagalog': 'Menudo',
|
||||
'category': 'ulam',
|
||||
'calories': 320,
|
||||
'protein_g': 22,
|
||||
'carbs_g': 12,
|
||||
'fat_g': 20,
|
||||
'serving_description': '1 serving',
|
||||
'serving_size_g': 200
|
||||
},
|
||||
{
|
||||
'name': 'Kare-Kare',
|
||||
'name_tagalog': 'Kare-Kare',
|
||||
'category': 'ulam',
|
||||
'calories': 380,
|
||||
'protein_g': 24,
|
||||
'carbs_g': 18,
|
||||
'fat_g': 25,
|
||||
'serving_description': '1 serving',
|
||||
'serving_size_g': 250
|
||||
},
|
||||
{
|
||||
'name': 'Lechon Kawali',
|
||||
'name_tagalog': 'Lechon Kawali',
|
||||
'category': 'ulam',
|
||||
'calories': 500,
|
||||
'protein_g': 30,
|
||||
'carbs_g': 2,
|
||||
'fat_g': 42,
|
||||
'serving_description': '1 serving',
|
||||
'serving_size_g': 150
|
||||
},
|
||||
{
|
||||
'name': 'Pork Nilaga',
|
||||
'name_tagalog': 'Nilagang Baboy',
|
||||
'category': 'sabaw',
|
||||
'calories': 280,
|
||||
'protein_g': 28,
|
||||
'carbs_g': 12,
|
||||
'fat_g': 14,
|
||||
'serving_description': '1 bowl',
|
||||
'serving_size_g': 350
|
||||
},
|
||||
{
|
||||
'name': 'Beef Bulalo',
|
||||
'name_tagalog': 'Bulalo',
|
||||
'category': 'sabaw',
|
||||
'calories': 350,
|
||||
'protein_g': 32,
|
||||
'carbs_g': 8,
|
||||
'fat_g': 22,
|
||||
'serving_description': '1 bowl',
|
||||
'serving_size_g': 400
|
||||
},
|
||||
|
||||
# Vegetables (Gulay)
|
||||
{
|
||||
'name': 'Pinakbet',
|
||||
'name_tagalog': 'Pinakbet',
|
||||
'category': 'gulay',
|
||||
'calories': 150,
|
||||
'protein_g': 5,
|
||||
'carbs_g': 20,
|
||||
'fat_g': 6,
|
||||
'serving_description': '1 cup',
|
||||
'serving_size_g': 200
|
||||
},
|
||||
{
|
||||
'name': 'Laing',
|
||||
'name_tagalog': 'Laing',
|
||||
'category': 'gulay',
|
||||
'calories': 180,
|
||||
'protein_g': 6,
|
||||
'carbs_g': 15,
|
||||
'fat_g': 12,
|
||||
'serving_description': '1 cup',
|
||||
'serving_size_g': 180
|
||||
},
|
||||
{
|
||||
'name': 'Ginisang Monggo',
|
||||
'name_tagalog': 'Ginisang Monggo',
|
||||
'category': 'gulay',
|
||||
'calories': 200,
|
||||
'protein_g': 12,
|
||||
'carbs_g': 30,
|
||||
'fat_g': 4,
|
||||
'serving_description': '1 cup',
|
||||
'serving_size_g': 220
|
||||
},
|
||||
|
||||
# Breakfast (Almusal)
|
||||
{
|
||||
'name': 'Beef Tapa with Rice and Egg',
|
||||
'name_tagalog': 'Tapsilog',
|
||||
'category': 'almusal',
|
||||
'calories': 650,
|
||||
'protein_g': 45,
|
||||
'carbs_g': 60,
|
||||
'fat_g': 25,
|
||||
'serving_description': '1 plate',
|
||||
'serving_size_g': 400
|
||||
},
|
||||
{
|
||||
'name': 'Longganisa with Rice and Egg',
|
||||
'name_tagalog': 'Longsilog',
|
||||
'category': 'almusal',
|
||||
'calories': 700,
|
||||
'protein_g': 38,
|
||||
'carbs_g': 65,
|
||||
'fat_g': 32,
|
||||
'serving_description': '1 plate',
|
||||
'serving_size_g': 420
|
||||
},
|
||||
{
|
||||
'name': 'Tocino with Rice and Egg',
|
||||
'name_tagalog': 'Tocilog',
|
||||
'category': 'almusal',
|
||||
'calories': 680,
|
||||
'protein_g': 42,
|
||||
'carbs_g': 62,
|
||||
'fat_g': 28,
|
||||
'serving_description': '1 plate',
|
||||
'serving_size_g': 400
|
||||
},
|
||||
{
|
||||
'name': 'Fried Egg',
|
||||
'name_tagalog': 'Pritong Itlog',
|
||||
'category': 'almusal',
|
||||
'calories': 90,
|
||||
'protein_g': 6,
|
||||
'carbs_g': 1,
|
||||
'fat_g': 7,
|
||||
'serving_description': '1 egg',
|
||||
'serving_size_g': 50
|
||||
},
|
||||
|
||||
# Snacks (Meryenda)
|
||||
{
|
||||
'name': 'Pandesal',
|
||||
'name_tagalog': 'Pandesal',
|
||||
'category': 'meryenda',
|
||||
'calories': 120,
|
||||
'protein_g': 3,
|
||||
'carbs_g': 22,
|
||||
'fat_g': 2,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 40
|
||||
},
|
||||
{
|
||||
'name': 'Turon',
|
||||
'name_tagalog': 'Turon',
|
||||
'category': 'meryenda',
|
||||
'calories': 180,
|
||||
'protein_g': 2,
|
||||
'carbs_g': 35,
|
||||
'fat_g': 5,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 80
|
||||
},
|
||||
{
|
||||
'name': 'Bibingka',
|
||||
'name_tagalog': 'Bibingka',
|
||||
'category': 'meryenda',
|
||||
'calories': 220,
|
||||
'protein_g': 5,
|
||||
'carbs_g': 38,
|
||||
'fat_g': 6,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 100
|
||||
},
|
||||
{
|
||||
'name': 'Puto',
|
||||
'name_tagalog': 'Puto',
|
||||
'category': 'meryenda',
|
||||
'calories': 90,
|
||||
'protein_g': 2,
|
||||
'carbs_g': 18,
|
||||
'fat_g': 1,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 40
|
||||
},
|
||||
{
|
||||
'name': 'Lumpia',
|
||||
'name_tagalog': 'Lumpia',
|
||||
'category': 'meryenda',
|
||||
'calories': 100,
|
||||
'protein_g': 4,
|
||||
'carbs_g': 10,
|
||||
'fat_g': 5,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 50
|
||||
},
|
||||
{
|
||||
'name': 'Banana Cue',
|
||||
'name_tagalog': 'Banana Cue',
|
||||
'category': 'meryenda',
|
||||
'calories': 150,
|
||||
'protein_g': 1,
|
||||
'carbs_g': 32,
|
||||
'fat_g': 4,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 100
|
||||
},
|
||||
|
||||
# Proteins
|
||||
{
|
||||
'name': 'Grilled Tilapia',
|
||||
'name_tagalog': 'Inihaw na Tilapia',
|
||||
'category': 'ulam',
|
||||
'calories': 180,
|
||||
'protein_g': 32,
|
||||
'carbs_g': 0,
|
||||
'fat_g': 5,
|
||||
'serving_description': '1 whole fish',
|
||||
'serving_size_g': 150
|
||||
},
|
||||
{
|
||||
'name': 'Grilled Chicken',
|
||||
'name_tagalog': 'Inihaw na Manok',
|
||||
'category': 'ulam',
|
||||
'calories': 280,
|
||||
'protein_g': 40,
|
||||
'carbs_g': 0,
|
||||
'fat_g': 13,
|
||||
'serving_description': '1 breast',
|
||||
'serving_size_g': 150
|
||||
},
|
||||
{
|
||||
'name': 'Fried Bangus',
|
||||
'name_tagalog': 'Pritong Bangus',
|
||||
'category': 'ulam',
|
||||
'calories': 220,
|
||||
'protein_g': 28,
|
||||
'carbs_g': 0,
|
||||
'fat_g': 12,
|
||||
'serving_description': '1 piece',
|
||||
'serving_size_g': 120
|
||||
}
|
||||
]
|
||||
|
||||
added_count = 0
|
||||
|
||||
for food_data in filipino_foods:
|
||||
# Check if already exists
|
||||
existing = FoodItem.query.filter_by(
|
||||
name=food_data['name'],
|
||||
is_filipino=True
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
food = FoodItem(
|
||||
name=food_data['name'],
|
||||
name_tagalog=food_data.get('name_tagalog'),
|
||||
category=food_data['category'],
|
||||
calories=food_data['calories'],
|
||||
protein_g=food_data['protein_g'],
|
||||
carbs_g=food_data['carbs_g'],
|
||||
fat_g=food_data['fat_g'],
|
||||
fiber_g=food_data.get('fiber_g', 0),
|
||||
sugar_g=food_data.get('sugar_g', 0),
|
||||
sodium_mg=food_data.get('sodium_mg', 0),
|
||||
serving_size_g=food_data['serving_size_g'],
|
||||
serving_description=food_data['serving_description'],
|
||||
source='filipino',
|
||||
is_filipino=True
|
||||
)
|
||||
db.session.add(food)
|
||||
added_count += 1
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
print(f"Successfully added {added_count} Filipino foods to the database!")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error seeding Filipino foods: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
from app import app, db
|
||||
|
||||
with app.app_context():
|
||||
seed_filipino_foods()
|
||||
6
calorie_tracker_app/seed_db.bat
Normal file
6
calorie_tracker_app/seed_db.bat
Normal file
@@ -0,0 +1,6 @@
|
||||
@echo off
|
||||
echo Seeding Filipino Foods database...
|
||||
venv\Scripts\python seed_data.py
|
||||
echo.
|
||||
echo Done!
|
||||
pause
|
||||
221
calorie_tracker_app/templates/add_meal.html
Normal file
221
calorie_tracker_app/templates/add_meal.html
Normal file
@@ -0,0 +1,221 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Add Meal - Calorie Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-6">Add Meal</h1>
|
||||
|
||||
<form method="POST" id="mealForm" class="glass rounded-xl p-8 shadow-lg">
|
||||
<!-- Meal Details -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Date</label>
|
||||
<input type="date" name="date" value="{{ today }}" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Meal Type</label>
|
||||
<select name="meal_type" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
<option value="breakfast">🌅 Breakfast</option>
|
||||
<option value="lunch">🌞 Lunch</option>
|
||||
<option value="dinner">🌙 Dinner</option>
|
||||
<option value="snack">🍪 Snack</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Time (optional)</label>
|
||||
<input type="time" name="time" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Food Search -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Search Foods</label>
|
||||
<input type="text" id="foodSearch" placeholder="Search Filipino or international foods..." class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary">
|
||||
<div id="searchResults" class="mt-2 max-h-64 overflow-y-auto hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Foods -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-bold mb-3">Selected Foods</h3>
|
||||
<div id="selectedFoods" class="space-y-3">
|
||||
<p class="text-gray-500 text-center py-8" id="emptyMessage">No foods added yet. Search and add foods above.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="bg-blue-50 border-l-4 border-blue-500 p-4 rounded mb-6">
|
||||
<h4 class="font-bold text-blue-900 mb-2">Meal Summary</h4>
|
||||
<div class="grid grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p class="text-sm text-blue-700">Calories</p>
|
||||
<p class="text-xl font-bold text-blue-900" id="totalCalories">0</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-blue-700">Protein</p>
|
||||
<p class="text-xl font-bold text-blue-900" id="totalProtein">0g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-blue-700">Carbs</p>
|
||||
<p class="text-xl font-bold text-blue-900" id="totalCarbs">0g</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-blue-700">Fat</p>
|
||||
<p class="text-xl font-bold text-blue-900" id="totalFat">0g</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between">
|
||||
<a href="{{ url_for('index') }}" class="px-6 py-3 border border-gray-300 rounded-lg hover:bg-gray-100 transition">
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="bg-primary text-white px-8 py-3 rounded-lg hover:bg-red-700 transition">
|
||||
Save Meal
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let selectedFoods = [];
|
||||
let searchTimeout;
|
||||
|
||||
// Food Search
|
||||
document.getElementById('foodSearch').addEventListener('input', function(e) {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = e.target.value;
|
||||
|
||||
if (query.length < 2) {
|
||||
document.getElementById('searchResults').classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(() => {
|
||||
fetch(`/api/search-food?q=${encodeURIComponent(query)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => displaySearchResults(data));
|
||||
}, 300);
|
||||
});
|
||||
|
||||
function displaySearchResults(results) {
|
||||
const container = document.getElementById('searchResults');
|
||||
|
||||
if (results.length === 0) {
|
||||
container.innerHTML = '<p class="p-4 text-gray-500">No foods found</p>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = results.map(food => `
|
||||
<div class="p-3 border rounded hover:bg-gray-50 cursor-pointer" onclick='addFood(${JSON.stringify(food)})'>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold">${food.name}</p>
|
||||
${food.name_tagalog ? `<p class="text-sm text-gray-600">${food.name_tagalog}</p>` : ''}
|
||||
<p class="text-xs text-gray-500">${food.serving_description || '1 serving'}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="font-bold text-primary">${Math.round(food.calories)} cal</p>
|
||||
<p class="text-xs text-gray-600">P:${Math.round(food.protein_g)}g C:${Math.round(food.carbs_g)}g F:${Math.round(food.fat_g)}g</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function addFood(food) {
|
||||
// Save to database if from API
|
||||
if (food.source === 'api' && !food.id) {
|
||||
fetch('/api/add-food', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(food)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
food.id = data.food_id;
|
||||
addFoodToList(food);
|
||||
});
|
||||
} else {
|
||||
addFoodToList(food);
|
||||
}
|
||||
|
||||
// Clear search
|
||||
document.getElementById('foodSearch').value = '';
|
||||
document.getElementById('searchResults').classList.add('hidden');
|
||||
}
|
||||
|
||||
function addFoodToList(food) {
|
||||
selectedFoods.push({...food, quantity: 1});
|
||||
renderSelectedFoods();
|
||||
}
|
||||
|
||||
function removeFood(index) {
|
||||
selectedFoods.splice(index, 1);
|
||||
renderSelectedFoods();
|
||||
}
|
||||
|
||||
function updateQuantity(index, quantity) {
|
||||
selectedFoods[index].quantity = parseFloat(quantity);
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function renderSelectedFoods() {
|
||||
const container = document.getElementById('selectedFoods');
|
||||
const emptyMessage = document.getElementById('emptyMessage');
|
||||
|
||||
if (selectedFoods.length === 0) {
|
||||
emptyMessage.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
emptyMessage.classList.add('hidden');
|
||||
|
||||
container.innerHTML = selectedFoods.map((food, index) => `
|
||||
<div class="flex items-center justify-between p-4 border rounded-lg bg-white">
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">${food.name}</p>
|
||||
<p class="text-sm text-gray-600">${Math.round(food.calories * food.quantity)} cal | P:${Math.round(food.protein_g * food.quantity)}g C:${Math.round(food.carbs_g * food.quantity)}g F:${Math.round(food.fat_g * food.quantity)}g</p>
|
||||
<input type="hidden" name="food_id[]" value="${food.id}">
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<label class="text-sm text-gray-600">Servings:</label>
|
||||
<input type="number" step="0.5" min="0.5" name="quantity[]" value="${food.quantity}"
|
||||
class="w-20 px-2 py-1 border rounded" onchange="updateQuantity(${index}, this.value)">
|
||||
<button type="button" onclick="removeFood(${index})" class="text-red-600 hover:text-red-800">
|
||||
❌
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
updateSummary();
|
||||
}
|
||||
|
||||
function updateSummary() {
|
||||
const totals = selectedFoods.reduce((acc, food) => ({
|
||||
calories: acc.calories + (food.calories * food.quantity),
|
||||
protein: acc.protein + (food.protein_g * food.quantity),
|
||||
carbs: acc.carbs + (food.carbs_g * food.quantity),
|
||||
fat: acc.fat + (food.fat_g * food.quantity)
|
||||
}), {calories: 0, protein: 0, carbs: 0, fat: 0});
|
||||
|
||||
document.getElementById('totalCalories').textContent = Math.round(totals.calories);
|
||||
document.getElementById('totalProtein').textContent = Math.round(totals.protein) + 'g';
|
||||
document.getElementById('totalCarbs').textContent = Math.round(totals.carbs) + 'g';
|
||||
document.getElementById('totalFat').textContent = Math.round(totals.fat) + 'g';
|
||||
}
|
||||
|
||||
// Prevent form submission if no foods
|
||||
document.getElementById('mealForm').addEventListener('submit', function(e) {
|
||||
if (selectedFoods.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('Please add at least one food item');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
92
calorie_tracker_app/templates/base.html
Normal file
92
calorie_tracker_app/templates/base.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Calorie Tracker{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#D62828',
|
||||
secondary: '#003F87',
|
||||
success: '#06D6A0',
|
||||
warning: '#FFB703',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Navigation -->
|
||||
<nav class="bg-primary text-white shadow-lg">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div class="flex items-center space-x-8">
|
||||
<a href="{{ url_for('index') }}" class="text-2xl font-bold">🍽️ Calorie Tracker</a>
|
||||
<div class="hidden md:flex space-x-6">
|
||||
<a href="{{ url_for('index') }}" class="hover:text-gray-200 transition {% if request.endpoint == 'index' %}font-bold{% endif %}">
|
||||
🏠 Dashboard
|
||||
</a>
|
||||
<a href="{{ url_for('meal_planner') }}" class="hover:text-gray-200 transition {% if request.endpoint == 'meal_planner' %}font-bold{% endif %}">
|
||||
📅 Meal Planner
|
||||
</a>
|
||||
<a href="{{ url_for('foods') }}" class="hover:text-gray-200 transition {% if request.endpoint == 'foods' %}font-bold{% endif %}">
|
||||
🍛 Foods
|
||||
</a>
|
||||
<a href="{{ url_for('progress') }}" class="hover:text-gray-200 transition {% if request.endpoint == 'progress' %}font-bold{% endif %}">
|
||||
📊 Progress
|
||||
</a>
|
||||
<a href="{{ url_for('goals') }}" class="hover:text-gray-200 transition {% if request.endpoint == 'goals' %}font-bold{% endif %}">
|
||||
🎯 Goals
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="hidden md:inline">👤 {{ current_user.name or current_user.username }}</span>
|
||||
<a href="{{ url_for('logout') }}" class="bg-white text-primary px-4 py-2 rounded hover:bg-gray-100 transition">
|
||||
Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="container mx-auto px-4 mt-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="{% if category == 'error' %}bg-red-100 border-red-400 text-red-700{% else %}bg-green-100 border-green-400 text-green-700{% endif %} border px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span class="block sm:inline">{{ message }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-800 text-white text-center py-4 mt-12">
|
||||
<p>© 2026 Calorie Tracker - Filipino Food Edition</p>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
300
calorie_tracker_app/templates/dashboard.html
Normal file
300
calorie_tracker_app/templates/dashboard.html
Normal file
@@ -0,0 +1,300 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard - Calorie Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">Today's Summary</h1>
|
||||
<p class="text-gray-600">{{ today.strftime('%A, %B %d, %Y') }}</p>
|
||||
</div>
|
||||
<div class="space-x-2">
|
||||
<a href="{{ url_for('add_meal') }}" class="bg-primary text-white px-6 py-3 rounded-lg hover:bg-red-700 transition inline-block">
|
||||
➕ Add Meal
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Cards Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
<!-- Calories Card -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-gray-600 text-sm">Calories</p>
|
||||
<h2 class="text-3xl font-bold text-primary">{{ nutrition.calories|round|int }}</h2>
|
||||
<p class="text-sm text-gray-500">/ {{ current_user.target_daily_calories }}</p>
|
||||
</div>
|
||||
<span class="text-4xl">🔥</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
{% set cal_percent = (nutrition.calories / current_user.target_daily_calories * 100)|int if current_user.target_daily_calories > 0 else 0 %}
|
||||
<div class="bg-primary h-3 rounded-full transition-all" style="width: {{ [cal_percent, 100]|min }}%"></div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 {% if remaining.calories >= 0 %}text-success{% else %}text-red-600{% endif %}">
|
||||
{{ remaining.calories|round|int }} remaining
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Protein Card -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<p class="text-gray-600 text-sm">Protein</p>
|
||||
<h2 class="text-3xl font-bold text-secondary">{{ nutrition.protein|round|int }}g</h2>
|
||||
<p class="text-sm text-gray-500">/ {{ goals.target_protein_g }}g</p>
|
||||
</div>
|
||||
<span class="text-4xl">💪</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-3">
|
||||
{% set prot_percent = (nutrition.protein / goals.target_protein_g * 100)|int if goals.target_protein_g > 0 else 0 %}
|
||||
<div class="bg-secondary h-3 rounded-full" style="width: {{ [prot_percent, 100]|min }}%"></div>
|
||||
</div>
|
||||
<p class="text-sm mt-2 {% if remaining.protein >= 0 %}text-success{% else %}text-red-600{% endif %}">
|
||||
{{ remaining.protein|round|int }}g remaining
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Carbs & Fat Card -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Carbs</span>
|
||||
<span class="font-bold text-warning">{{ nutrition.carbs|round|int }}g / {{ goals.target_carbs_g }}g</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
{% set carb_percent = (nutrition.carbs / goals.target_carbs_g * 100)|int if goals.target_carbs_g > 0 else 0 %}
|
||||
<div class="bg-warning h-2 rounded-full" style="width: {{ [carb_percent, 100]|min }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Fat</span>
|
||||
<span class="font-bold text-orange-600">{{ nutrition.fat|round|int }}g / {{ goals.target_fat_g }}g</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
{% set fat_percent = (nutrition.fat / goals.target_fat_g * 100)|int if goals.target_fat_g > 0 else 0 %}
|
||||
<div class="bg-orange-600 h-2 rounded-full" style="width: {{ [fat_percent, 100]|min }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Water & Weight Card -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<div class="mb-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Water</span>
|
||||
<span class="font-bold text-blue-600">{{ water.total_ml }}ml / {{ goals.target_water_ml }}ml</span>
|
||||
</div>
|
||||
<div class="flex space-x-1 mt-2">
|
||||
{% set glasses_filled = (water.total_ml / 250)|int %}
|
||||
{% for i in range(8) %}
|
||||
<span class="{% if i < glasses_filled %}text-blue-500{% else %}text-gray-300{% endif %}">💧</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<!-- Quick add water buttons -->
|
||||
<form method="POST" action="{{ url_for('add_water') }}" class="flex space-x-1 mt-2">
|
||||
<button type="submit" name="amount_ml" value="250" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs hover:bg-blue-200">+250ml</button>
|
||||
<button type="submit" name="amount_ml" value="500" class="bg-blue-100 text-blue-700 px-2 py-1 rounded text-xs hover:bg-blue-200">+500ml</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="pt-3 border-t">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 text-sm">Weight</span>
|
||||
{% if weight_today %}
|
||||
<div class="text-right">
|
||||
<span class="font-bold">{{ weight_today.weight_kg }}kg</span>
|
||||
{% if weight_change %}
|
||||
<span class="text-xs {% if weight_change < 0 %}text-success{% else %}text-red-600{% endif %}">
|
||||
{{ weight_change|round(1) }}kg
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('add_weight') }}" class="flex items-center space-x-2">
|
||||
<input type="number" step="0.1" name="weight_kg" placeholder="kg" class="w-20 px-2 py-1 border rounded text-sm" required>
|
||||
<button type="submit" class="bg-green-500 text-white px-2 py-1 rounded text-xs hover:bg-green-600">Log</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Macro Distribution Pie Chart -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<h3 class="text-lg font-bold mb-4">Macro Distribution</h3>
|
||||
<canvas id="macroChart" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg lg:col-span-2">
|
||||
<h3 class="text-lg font-bold mb-4">💡 Smart Suggestions</h3>
|
||||
{% if suggestions %}
|
||||
<div class="space-y-3">
|
||||
{% for suggestion in suggestions %}
|
||||
<div class="bg-blue-50 border-l-4 border-blue-500 p-3 rounded">
|
||||
<p class="font-semibold text-blue-900">{{ suggestion.category }}</p>
|
||||
<p class="text-sm text-blue-700">Try: {{ suggestion.examples|join(', ') }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-600">You're on track! Keep up the good work! 🎉</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Meals -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg mb-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-bold">Today's Meals</h3>
|
||||
<a href="{{ url_for('add_meal') }}" class="text-primary hover:underline">+ Add Meal</a>
|
||||
</div>
|
||||
|
||||
{% if nutrition.meals %}
|
||||
<div class="space-y-4">
|
||||
{% for meal in nutrition.meals %}
|
||||
<div class="border-l-4 {% if meal.type == 'breakfast' %}border-yellow-500{% elif meal.type == 'lunch' %}border-green-500{% elif meal.type == 'dinner' %}border-blue-500{% else %}border-purple-500{% endif %} pl-4 py-2">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-2xl">
|
||||
{% if meal.type == 'breakfast' %}🌅{% elif meal.type == 'lunch' %}🌞{% elif meal.type == 'dinner' %}🌙{% else %}🍪{% endif %}
|
||||
</span>
|
||||
<h4 class="font-bold text-lg capitalize">{{ meal.type }}</h4>
|
||||
{% if meal.time %}
|
||||
<span class="text-sm text-gray-500">{{ meal.time }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
{% for food in meal.foods %}
|
||||
<p class="text-sm text-gray-700">• {{ food.name }} ({{ food.quantity }}x) - {{ food.calories|round|int }} cal</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-2xl font-bold text-primary">{{ meal.totals.calories|round|int }}</p>
|
||||
<p class="text-xs text-gray-500">calories</p>
|
||||
<p class="text-xs text-gray-600 mt-1">
|
||||
P: {{ meal.totals.protein|round|int }}g |
|
||||
C: {{ meal.totals.carbs|round|int }}g |
|
||||
F: {{ meal.totals.fat|round|int }}g
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12 text-gray-500">
|
||||
<p class="text-4xl mb-4">🍽️</p>
|
||||
<p>No meals logged yet today.</p>
|
||||
<a href="{{ url_for('add_meal') }}" class="text-primary hover:underline mt-2 inline-block">Add your first meal</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Weekly Trends -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Calorie Trend -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<h3 class="text-lg font-bold mb-4">📈 Calorie Trend (7 Days)</h3>
|
||||
<canvas id="calorieChart" height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Weight Trend -->
|
||||
<div class="glass rounded-xl p-6 shadow-lg">
|
||||
<h3 class="text-lg font-bold mb-4">⚖️ Weight Trend (7 Days)</h3>
|
||||
<canvas id="weightChart" height="200"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Macro Distribution Chart
|
||||
const macroCtx = document.getElementById('macroChart').getContext('2d');
|
||||
new Chart(macroCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Protein', 'Carbs', 'Fat'],
|
||||
datasets: [{
|
||||
data: [{{ macro_percentages.protein }}, {{ macro_percentages.carbs }}, {{ macro_percentages.fat }}],
|
||||
backgroundColor: ['#003F87', '#FFB703', '#FF6B35']
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Calorie Trend Chart
|
||||
const calorieCtx = document.getElementById('calorieChart').getContext('2d');
|
||||
new Chart(calorieCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [{% for day in calorie_trend %}'{{ day.date }}'{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
label: 'Calories',
|
||||
data: [{% for day in calorie_trend %}{{ day.calories }}{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
borderColor: '#D62828',
|
||||
backgroundColor: 'rgba(214, 40, 40, 0.1)',
|
||||
tension: 0.4
|
||||
}, {
|
||||
label: 'Target',
|
||||
data: Array(7).fill({{ current_user.target_daily_calories }}),
|
||||
borderColor: '#06D6A0',
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Weight Trend Chart
|
||||
{% if weight_trend %}
|
||||
const weightCtx = document.getElementById('weightChart').getContext('2d');
|
||||
new Chart(weightCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [{% for day in weight_trend %}'{{ day.date }}'{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
label: 'Weight (kg)',
|
||||
data: [{% for day in weight_trend %}{{ day.weight_kg }}{% if not loop.last %}, {% endif %}{% endfor %}],
|
||||
borderColor: '#003F87',
|
||||
backgroundColor: 'rgba(0, 63, 135, 0.1)',
|
||||
tension: 0.4,
|
||||
fill: true
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
14
calorie_tracker_app/templates/foods.html
Normal file
14
calorie_tracker_app/templates/foods.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Foods Database{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold mb-6">Filipino Foods Database</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{% for food in filipino_foods %}
|
||||
<div class="glass rounded-lg p-4">
|
||||
<h3 class="font-bold">{{ food.name }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ food.name_tagalog }}</p>
|
||||
<p class="text-primary font-bold">{{ food.calories|round|int }} cal</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
21
calorie_tracker_app/templates/goals.html
Normal file
21
calorie_tracker_app/templates/goals.html
Normal file
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Goals & Settings{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold mb-6">Goals & Settings</h1>
|
||||
<form method="POST" class="glass rounded-xl p-6 max-w-2xl">
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Age</label>
|
||||
<input type="number" name="age" value="{{ user.age or 25 }}" class="w-full px-4 py-2 border rounded" required>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-2">Gender</label>
|
||||
<select name="gender" class="w-full px-4 py-2 border rounded" required>
|
||||
<option value="male" {% if user.gender == 'male' %}selected{% endif %}>Male</option>
|
||||
<option value="female" {% if user.gender == 'female' %}selected{% endif %}>Female</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="bg-primary text-white px-6 py-3 rounded hover:bg-red-700">Save</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
32
calorie_tracker_app/templates/login.html
Normal file
32
calorie_tracker_app/templates/login.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login - Calorie Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="glass rounded-xl p-8 shadow-lg">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">🍽️ Calorie Tracker</h1>
|
||||
<p class="text-center text-gray-600 mb-8">Filipino Food Edition</p>
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<input type="text" name="username" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input type="password" name="password" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-primary text-white py-3 rounded-lg hover:bg-red-700 transition font-semibold">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center mt-6 text-gray-600">
|
||||
Don't have an account? <a href="{{ url_for('register') }}" class="text-primary hover:underline font-semibold">Register</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
6
calorie_tracker_app/templates/meal_planner.html
Normal file
6
calorie_tracker_app/templates/meal_planner.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Meal Planner{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold mb-6">Meal Planner</h1>
|
||||
<p class="text-gray-600">Coming soon! Plan your meals for the week ahead.</p>
|
||||
{% endblock %}
|
||||
8
calorie_tracker_app/templates/progress.html
Normal file
8
calorie_tracker_app/templates/progress.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Progress{% endblock %}
|
||||
{% block content %}
|
||||
<h1 class="text-3xl font-bold mb-6">Your Progress</h1>
|
||||
<div class="glass rounded-xl p-6">
|
||||
<canvas id="progressChart"></canvas>
|
||||
</div>
|
||||
{% endblock %}
|
||||
36
calorie_tracker_app/templates/register.html
Normal file
36
calorie_tracker_app/templates/register.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Register - Calorie Tracker{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-md mx-auto">
|
||||
<div class="glass rounded-xl p-8 shadow-lg">
|
||||
<h1 class="text-3xl font-bold text-center text-gray-800 mb-6">Create Account</h1>
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Name</label>
|
||||
<input type="text" name="name" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Username</label>
|
||||
<input type="text" name="username" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Password</label>
|
||||
<input type="password" name="password" class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full bg-primary text-white py-3 rounded-lg hover:bg-red-700 transition font-semibold">
|
||||
Register
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="text-center mt-6 text-gray-600">
|
||||
Already have an account? <a href="{{ url_for('login') }}" class="text-primary hover:underline font-semibold">Login</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
258
calorie_tracker_app/utils.py
Normal file
258
calorie_tracker_app/utils.py
Normal file
@@ -0,0 +1,258 @@
|
||||
from datetime import date, timedelta
|
||||
from models import db, Meal, WaterLog, WeightLog, DailySummary, UserGoal
|
||||
|
||||
def calculate_bmr(weight_kg, height_cm, age, gender):
|
||||
"""
|
||||
Calculate Basal Metabolic Rate using Mifflin-St Jeor Equation
|
||||
"""
|
||||
if gender.lower() == 'male':
|
||||
bmr = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) + 5
|
||||
else: # female
|
||||
bmr = (10 * weight_kg) + (6.25 * height_cm) - (5 * age) - 161
|
||||
|
||||
return round(bmr)
|
||||
|
||||
def calculate_tdee(bmr, activity_level):
|
||||
"""
|
||||
Calculate Total Daily Energy Expenditure
|
||||
"""
|
||||
multipliers = {
|
||||
'sedentary': 1.2,
|
||||
'light': 1.375,
|
||||
'moderate': 1.55,
|
||||
'active': 1.725,
|
||||
'very_active': 1.9
|
||||
}
|
||||
|
||||
multiplier = multipliers.get(activity_level, 1.55)
|
||||
return round(bmr * multiplier)
|
||||
|
||||
def calculate_macro_targets(weight_kg, goal_type='recomp'):
|
||||
"""
|
||||
Calculate macro targets based on body weight and goal
|
||||
"""
|
||||
if goal_type == 'muscle_gain':
|
||||
protein = weight_kg * 2.4 # High protein for muscle building
|
||||
carbs = weight_kg * 3.5 # Higher carbs for energy
|
||||
fat = weight_kg * 1.0 # Moderate fat
|
||||
elif goal_type == 'weight_loss':
|
||||
protein = weight_kg * 2.2 # High protein to preserve muscle
|
||||
carbs = weight_kg * 2.0 # Lower carbs for deficit
|
||||
fat = weight_kg * 0.8 # Lower fat
|
||||
else: # recomp (body recomposition)
|
||||
protein = weight_kg * 2.2 # High protein
|
||||
carbs = weight_kg * 2.5 # Moderate carbs
|
||||
fat = weight_kg * 0.9 # Moderate fat
|
||||
|
||||
return {
|
||||
'protein_g': round(protein),
|
||||
'carbs_g': round(carbs),
|
||||
'fat_g': round(fat)
|
||||
}
|
||||
|
||||
def calculate_daily_totals(user_id, target_date=None):
|
||||
"""
|
||||
Calculate total nutrition consumed for a given date
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = date.today()
|
||||
|
||||
# Get all meals for the date
|
||||
meals = Meal.query.filter_by(user_id=user_id, date=target_date).all()
|
||||
|
||||
totals = {
|
||||
'calories': 0,
|
||||
'protein': 0,
|
||||
'carbs': 0,
|
||||
'fat': 0,
|
||||
'meals': []
|
||||
}
|
||||
|
||||
for meal in meals:
|
||||
meal_totals = meal.calculate_totals()
|
||||
totals['calories'] += meal_totals['calories']
|
||||
totals['protein'] += meal_totals['protein']
|
||||
totals['carbs'] += meal_totals['carbs']
|
||||
totals['fat'] += meal_totals['fat']
|
||||
|
||||
totals['meals'].append({
|
||||
'id': meal.id,
|
||||
'type': meal.meal_type,
|
||||
'time': meal.time.strftime('%H:%M') if meal.time else None,
|
||||
'totals': meal_totals,
|
||||
'foods': [
|
||||
{
|
||||
'name': mf.food.name,
|
||||
'quantity': mf.quantity,
|
||||
'calories': mf.calories_consumed
|
||||
}
|
||||
for mf in meal.foods
|
||||
]
|
||||
})
|
||||
|
||||
return totals
|
||||
|
||||
def calculate_water_total(user_id, target_date=None):
|
||||
"""
|
||||
Calculate total water intake for a given date
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = date.today()
|
||||
|
||||
water_logs = WaterLog.query.filter_by(user_id=user_id, date=target_date).all()
|
||||
total = sum(log.amount_ml for log in water_logs)
|
||||
|
||||
return {
|
||||
'total_ml': total,
|
||||
'logs': [
|
||||
{
|
||||
'id': log.id,
|
||||
'amount_ml': log.amount_ml,
|
||||
'time': log.time.strftime('%H:%M') if log.time else None
|
||||
}
|
||||
for log in water_logs
|
||||
]
|
||||
}
|
||||
|
||||
def get_weight_trend(user_id, days=7):
|
||||
"""
|
||||
Get weight trend for the past N days
|
||||
"""
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
weight_logs = WeightLog.query.filter(
|
||||
WeightLog.user_id == user_id,
|
||||
WeightLog.date >= start_date,
|
||||
WeightLog.date <= end_date
|
||||
).order_by(WeightLog.date).all()
|
||||
|
||||
return [
|
||||
{
|
||||
'date': log.date.strftime('%Y-%m-%d'),
|
||||
'weight_kg': log.weight_kg
|
||||
}
|
||||
for log in weight_logs
|
||||
]
|
||||
|
||||
def get_calorie_trend(user_id, days=7):
|
||||
"""
|
||||
Get calorie intake trend for the past N days
|
||||
"""
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
trend = []
|
||||
current_date = start_date
|
||||
|
||||
while current_date <= end_date:
|
||||
totals = calculate_daily_totals(user_id, current_date)
|
||||
trend.append({
|
||||
'date': current_date.strftime('%Y-%m-%d'),
|
||||
'calories': round(totals['calories']),
|
||||
'protein': round(totals['protein']),
|
||||
'carbs': round(totals['carbs']),
|
||||
'fat': round(totals['fat'])
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return trend
|
||||
|
||||
def update_daily_summary(user_id, target_date=None):
|
||||
"""
|
||||
Update or create daily summary for a user
|
||||
"""
|
||||
if target_date is None:
|
||||
target_date = date.today()
|
||||
|
||||
# Calculate totals
|
||||
nutrition = calculate_daily_totals(user_id, target_date)
|
||||
water = calculate_water_total(user_id, target_date)
|
||||
|
||||
# Get weight for the day
|
||||
weight_log = WeightLog.query.filter_by(user_id=user_id, date=target_date).first()
|
||||
weight = weight_log.weight_kg if weight_log else None
|
||||
|
||||
# Get user's calorie target
|
||||
from models import User
|
||||
user = User.query.get(user_id)
|
||||
target_calories = user.target_daily_calories if user else 2000
|
||||
|
||||
# Find or create summary
|
||||
summary = DailySummary.query.filter_by(user_id=user_id, date=target_date).first()
|
||||
|
||||
if not summary:
|
||||
summary = DailySummary(user_id=user_id, date=target_date)
|
||||
db.session.add(summary)
|
||||
|
||||
# Update values
|
||||
summary.total_calories = nutrition['calories']
|
||||
summary.total_protein_g = nutrition['protein']
|
||||
summary.total_carbs_g = nutrition['carbs']
|
||||
summary.total_fat_g = nutrition['fat']
|
||||
summary.total_water_ml = water['total_ml']
|
||||
summary.calories_remaining = target_calories - nutrition['calories']
|
||||
summary.weight_kg = weight
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
return summary
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error updating daily summary: {e}")
|
||||
return None
|
||||
|
||||
def get_macro_percentages(protein_g, carbs_g, fat_g):
|
||||
"""
|
||||
Calculate macro distribution as percentages
|
||||
"""
|
||||
protein_cal = protein_g * 4
|
||||
carbs_cal = carbs_g * 4
|
||||
fat_cal = fat_g * 9
|
||||
total_cal = protein_cal + carbs_cal + fat_cal
|
||||
|
||||
if total_cal == 0:
|
||||
return {'protein': 0, 'carbs': 0, 'fat': 0}
|
||||
|
||||
return {
|
||||
'protein': round((protein_cal / total_cal) * 100),
|
||||
'carbs': round((carbs_cal / total_cal) * 100),
|
||||
'fat': round((fat_cal / total_cal) * 100)
|
||||
}
|
||||
|
||||
def suggest_foods_for_macros(remaining_protein, remaining_carbs, remaining_fat):
|
||||
"""
|
||||
Suggest Filipino foods based on remaining macros
|
||||
Returns category suggestions
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# High protein needed
|
||||
if remaining_protein > 30:
|
||||
suggestions.append({
|
||||
'category': 'High Protein Ulam',
|
||||
'examples': ['Grilled Tilapia', 'Chicken Tinola', 'Grilled Chicken']
|
||||
})
|
||||
|
||||
# High carbs needed
|
||||
if remaining_carbs > 40:
|
||||
suggestions.append({
|
||||
'category': 'Carbs',
|
||||
'examples': ['White Rice', 'Pandesal', 'Sweet Potato']
|
||||
})
|
||||
|
||||
# High fat needed
|
||||
if remaining_fat > 20:
|
||||
suggestions.append({
|
||||
'category': 'Healthy Fats',
|
||||
'examples': ['Sisig', 'Lechon Kawali', 'Bicol Express']
|
||||
})
|
||||
|
||||
# Balanced meal needed
|
||||
if remaining_protein > 20 and remaining_carbs > 30:
|
||||
suggestions.append({
|
||||
'category': 'Balanced Meals',
|
||||
'examples': ['Tapsilog', 'Chicken Adobo with Rice', 'Sinigang']
|
||||
})
|
||||
|
||||
return suggestions
|
||||
Reference in New Issue
Block a user