Deploy 2026-06-15T16:17:09.309Z

This commit is contained in:
2026-06-15 17:17:09 +01:00
parent 3edfe36e0a
commit d338a71513
7 changed files with 426 additions and 13 deletions

View File

@@ -6,10 +6,24 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="theme-color" content="#1a1a2e" />
<link rel="manifest" href="/manifest.json" />
<title>Diet Coach</title>
</head>
<body>
<div id="root"></div>
<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>
</html>

11
public/manifest.json Normal file
View 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
View 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))
);
});

View File

@@ -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; }

View File

@@ -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 <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 (
<div className="app">
<header>
<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>
<div className="messages">
{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) => (
<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">{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 ref={messagesEnd} />
</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">
<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
ref={inputRef}
value={input}
@@ -97,7 +154,7 @@ export default function App() {
disabled={loading}
autoFocus
/>
<button type="submit" disabled={loading || !input.trim()}>Send</button>
<button type="submit" disabled={loading || (!input.trim() && !images.length)}>Send</button>
</form>
</div>
)

50
src/Dashboard.css Normal file
View 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
View 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>
)
}