Deploy 2026-06-15T16:17:09.309Z
This commit is contained in:
14
index.html
14
index.html
@@ -6,10 +6,24 @@
|
|||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="theme-color" content="#1a1a2e" />
|
<meta name="theme-color" content="#1a1a2e" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>Diet Coach</title>
|
<title>Diet Coach</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').then(reg => {
|
||||||
|
reg.addEventListener('updatefound', () => {
|
||||||
|
const sw = reg.installing;
|
||||||
|
sw.addEventListener('statechange', () => {
|
||||||
|
if (sw.state === 'activated') window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setInterval(() => reg.update(), 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
11
public/manifest.json
Normal file
11
public/manifest.json
Normal file
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
public/sw.js
Normal file
21
public/sw.js
Normal file
@@ -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))
|
||||||
|
);
|
||||||
|
});
|
||||||
68
src/App.css
68
src/App.css
@@ -29,9 +29,13 @@ body {
|
|||||||
header {
|
header {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--surface);
|
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 {
|
header h1 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -86,7 +90,19 @@ header h1 {
|
|||||||
background: var(--bg);
|
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;
|
flex: 1;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
@@ -101,7 +117,7 @@ header h1 {
|
|||||||
border-color: var(--primary);
|
border-color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-bar button {
|
.input-bar button[type="submit"] {
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -112,11 +128,52 @@ header h1 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-bar button:disabled {
|
.input-bar button[type="submit"]:disabled {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
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 */
|
/* Markdown inside bubbles */
|
||||||
.msg.assistant .bubble h1,
|
.msg.assistant .bubble h1,
|
||||||
.msg.assistant .bubble h2,
|
.msg.assistant .bubble h2,
|
||||||
@@ -172,3 +229,6 @@ header h1 {
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.85em;
|
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; }
|
||||||
|
|||||||
73
src/App.jsx
73
src/App.jsx
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
|
import Dashboard from './Dashboard'
|
||||||
import './App.css'
|
import './App.css'
|
||||||
|
import './Dashboard.css'
|
||||||
|
|
||||||
marked.setOptions({ breaks: true, gfm: true })
|
marked.setOptions({ breaks: true, gfm: true })
|
||||||
|
|
||||||
@@ -9,30 +11,57 @@ const API_URL = window.location.hostname === 'localhost'
|
|||||||
: 'https://diet-api.psbailey.uk'
|
: 'https://diet-api.psbailey.uk'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [page, setPage] = useState('chat')
|
||||||
const [messages, setMessages] = useState([])
|
const [messages, setMessages] = useState([])
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
|
const [images, setImages] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [restarting, setRestarting] = useState(false)
|
||||||
const messagesEnd = useRef(null)
|
const messagesEnd = useRef(null)
|
||||||
const inputRef = useRef(null)
|
const inputRef = useRef(null)
|
||||||
|
const fileRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
|
||||||
}, [messages])
|
}, [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) {
|
async function send(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
if (!text || loading) return
|
if ((!text && !images.length) || loading) return
|
||||||
|
|
||||||
setInput('')
|
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)
|
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 {
|
try {
|
||||||
const res = await fetch(`${API_URL}/chat`, {
|
const res = await fetch(`${API_URL}/chat`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message: text })
|
body: JSON.stringify(payload)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed')
|
if (!res.ok) throw new Error('Failed')
|
||||||
@@ -47,12 +76,11 @@ export default function App() {
|
|||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
if (done) break
|
if (done) break
|
||||||
const chunk = decoder.decode(value, { stream: true })
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
// Parse SSE lines
|
|
||||||
for (const line of chunk.split('\n')) {
|
for (const line of chunk.split('\n')) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
const data = line.slice(6)
|
const data = line.slice(6)
|
||||||
if (data === '[DONE]') break
|
if (data === '[DONE]') break
|
||||||
assistantMsg += data
|
assistantMsg += data.replace(/\\n/g, '\n')
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
const updated = [...prev]
|
const updated = [...prev]
|
||||||
updated[updated.length - 1] = { role: 'assistant', content: assistantMsg }
|
updated[updated.length - 1] = { role: 'assistant', content: assistantMsg }
|
||||||
@@ -69,26 +97,55 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (page === 'dashboard') return <Dashboard onBack={() => 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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>🥗 Diet Coach</h1>
|
<h1>🥗 Diet Coach</h1>
|
||||||
|
<div className="header-btns">
|
||||||
|
<button onClick={restartApi} className="nav-btn" disabled={restarting}>{restarting ? '⏳' : '🔄'} API</button>
|
||||||
|
<button onClick={() => setPage('dashboard')} className="nav-btn">📊 Dashboard</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="messages">
|
<div className="messages">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="empty">Ask me about your diet, log food, or get meal suggestions.</div>
|
<div className="empty">Ask me about your diet, log food, or send a screenshot of your Lose It report.</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
<div key={i} className={`msg ${msg.role}`}>
|
<div key={i} className={`msg ${msg.role}`}>
|
||||||
{msg.role === 'assistant'
|
{msg.role === 'assistant'
|
||||||
? <div className="bubble" dangerouslySetInnerHTML={{ __html: marked.parse(msg.content || '...') }} />
|
? <div className="bubble" dangerouslySetInnerHTML={{ __html: marked.parse(msg.content || '...') }} />
|
||||||
: <div className="bubble">{msg.content}</div>
|
: <div className="bubble">
|
||||||
|
{msg.images?.map((src, j) => <img key={j} src={src} className="msg-img" alt="" />)}
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEnd} />
|
<div ref={messagesEnd} />
|
||||||
</div>
|
</div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="image-preview">
|
||||||
|
{images.map((img, i) => (
|
||||||
|
<span key={i} className="preview-item">
|
||||||
|
<img src={img.preview} alt="" />
|
||||||
|
<button onClick={() => removeImage(i)} className="remove-img">✕</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<form onSubmit={send} className="input-bar">
|
<form onSubmit={send} className="input-bar">
|
||||||
|
<button type="button" className="attach-btn" onClick={() => fileRef.current?.click()} disabled={loading}>📷</button>
|
||||||
|
<input type="file" ref={fileRef} accept="image/*" multiple onChange={handleImage} hidden />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
@@ -97,7 +154,7 @@ export default function App() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={loading || !input.trim()}>Send</button>
|
<button type="submit" disabled={loading || (!input.trim() && !images.length)}>Send</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
50
src/Dashboard.css
Normal file
50
src/Dashboard.css
Normal file
@@ -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; }
|
||||||
200
src/Dashboard.jsx
Normal file
200
src/Dashboard.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="macro-row">
|
||||||
|
<span className="macro-label">{label}</span>
|
||||||
|
<div className="macro-bar-bg">
|
||||||
|
<div className={`macro-bar-fill ${over ? 'over' : inRange ? 'good' : ''}`} style={{ width: `${Math.min(pct, 100)}%` }} />
|
||||||
|
<div className="macro-target-zone" style={{ left: `${(min/max)*100}%`, width: `${((max-min)/max)*100}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`macro-value ${over ? 'over' : inRange ? 'good' : ''}`}>
|
||||||
|
{Math.round(value)}{unit || 'g'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dashboard">
|
||||||
|
<header className="dash-header">
|
||||||
|
<button onClick={onBack} className="back-btn">← Chat</button>
|
||||||
|
<h1>📊 Dashboard</h1>
|
||||||
|
<input type="date" value={date} onChange={e => setDate(e.target.value)} max={today()} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="dash-grid">
|
||||||
|
{/* Health Panel */}
|
||||||
|
<section className="panel health-panel">
|
||||||
|
<h2>❤️ Health</h2>
|
||||||
|
{health ? (
|
||||||
|
<div className="health-stats">
|
||||||
|
{health.weight && <div className="stat"><span className="stat-val">{health.weight.toFixed(1)}</span><span className="stat-label">kg</span></div>}
|
||||||
|
{health.bodyFat && <div className="stat"><span className="stat-val">{health.bodyFat.toFixed(1)}</span><span className="stat-label">% body fat</span></div>}
|
||||||
|
{health.hr && <div className="stat"><span className="stat-val">{Math.round(health.hr)}</span><span className="stat-label">bpm rest</span></div>}
|
||||||
|
{health.hrv && <div className="stat"><span className="stat-val">{Math.round(health.hrv)}</span><span className="stat-label">ms HRV</span></div>}
|
||||||
|
{health.steps > 0 && <div className="stat"><span className="stat-val">{Math.round(health.steps).toLocaleString()}</span><span className="stat-label">steps</span></div>}
|
||||||
|
{health.energy > 0 && <div className="stat"><span className="stat-val">{Math.round(health.energy)}</span><span className="stat-label">kcal burned</span></div>}
|
||||||
|
</div>
|
||||||
|
) : <p className="loading">Loading...</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Macros Panel */}
|
||||||
|
<section className="panel macros-panel">
|
||||||
|
<h2>🎯 Macros</h2>
|
||||||
|
{totals ? (
|
||||||
|
<div className="macro-bars">
|
||||||
|
<MacroBar label="Calories" value={totals.calories} min={TARGETS.calories[0]} max={TARGETS.calories[1]} unit="kcal" />
|
||||||
|
<MacroBar label="Protein" value={totals.protein} min={TARGETS.protein[0]} max={TARGETS.protein[1]} />
|
||||||
|
<MacroBar label="Fat" value={totals.fat} min={TARGETS.fat[0]} max={TARGETS.fat[1]} />
|
||||||
|
<MacroBar label="Carbs" value={totals.carbs} min={TARGETS.carbs[0]} max={TARGETS.carbs[1]} />
|
||||||
|
<MacroBar label="Fibre" value={totals.fibre} min={TARGETS.fibre[0]} max={TARGETS.fibre[1]} />
|
||||||
|
<MacroBar label="Sodium" value={totals.sodium} min={TARGETS.sodium[0]} max={TARGETS.sodium[1]} unit="mg" />
|
||||||
|
<MacroBar label="Sugar" value={totals.sugar} min={TARGETS.sugar[0]} max={TARGETS.sugar[1]} />
|
||||||
|
</div>
|
||||||
|
) : <p className="loading">{meals === null ? 'Loading...' : 'No meals logged yet'}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Meals Panel */}
|
||||||
|
<section className="panel meals-panel">
|
||||||
|
<h2>🍽️ Meals</h2>
|
||||||
|
{Object.keys(mealGroups).length > 0 ? (
|
||||||
|
Object.entries(mealGroups).map(([meal, items]) => (
|
||||||
|
<div key={meal} className="meal-group">
|
||||||
|
<h3>{meal}</h3>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="meal-item">
|
||||||
|
<span className="item-name">{item.food_entry_name}</span>
|
||||||
|
<span className="item-desc">{(item.food_entry_description || '').replace(/:custom:\S*\s*/g, '')}</span>
|
||||||
|
<span className="item-cal">{item.calories} cal</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : <p className="loading">{meals === null ? 'Loading...' : 'No meals logged'}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Suggestions Panel */}
|
||||||
|
<section className="panel suggestions-panel">
|
||||||
|
<h2>💡 Suggestions</h2>
|
||||||
|
<button onClick={getSuggestions} disabled={loadingSuggestions || !totals} className="suggest-btn">
|
||||||
|
{loadingSuggestions ? 'Thinking...' : 'What should I eat next?'}
|
||||||
|
</button>
|
||||||
|
{suggestions && (
|
||||||
|
<div className="suggestions-content" dangerouslySetInnerHTML={{ __html: marked.parse(suggestions) }} />
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user