commit 139f75680729c6ab3b613c7477533e752cf0d5c7 Author: Otto Date: Mon Mar 30 13:34:53 2026 -0400 Meal Tracker v1 - complete diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef8bdfc --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Meal Tracker + +A personal food and drink logging application for accountability and macro tracking. + +## Project Structure + +``` +meal-tracker/ +├── client/ # React frontend +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ ├── pages/ # Page components +│ │ └── utils/ # Helper functions & API client +│ ├── public/ # Static assets +│ ├── package.json # Client dependencies +│ └── vite.config.js # Vite configuration +├── server/ # Express backend +│ ├── routes/ # API route handlers +│ ├── models/ # Database models +│ ├── middleware/ # Express middleware +│ ├── index.js # Server entry point +│ └── package.json # Server dependencies +├── uploads/ # Image uploads (food photos) +├── data/ # SQLite database +├── docker-compose.yaml # Docker setup (optional) +└── README.md +``` + +## Features + +### v1 (MVP) +- Manual food entry (name, description, image) +- Image upload support +- Notes field for accountability +- Daily view of entries + +### v2 +- Food search with macro lookup (OpenFoodFacts API) +- Auto-populate macros from search results +- Manual macro entry override + +### v3 +- Daily/weekly summary views +- Macro totals (protein, carbs, fat, calories) +- Charts and trends + +## Tech Stack + +- **Frontend:** React, Vite, Tailwind CSS +- **Backend:** Node.js, Express +- **Database:** SQLite (local) +- **Image Storage:** Local filesystem +- **Food API:** OpenFoodFacts (free, no key required) + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | /api/entries | Get all entries | +| POST | /api/entries | Create new entry | +| GET | /api/entries/:id | Get single entry | +| DELETE | /api/entries/:id | Delete entry | +| GET | /api/foods/search | Search food database | +| GET | /api/summary/daily | Get daily summary | + +## Running Locally + +```bash +# Install dependencies +cd client && npm install +cd ../server && npm install + +# Start backend +cd server && npm start + +# Start frontend (separate terminal) +cd client && npm run dev +``` + +## Environment Variables + +See `.env.example` for required configuration. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..fa67b58 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + Meal Tracker + + + +
+ + + \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..37a2802 --- /dev/null +++ b/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "meal-tracker-client", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.2.0", + "vite": "^5.0.0", + "tailwindcss": "^3.3.0" + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..2403127 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,25 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import Daily from './pages/Daily' +import AddEntry from './pages/AddEntry' +import EntryDetail from './pages/EntryDetail' + +export default function App() { + return ( + +
+
+
+

Meal Tracker

+
+
+
+ + } /> + } /> + } /> + +
+
+
+ ) +} \ No newline at end of file diff --git a/client/src/components/FoodSearch.jsx b/client/src/components/FoodSearch.jsx new file mode 100644 index 0000000..aacde7b --- /dev/null +++ b/client/src/components/FoodSearch.jsx @@ -0,0 +1,64 @@ +import { useState } from 'react' +import { searchFoods } from '../utils/api' + +export default function FoodSearch({ onSelect, onClose }) { + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + + const handleSearch = async () => { + if (!query.trim()) return + setLoading(true) + try { + const data = await searchFoods(query) + setResults(data) + } catch (err) { + console.error('Search failed', err) + } finally { + setLoading(false) + } + } + + const handleSelect = (item) => { + const product = item.product + onSelect({ + name: product.product_name || product.product_name_en || query, + calories: Math.round(product.nutriments?.['energy-kcal_100g'] || 0), + protein: Math.round(product.nutriments?.proteins_100g || 0), + carbs: Math.round(product.nutriments?.carbohydrates_100g || 0), + fat: Math.round(product.nutriments?.fat_100g || 0) + }) + } + + return ( +
+
+
+

Search Food

+ +
+ +
+ setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} placeholder="Search OpenFoodFacts..." className="flex-1 px-3 py-2 border rounded-lg" /> + +
+ +
+ {loading &&
Searching...
} + {!loading && results.length === 0 && query &&
No results found
} + {results.map(item => ( + + ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..4a10981 --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,5 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } \ No newline at end of file diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..54ebda4 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) \ No newline at end of file diff --git a/client/src/pages/AddEntry.jsx b/client/src/pages/AddEntry.jsx new file mode 100644 index 0000000..67efa61 --- /dev/null +++ b/client/src/pages/AddEntry.jsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { createEntry } from '../utils/api' +import FoodSearch from '../components/FoodSearch' + +export default function AddEntry() { + const navigate = useNavigate() + const [form, setForm] = useState({ name: '', description: '', notes: '', calories: '', protein: '', carbs: '', fat: '' }) + const [image, setImage] = useState(null) + const [preview, setPreview] = useState(null) + const [showSearch, setShowSearch] = useState(false) + const [saving, setSaving] = useState(false) + + const handleImage = (e) => { + const file = e.target.files[0] + if (file) { + setImage(file) + setPreview(URL.createObjectURL(file)) + } + } + + const handleSearchSelect = (food) => { + setForm(f => ({ ...f, name: food.name, calories: food.calories, protein: food.protein, carbs: food.carbs, fat: food.fat })) + setShowSearch(false) + } + + const handleSubmit = async (e) => { + e.preventDefault() + setSaving(true) + try { + const payload = new FormData() + payload.append('name', form.name) + payload.append('description', form.description) + payload.append('notes', form.notes) + payload.append('calories', form.calories || 0) + payload.append('protein', form.protein || 0) + payload.append('carbs', form.carbs || 0) + payload.append('fat', form.fat || 0) + if (image) payload.append('image', image) + await createEntry(payload) + navigate('/') + } catch (err) { + console.error('Failed to save', err) + alert('Failed to save entry') + } finally { + setSaving(false) + } + } + + return ( +
+
+
+ + setForm({...form, name: e.target.value})} className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent" required /> +
+ +
+ + setForm({...form, description: e.target.value})} className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent" /> +
+ +
+ + + {preview && Preview} +
+ +
+ +