diff --git a/index.html b/index.html index ea6db62..6aec247 100644 --- a/index.html +++ b/index.html @@ -6,10 +6,24 @@ + Diet Coach
+ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..c811bb3 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Diet Coach", + "short_name": "Diet Coach", + "start_url": "/", + "display": "standalone", + "background_color": "#1a1a2e", + "theme_color": "#1a1a2e", + "icons": [ + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..a1eb0a5 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,21 @@ +const CACHE_NAME = 'diet-coach-v1'; + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then(keys => Promise.all( + keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)) + )).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', (e) => { + if (e.request.method !== 'GET' || e.request.url.includes('/api') || e.request.url.includes(':4003')) return; + e.respondWith( + fetch(e.request).then(res => { + const clone = res.clone(); + caches.open(CACHE_NAME).then(c => c.put(e.request, clone)); + return res; + }).catch(() => caches.match(e.request)) + ); +}); diff --git a/src/App.css b/src/App.css index f5c6549..e197577 100644 --- a/src/App.css +++ b/src/App.css @@ -29,9 +29,13 @@ body { header { padding: 12px 16px; border-bottom: 1px solid var(--surface); - text-align: center; + display: flex; + align-items: center; + justify-content: space-between; } +.header-btns { display: flex; gap: 8px; } + header h1 { font-size: 1.2rem; font-weight: 600; @@ -86,7 +90,19 @@ header h1 { background: var(--bg); } -.input-bar input { +.attach-btn { + padding: 12px; + border-radius: 50%; + border: none; + background: var(--surface); + font-size: 1.2rem; + cursor: pointer; +} + +.attach-btn:disabled { opacity: 0.4; } + +.input-bar input[type="text"], +.input-bar input:not([type]) { flex: 1; padding: 12px 16px; border-radius: 24px; @@ -101,7 +117,7 @@ header h1 { border-color: var(--primary); } -.input-bar button { +.input-bar button[type="submit"] { padding: 12px 20px; border-radius: 24px; border: none; @@ -112,11 +128,52 @@ header h1 { cursor: pointer; } -.input-bar button:disabled { +.input-bar button[type="submit"]:disabled { opacity: 0.4; cursor: not-allowed; } +.image-preview { + position: relative; + padding: 8px 16px; + border-top: 1px solid var(--surface); + display: flex; + gap: 8px; + overflow-x: auto; +} + +.preview-item { + position: relative; + flex-shrink: 0; +} + +.image-preview img { + max-height: 80px; + border-radius: 8px; +} + +.remove-img { + position: absolute; + top: -4px; + right: -4px; + background: rgba(0,0,0,0.7); + color: white; + border: none; + border-radius: 50%; + width: 20px; + height: 20px; + cursor: pointer; + font-size: 0.7rem; +} + +.msg-img { + max-width: 100%; + max-height: 200px; + border-radius: 8px; + margin-bottom: 6px; + display: block; +} + /* Markdown inside bubbles */ .msg.assistant .bubble h1, .msg.assistant .bubble h2, @@ -172,3 +229,6 @@ header h1 { border-radius: 3px; font-size: 0.85em; } + +.nav-btn { background: none; border: 1px solid #333; color: #7ec8e3; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; } +.nav-btn:hover { background: #1a1a2e; } diff --git a/src/App.jsx b/src/App.jsx index 1ac70f5..54a8673 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,6 +1,8 @@ import { useState, useRef, useEffect } from 'react' import { marked } from 'marked' +import Dashboard from './Dashboard' import './App.css' +import './Dashboard.css' marked.setOptions({ breaks: true, gfm: true }) @@ -9,30 +11,57 @@ const API_URL = window.location.hostname === 'localhost' : 'https://diet-api.psbailey.uk' export default function App() { + const [page, setPage] = useState('chat') const [messages, setMessages] = useState([]) const [input, setInput] = useState('') + const [images, setImages] = useState([]) const [loading, setLoading] = useState(false) + const [restarting, setRestarting] = useState(false) const messagesEnd = useRef(null) const inputRef = useRef(null) + const fileRef = useRef(null) useEffect(() => { messagesEnd.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages]) + function handleImage(e) { + const files = Array.from(e.target.files || []) + for (const file of files) { + const reader = new FileReader() + reader.onload = () => { + const base64 = reader.result.split(',')[1] + setImages(prev => [...prev, { base64, preview: reader.result, mime: file.type }]) + } + reader.readAsDataURL(file) + } + } + + function removeImage(idx) { + setImages(prev => prev.filter((_, i) => i !== idx)) + if (fileRef.current) fileRef.current.value = '' + } + async function send(e) { e.preventDefault() const text = input.trim() - if (!text || loading) return + if ((!text && !images.length) || loading) return setInput('') - setMessages(prev => [...prev, { role: 'user', content: text }]) + const userMsg = { role: 'user', content: text, images: images.map(i => i.preview) } + setMessages(prev => [...prev, userMsg]) setLoading(true) + const payload = { message: text || 'Analyse these images' } + if (images.length) payload.images = images.map(i => ({ base64: i.base64, mime: i.mime })) + setImages([]) + if (fileRef.current) fileRef.current.value = '' + try { const res = await fetch(`${API_URL}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: text }) + body: JSON.stringify(payload) }) if (!res.ok) throw new Error('Failed') @@ -47,12 +76,11 @@ export default function App() { const { done, value } = await reader.read() if (done) break const chunk = decoder.decode(value, { stream: true }) - // Parse SSE lines for (const line of chunk.split('\n')) { if (line.startsWith('data: ')) { const data = line.slice(6) if (data === '[DONE]') break - assistantMsg += data + assistantMsg += data.replace(/\\n/g, '\n') setMessages(prev => { const updated = [...prev] updated[updated.length - 1] = { role: 'assistant', content: assistantMsg } @@ -69,26 +97,55 @@ export default function App() { } } + if (page === 'dashboard') return setPage('chat')} /> + + async function restartApi() { + setRestarting(true) + try { + await fetch(`${API_URL}/restart`, { method: 'POST', headers: { Authorization: 'my-health-data' } }) + } catch {} + setTimeout(() => setRestarting(false), 5000) + } + return (

🥗 Diet Coach

+
+ + +
{messages.length === 0 && ( -
Ask me about your diet, log food, or get meal suggestions.
+
Ask me about your diet, log food, or send a screenshot of your Lose It report.
)} {messages.map((msg, i) => (
- {msg.role === 'assistant' + {msg.role === 'assistant' ?
- :
{msg.content}
+ :
+ {msg.images?.map((src, j) => )} + {msg.content} +
}
))}
+ {images.length > 0 && ( +
+ {images.map((img, i) => ( + + + + + ))} +
+ )}
+ + - +
) diff --git a/src/Dashboard.css b/src/Dashboard.css new file mode 100644 index 0000000..83670fe --- /dev/null +++ b/src/Dashboard.css @@ -0,0 +1,50 @@ +.dashboard { display: flex; flex-direction: column; height: 100vh; background: #1a1a2e; color: #e0e0e0; } +.dash-header { display: flex; align-items: center; gap: 1rem; padding: 1rem 1.5rem; background: #16213e; border-bottom: 1px solid #0f3460; } +.dash-header h1 { flex: 1; margin: 0; font-size: 1.3rem; } +.dash-header input[type="date"] { background: #0f3460; border: 1px solid #1a4080; color: #e0e0e0; padding: 0.4rem 0.8rem; border-radius: 6px; font-size: 0.9rem; } +.back-btn { background: none; border: 1px solid #1a4080; color: #7ec8e3; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; } +.back-btn:hover { background: #0f3460; } + +.dash-grid { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; gap: 1rem; padding: 1rem 1.5rem; overflow-y: auto; flex: 1; } +@media (max-width: 768px) { .dash-grid { grid-template-columns: 1fr; grid-template-rows: auto; } } + +.panel { background: #16213e; border-radius: 12px; padding: 1.2rem; border: 1px solid #0f3460; overflow-y: auto; } +.panel h2 { margin: 0 0 0.8rem; font-size: 1.1rem; color: #7ec8e3; } +.panel h3 { margin: 0.6rem 0 0.3rem; font-size: 0.85rem; color: #a0a0a0; text-transform: uppercase; letter-spacing: 0.5px; } + +/* Health stats */ +.health-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem; } +.stat { text-align: center; padding: 0.6rem; background: #0f3460; border-radius: 8px; } +.stat-val { display: block; font-size: 1.4rem; font-weight: 700; color: #fff; } +.stat-label { font-size: 0.75rem; color: #a0a0a0; } + +/* Macro bars */ +.macro-bars { display: flex; flex-direction: column; gap: 0.5rem; } +.macro-row { display: grid; grid-template-columns: 70px 1fr 65px; align-items: center; gap: 0.5rem; } +.macro-label { font-size: 0.8rem; color: #a0a0a0; } +.macro-bar-bg { height: 20px; background: #0a1628; border-radius: 10px; position: relative; overflow: hidden; } +.macro-bar-fill { height: 100%; border-radius: 10px; transition: width 0.4s; background: #e94560; } +.macro-bar-fill.good { background: #4caf50; } +.macro-bar-fill.over { background: #ff5722; } +.macro-target-zone { position: absolute; top: 0; height: 100%; background: rgba(76, 175, 80, 0.15); border-left: 1px dashed rgba(76,175,80,0.5); border-right: 1px dashed rgba(76,175,80,0.5); } +.macro-value { text-align: right; font-size: 0.8rem; font-weight: 600; } +.macro-value.good { color: #4caf50; } +.macro-value.over { color: #ff5722; } + +/* Meals */ +.meals-panel { overflow-y: auto; } +.meal-group { margin-bottom: 0.5rem; } +.meal-item { display: grid; grid-template-columns: 160px 1fr auto; gap: 0.5rem; align-items: baseline; padding: 0.3rem 0; border-bottom: 1px solid #0a1628; font-size: 0.82rem; } +.item-name { font-weight: 500; color: #e0e0e0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-desc { color: #808080; font-size: 0.75rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-cal { color: #7ec8e3; font-weight: 600; white-space: nowrap; text-align: right; } + +/* Suggestions */ +.suggest-btn { background: #0f3460; border: 1px solid #1a4080; color: #7ec8e3; padding: 0.6rem 1.2rem; border-radius: 8px; cursor: pointer; font-size: 0.9rem; width: 100%; } +.suggest-btn:hover:not(:disabled) { background: #1a4080; } +.suggest-btn:disabled { opacity: 0.5; cursor: not-allowed; } +.suggestions-content { margin-top: 0.8rem; font-size: 0.85rem; line-height: 1.5; } +.suggestions-content ul { padding-left: 1.2rem; } +.suggestions-content li { margin-bottom: 0.3rem; } + +.loading { color: #808080; font-style: italic; font-size: 0.85rem; } diff --git a/src/Dashboard.jsx b/src/Dashboard.jsx new file mode 100644 index 0000000..a2438d6 --- /dev/null +++ b/src/Dashboard.jsx @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react' +import { marked } from 'marked' + +const API_URL = window.location.hostname === 'localhost' + ? 'http://localhost:4003' + : 'https://diet-api.psbailey.uk' + +const TARGETS = { + calories: [1700, 1900], protein: [140, 160], fat: [45, 65], + carbs: [120, 160], fibre: [25, 35], sodium: [0, 2300], sugar: [0, 50] +} + +function today() { return new Date().toISOString().slice(0, 10) } + +export default function Dashboard({ onBack }) { + const [date, setDate] = useState(today()) + const [meals, setMeals] = useState(null) + const [health, setHealth] = useState(null) + const [suggestions, setSuggestions] = useState('') + const [loadingSuggestions, setLoadingSuggestions] = useState(false) + + useEffect(() => { fetchData() }, [date]) + + async function fetchData() { + const [mealRes, weightRes, bfRes, hrRes, hrvRes, stepsRes, energyRes] = await Promise.all([ + fetch(`${API_URL}/fatsecret/food-entries?date=${date}`).then(r => r.json()).catch(() => ({ entries: [] })), + fetch(`${API_URL}/health-data?metric=weight_body_mass&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + fetch(`${API_URL}/health-data?metric=body_fat_percentage&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + fetch(`${API_URL}/health-data?metric=resting_heart_rate&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + fetch(`${API_URL}/health-data?metric=heart_rate_variability&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + fetch(`${API_URL}/health-data?metric=step_count&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + fetch(`${API_URL}/health-data?metric=active_energy&from=${date}`).then(r => r.json()).catch(() => ({ data: [] })), + ]) + setMeals(mealRes.entries || []) + const forDate = (res) => (res.data || []).filter(d => d.date?.startsWith(date)) + const stepsForDay = forDate(stepsRes).filter(d => { + const hour = parseInt(d.date?.slice(11, 13) || '0') + return hour >= 5 + }) + setHealth({ + weight: forDate(weightRes)[0]?.value, + bodyFat: forDate(bfRes)[0]?.value, + hr: forDate(hrRes)[0]?.value, + hrv: forDate(hrvRes)[0]?.value, + steps: Math.round(stepsForDay.reduce((sum, d) => sum + (d.value || 0), 0)), + energy: forDate(energyRes).reduce((sum, d) => sum + (d.value || 0), 0), + }) + } + + function getTotals() { + if (!meals || !meals.length) return null + const t = { calories: 0, protein: 0, fat: 0, carbs: 0, fibre: 0, sodium: 0, sugar: 0 } + for (const e of meals) { + t.calories += +e.calories || 0 + t.protein += +e.protein || 0 + t.fat += +e.fat || 0 + t.carbs += +e.carbohydrate || 0 + t.fibre += +e.fiber || 0 + t.sodium += +e.sodium || 0 + t.sugar += +e.sugar || 0 + } + return t + } + + function getMealGroups() { + if (!meals) return {} + const groups = {} + for (const e of meals) { + const m = e.meal || 'Other' + if (!groups[m]) groups[m] = [] + groups[m].push(e) + } + return groups + } + + async function getSuggestions() { + const totals = getTotals() + if (!totals) return + setLoadingSuggestions(true) + setSuggestions('') + try { + const res = await fetch(`${API_URL}/dashboard-suggestions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ date, totals, targets: TARGETS }) + }) + const reader = res.body.getReader() + const decoder = new TextDecoder() + let text = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + for (const line of chunk.split('\n')) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data === '[DONE]') break + text += data.replace(/\\n/g, '\n') + setSuggestions(text) + } + } + } + } catch { setSuggestions('⚠️ Could not get suggestions.') } + finally { setLoadingSuggestions(false) } + } + + const totals = getTotals() + const mealGroups = getMealGroups() + + function MacroBar({ label, value, min, max, unit }) { + const pct = Math.min((value / max) * 100, 150) + const inRange = value >= min && value <= max + const over = value > max + return ( +
+ {label} +
+
+
+
+ + {Math.round(value)}{unit || 'g'} + +
+ ) + } + + return ( +
+
+ +

📊 Dashboard

+ setDate(e.target.value)} max={today()} /> +
+ +
+ {/* Health Panel */} +
+

❤️ Health

+ {health ? ( +
+ {health.weight &&
{health.weight.toFixed(1)}kg
} + {health.bodyFat &&
{health.bodyFat.toFixed(1)}% body fat
} + {health.hr &&
{Math.round(health.hr)}bpm rest
} + {health.hrv &&
{Math.round(health.hrv)}ms HRV
} + {health.steps > 0 &&
{Math.round(health.steps).toLocaleString()}steps
} + {health.energy > 0 &&
{Math.round(health.energy)}kcal burned
} +
+ ) :

Loading...

} +
+ + {/* Macros Panel */} +
+

🎯 Macros

+ {totals ? ( +
+ + + + + + + +
+ ) :

{meals === null ? 'Loading...' : 'No meals logged yet'}

} +
+ + {/* Meals Panel */} +
+

🍽️ Meals

+ {Object.keys(mealGroups).length > 0 ? ( + Object.entries(mealGroups).map(([meal, items]) => ( +
+

{meal}

+ {items.map((item, i) => ( +
+ {item.food_entry_name} + {(item.food_entry_description || '').replace(/:custom:\S*\s*/g, '')} + {item.calories} cal +
+ ))} +
+ )) + ) :

{meals === null ? 'Loading...' : 'No meals logged'}

} +
+ + {/* Suggestions Panel */} +
+

💡 Suggestions

+ + {suggestions && ( +
+ )} +
+
+
+ ) +}