Meal Tracker v1 - complete

This commit is contained in:
Otto
2026-03-30 13:34:53 -04:00
commit 139f756807
21 changed files with 938 additions and 0 deletions

13
client/index.html Normal file
View 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
View 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
View 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>
)
}

View 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">&times;</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
View 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
View 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>
)

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

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

View 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
View 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
View 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
View 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'
}
}
})