Meal Tracker v1 - complete
This commit is contained in:
82
README.md
Normal file
82
README.md
Normal file
@@ -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.
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meal Tracker</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-100 min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
21
client/package.json
Normal file
21
client/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
25
client/src/App.jsx
Normal file
25
client/src/App.jsx
Normal file
@@ -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 (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<header className="bg-white shadow-sm sticky top-0 z-10">
|
||||
<div className="max-w-md mx-auto px-4 py-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-gray-800">Meal Tracker</h1>
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-md mx-auto px-4 py-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Daily />} />
|
||||
<Route path="/add" element={<AddEntry />} />
|
||||
<Route path="/entry/:id" element={<EntryDetail />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
64
client/src/components/FoodSearch.jsx
Normal file
64
client/src/components/FoodSearch.jsx
Normal file
@@ -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 (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg w-full max-w-md max-h-[80vh] flex flex-col">
|
||||
<div className="p-4 border-b flex items-center justify-between">
|
||||
<h2 className="font-semibold">Search Food</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b flex gap-2">
|
||||
<input type="text" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleSearch()} placeholder="Search OpenFoodFacts..." className="flex-1 px-3 py-2 border rounded-lg" />
|
||||
<button onClick={handleSearch} className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700">Search</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading && <div className="text-center py-4 text-gray-500">Searching...</div>}
|
||||
{!loading && results.length === 0 && query && <div className="text-center py-4 text-gray-500">No results found</div>}
|
||||
{results.map(item => (
|
||||
<button key={item.code} onClick={() => handleSelect(item)} className="w-full text-left p-3 hover:bg-gray-50 rounded-lg border-b last:border-b-0">
|
||||
<div className="font-medium">{item.product?.product_name || item.product?.product_name_en || 'Unknown'}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{item.product?.nutriments?.['energy-kcal_100g'] || '?'} kcal ·
|
||||
P: {item.product?.nutriments?.proteins_100g || '?'}g ·
|
||||
C: {item.product?.nutriments?.carbohydrates_100g || '?'}g ·
|
||||
F: {item.product?.nutriments?.fat_100g || '?'}g
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
client/src/index.css
Normal file
5
client/src/index.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
91
client/src/pages/AddEntry.jsx
Normal file
91
client/src/pages/AddEntry.jsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Food Name</label>
|
||||
<input type="text" value={form.name} onChange={e => 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 />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
|
||||
<input type="text" value={form.description} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Image</label>
|
||||
<input type="file" accept="image/*" onChange={handleImage} className="w-full" />
|
||||
{preview && <img src={preview} alt="Preview" className="mt-2 w-24 h-24 object-cover rounded-lg" />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||
<textarea value={form.notes} onChange={e => setForm({...form, notes: e.target.value})} className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent" rows={2} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={() => setShowSearch(true)} className="px-3 py-2 bg-gray-200 rounded-lg text-sm hover:bg-gray-300">Search Food</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div><label className="block text-xs text-gray-500 mb-1">Calories</label><input type="number" value={form.calories} onChange={e => setForm({...form, calories: e.target.value})} className="w-full px-3 py-2 border rounded-lg" /></div>
|
||||
<div><label className="block text-xs text-gray-500 mb-1">Protein (g)</label><input type="number" value={form.protein} onChange={e => setForm({...form, protein: e.target.value})} className="w-full px-3 py-2 border rounded-lg" /></div>
|
||||
<div><label className="block text-xs text-gray-500 mb-1">Carbs (g)</label><input type="number" value={form.carbs} onChange={e => setForm({...form, carbs: e.target.value})} className="w-full px-3 py-2 border rounded-lg" /></div>
|
||||
<div><label className="block text-xs text-gray-500 mb-1">Fat (g)</label><input type="number" value={form.fat} onChange={e => setForm({...form, fat: e.target.value})} className="w-full px-3 py-2 border rounded-lg" /></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={saving} className="w-full py-3 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50">{saving ? 'Saving...' : 'Save Entry'}</button>
|
||||
</form>
|
||||
|
||||
{showSearch && <FoodSearch onSelect={handleSearchSelect} onClose={() => setShowSearch(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
client/src/pages/Daily.jsx
Normal file
78
client/src/pages/Daily.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getEntries } from '../utils/api'
|
||||
|
||||
export default function Daily() {
|
||||
const [entries, setEntries] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries()
|
||||
}, [])
|
||||
|
||||
const loadEntries = async () => {
|
||||
try {
|
||||
const data = await getEntries()
|
||||
const todayEntries = data.filter(e => e.date?.startsWith(today) || e.createdAt?.startsWith(today))
|
||||
setEntries(todayEntries)
|
||||
} catch (err) {
|
||||
console.error('Failed to load entries', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const totalMacros = entries.reduce((acc, e) => ({
|
||||
calories: acc.calories + (e.calories || 0),
|
||||
protein: acc.protein + (e.protein || 0),
|
||||
carbs: acc.carbs + (e.carros || 0),
|
||||
fat: acc.fat + (e.fat || 0)
|
||||
}), { calories: 0, protein: 0, carbs: 0, fat: 0 })
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-gray-500">Loading...</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg p-4 mb-4 shadow-sm">
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-2">Today's Totals</h2>
|
||||
<div className="flex justify-between text-center">
|
||||
<div><div className="text-lg font-semibold">{totalMacros.calories}</div><div className="text-xs text-gray-500">cal</div></div>
|
||||
<div><div className="text-lg font-semibold">{totalMacros.protein}g</div><div className="text-xs text-gray-500">protein</div></div>
|
||||
<div><div className="text-lg font-semibold">{totalMacros.carbs}g</div><div className="text-xs text-gray-500">carbs</div></div>
|
||||
<div><div className="text-lg font-semibold">{totalMacros.fat}g</div><div className="text-xs text-gray-500">fat</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No meals logged today</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map(entry => (
|
||||
<Link key={entry._id || entry.id} to={`/entry/${entry._id || entry.id}`} className="block bg-white rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="flex gap-3">
|
||||
{entry.image && (
|
||||
<img src={entry.image} alt="" className="w-16 h-16 object-cover rounded-lg flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-medium text-gray-800 truncate">{entry.name}</h3>
|
||||
<p className="text-sm text-gray-500 truncate">{entry.description}</p>
|
||||
<div className="mt-1 flex gap-2 text-xs text-gray-400">
|
||||
<span>{entry.calories || 0} cal</span>
|
||||
<span>P: {entry.protein || 0}g</span>
|
||||
<span>C: {entry.carbs || 0}g</span>
|
||||
<span>F: {entry.fat || 0}g</span>
|
||||
</div>
|
||||
{entry.notes && <p className="text-xs text-gray-400 mt-1 truncate">{entry.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link to="/add" className="fixed bottom-6 right-6 bg-emerald-600 text-white w-14 h-14 rounded-full flex items-center justify-center shadow-lg hover:bg-emerald-700 transition-colors text-2xl">+</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
client/src/pages/EntryDetail.jsx
Normal file
73
client/src/pages/EntryDetail.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||
import { getEntries, deleteEntry } from '../utils/api'
|
||||
|
||||
export default function EntryDetail() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [entry, setEntry] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadEntry()
|
||||
}, [id])
|
||||
|
||||
const loadEntry = async () => {
|
||||
try {
|
||||
const entries = await getEntries()
|
||||
const found = entries.find(e => (e._id || e.id) === id)
|
||||
setEntry(found)
|
||||
} catch (err) {
|
||||
console.error('Failed to load entry', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this entry?')) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deleteEntry(id)
|
||||
navigate('/')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete', err)
|
||||
alert('Failed to delete')
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-center py-8 text-gray-500">Loading...</div>
|
||||
if (!entry) return <div className="text-center py-8 text-gray-500">Entry not found</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entry.image && <img src={entry.image} alt={entry.name} className="w-full h-64 object-cover rounded-lg mb-4" />}
|
||||
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm mb-4">
|
||||
<h1 className="text-xl font-semibold text-gray-800 mb-2">{entry.name}</h1>
|
||||
{entry.description && <p className="text-gray-600 mb-4">{entry.description}</p>}
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 text-center py-3 border-t">
|
||||
<div><div className="text-lg font-semibold">{entry.calories || 0}</div><div className="text-xs text-gray-500">cal</div></div>
|
||||
<div><div className="text-lg font-semibold">{entry.protein || 0}g</div><div className="text-xs text-gray-500">protein</div></div>
|
||||
<div><div className="text-lg font-semibold">{entry.carbs || 0}g</div><div className="text-xs text-gray-500">carbs</div></div>
|
||||
<div><div className="text-lg font-semibold">{entry.fat || 0}g</div><div className="text-xs text-gray-500">fat</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entry.notes && (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm mb-4">
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-2">Notes</h2>
|
||||
<p className="text-gray-700">{entry.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Link to="/" className="flex-1 py-3 bg-gray-200 text-center rounded-lg font-medium hover:bg-gray-300">Back</Link>
|
||||
<button onClick={handleDelete} disabled={deleting} className="flex-1 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50">{deleting ? 'Deleting...' : 'Delete'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
client/src/utils/api.js
Normal file
27
client/src/utils/api.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
export async function getEntries() {
|
||||
const { data } = await api.get('/entries')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function createEntry(formData) {
|
||||
const { data } = await api.post('/entries', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteEntry(id) {
|
||||
await api.delete(`/entries/${id}`)
|
||||
}
|
||||
|
||||
export async function searchFoods(query) {
|
||||
const { data } = await api.get(`/foods/search?q=${encodeURIComponent(query)}`)
|
||||
return data
|
||||
}
|
||||
11
client/tailwind.config.js
Normal file
11
client/tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
11
client/vite.config.js
Normal file
11
client/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3000'
|
||||
}
|
||||
}
|
||||
})
|
||||
3
server/.env
Normal file
3
server/.env
Normal file
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
UPLOAD_DIR=../uploads
|
||||
DB_PATH=../data/meal-tracker.db
|
||||
47
server/index.js
Normal file
47
server/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import db from './models/db.js';
|
||||
import entriesRouter from './routes/entries.js';
|
||||
import foodsRouter from './routes/foods.js';
|
||||
import summaryRouter from './routes/summary.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Serve uploaded files statically
|
||||
app.use('/uploads', express.static(path.join(__dirname, process.env.UPLOAD_DIR || '../uploads')));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/entries', entriesRouter);
|
||||
app.use('/api/foods', foodsRouter);
|
||||
app.use('/api/summary', summaryRouter);
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Initialize database and start server
|
||||
db.initialize()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Meal Tracker API running on port ${PORT}`);
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to initialize database:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
export default app;
|
||||
42
server/middleware/upload.js
Normal file
42
server/middleware/upload.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import multer from 'multer';
|
||||
import 'dotenv/config';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mime = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (ext && mime) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
};
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024
|
||||
}
|
||||
});
|
||||
|
||||
export default upload;
|
||||
119
server/models/db.js
Normal file
119
server/models/db.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import 'dotenv/config';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '../../data/meal-tracker.db');
|
||||
|
||||
let db;
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
db = new Database(DB_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function initialize() {
|
||||
const database = getDb();
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
image_url TEXT,
|
||||
notes TEXT,
|
||||
calories REAL DEFAULT 0,
|
||||
protein REAL DEFAULT 0,
|
||||
carbs REAL DEFAULT 0,
|
||||
fat REAL DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_created_at ON entries(created_at)
|
||||
`);
|
||||
|
||||
console.log('Database initialized');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
export function getAll(dateFilter = null) {
|
||||
const db = getDb();
|
||||
let query = 'SELECT * FROM entries';
|
||||
let params = [];
|
||||
|
||||
if (dateFilter) {
|
||||
query += ' WHERE date(created_at) = date(?)';
|
||||
params.push(dateFilter);
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
return params.length ? stmt.all(...params) : stmt.all();
|
||||
}
|
||||
|
||||
export function getById(id) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('SELECT * FROM entries WHERE id = ?');
|
||||
return stmt.get(id);
|
||||
}
|
||||
|
||||
export function create(entry) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO entries (name, description, image_url, notes, calories, protein, carbs, fat)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
entry.name,
|
||||
entry.description || null,
|
||||
entry.image_url || null,
|
||||
entry.notes || null,
|
||||
entry.calories || 0,
|
||||
entry.protein || 0,
|
||||
entry.carbs || 0,
|
||||
entry.fat || 0
|
||||
);
|
||||
|
||||
return { id: result.lastInsertRowid, ...entry };
|
||||
}
|
||||
|
||||
export function remove(id) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare('DELETE FROM entries WHERE id = ?');
|
||||
const result = stmt.run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getDailyTotals(date) {
|
||||
const db = getDb();
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
SUM(calories) as total_calories,
|
||||
SUM(protein) as total_protein,
|
||||
SUM(carbs) as total_carbs,
|
||||
SUM(fat) as total_fat,
|
||||
COUNT(*) as entry_count
|
||||
FROM entries
|
||||
WHERE date(created_at) = date(?)
|
||||
`);
|
||||
|
||||
const result = stmt.get(date);
|
||||
return {
|
||||
date,
|
||||
total_calories: result.total_calories || 0,
|
||||
total_protein: result.total_protein || 0,
|
||||
total_carbs: result.total_carbs || 0,
|
||||
total_fat: result.total_fat || 0,
|
||||
entry_count: result.entry_count || 0
|
||||
};
|
||||
}
|
||||
17
server/package.json
Normal file
17
server/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "meal-tracker-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.0",
|
||||
"cors": "^2.8.5",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"better-sqlite3": "^9.2.0",
|
||||
"axios": "^1.6.0",
|
||||
"dotenv": "^16.3.0"
|
||||
}
|
||||
}
|
||||
120
server/routes/entries.js
Normal file
120
server/routes/entries.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import express from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import 'dotenv/config';
|
||||
|
||||
import db from '../models/db.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '../../uploads');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const timestamp = Date.now();
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const ext = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mime = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (ext && mime) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'), false);
|
||||
}
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/entries
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const entries = db.getAll(date);
|
||||
res.json(entries);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/entries/:id
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const entry = db.getById(req.params.id);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
res.json(entry);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/entries
|
||||
router.post('/', upload.single('image'), (req, res) => {
|
||||
try {
|
||||
const { name, description, notes, calories, protein, carbs, fat } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Name is required' });
|
||||
}
|
||||
|
||||
const image_url = req.file ? `/uploads/${req.file.filename}` : null;
|
||||
|
||||
const entry = db.create({
|
||||
name,
|
||||
description,
|
||||
image_url,
|
||||
notes,
|
||||
calories: calories ? parseFloat(calories) : 0,
|
||||
protein: protein ? parseFloat(protein) : 0,
|
||||
carbs: carbs ? parseFloat(carbs) : 0,
|
||||
fat: fat ? parseFloat(fat) : 0
|
||||
});
|
||||
|
||||
res.status(201).json(entry);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/entries/:id
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const entry = db.getById(req.params.id);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Entry not found' });
|
||||
}
|
||||
|
||||
if (entry.image_url) {
|
||||
const imagePath = path.join(__dirname, '../../', entry.image_url);
|
||||
if (fs.existsSync(imagePath)) {
|
||||
fs.unlinkSync(imagePath);
|
||||
}
|
||||
}
|
||||
|
||||
db.remove(req.params.id);
|
||||
res.json({ message: 'Entry deleted' });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
53
server/routes/foods.js
Normal file
53
server/routes/foods.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import express from 'express';
|
||||
const router = express.Router();
|
||||
|
||||
const OPENFOODFACTS_API = 'https://world.openfoodfacts.org/cgi/search.pl';
|
||||
|
||||
// GET /api/foods/search?q=
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length < 2) {
|
||||
return res.status(400).json({ error: 'Search query must be at least 2 characters' });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
search_terms: q,
|
||||
search_simple: 1,
|
||||
action: 'process',
|
||||
json: 1,
|
||||
page_size: 20,
|
||||
fields: 'product_name,brands,nutriments,serving_size'
|
||||
});
|
||||
|
||||
const response = await fetch(`${OPENFOODFACTS_API}?${params}`, {
|
||||
headers: {
|
||||
'User-Agent': 'MealTracker/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenFoodFacts API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const foods = (data.products || []).map(product => ({
|
||||
name: product.product_name || 'Unknown',
|
||||
brand: product.brands || null,
|
||||
calories: product.nutriments?.['energy-kcal_100g'] ?? product.nutriments?.['energy-kcal'] ?? 0,
|
||||
protein: product.nutriments?.proteins_100g ?? product.nutriments?.proteins ?? 0,
|
||||
carbs: product.nutriments?.carbohydrates_100g ?? product.nutriments?.carbohydrates ?? 0,
|
||||
fat: product.nutriments?.fat_100g ?? product.nutriments?.fat ?? 0,
|
||||
serving_size: product.serving_size || '100g'
|
||||
})).filter(f => f.name !== 'Unknown');
|
||||
|
||||
res.json(foods);
|
||||
} catch (err) {
|
||||
console.error('Food search error:', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
26
server/routes/summary.js
Normal file
26
server/routes/summary.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import express from 'express';
|
||||
import db from '../models/db.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/summary/daily?date=YYYY-MM-DD
|
||||
router.get('/daily', (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date parameter is required (YYYY-MM-DD)' });
|
||||
}
|
||||
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({ error: 'Invalid date format. Use YYYY-MM-DD' });
|
||||
}
|
||||
|
||||
const summary = db.getDailyTotals(date);
|
||||
res.json(summary);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user