v2: Full Family Life OS - Dashboard, Meal Planner, Projections, Recipes
This commit is contained in:
184
src/App.css
184
src/App.css
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
241
src/App.jsx
241
src/App.jsx
@@ -1,228 +1,35 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Dashboard } from './tabs/Dashboard.jsx'
|
||||
import { MealPlanner } from './tabs/MealPlanner.jsx'
|
||||
import { Projections } from './tabs/Projections.jsx'
|
||||
import { Recipes } from './tabs/Recipes.jsx'
|
||||
|
||||
const API = 'http://localhost:4003'
|
||||
|
||||
// Meal rotation: Mon=0, Sun=6
|
||||
const MEALS = [
|
||||
{ day: 'Monday', meal: 'Thai Red Curry', icon: '🍛', desc: 'Chicken breast (160g) + Thai Red sauce + boil-in-bag rice + broccoli', protein: 45, cals: 520 },
|
||||
{ day: 'Tuesday', meal: 'Rice Bowl', icon: '🍚', desc: 'Chicken breast (150g) + rice + broccoli + Nando\'s peri-peri', protein: 42, cals: 480 },
|
||||
{ day: 'Wednesday', meal: 'Chow Mein', icon: '🍜', desc: 'Chicken breast (200g) + noodles + chow mein sauce + red pepper + sesame oil', protein: 50, cals: 530 },
|
||||
{ day: 'Thursday', meal: 'Sweet & Sour', icon: '🍗', desc: 'Chicken breast (160g) + Ben\'s S&S sauce + boil-in-bag rice + broccoli', protein: 43, cals: 490 },
|
||||
{ day: 'Friday', meal: 'Curry (Takeaway)', icon: '🥘', desc: 'Half butter chicken + half pilau rice', protein: 30, cals: 650 },
|
||||
{ day: 'Saturday', meal: 'Curry (Takeaway)', icon: '🥘', desc: 'Half butter chicken + half pilau rice (+ half naan on run days)', protein: 30, cals: 650 },
|
||||
{ day: 'Sunday', meal: 'Meatballs', icon: '🍝', desc: 'SuperValu meatballs + spaghetti (60g) + pizza sauce + cheddar + broccoli', protein: 38, cals: 510 },
|
||||
const TABS = [
|
||||
{ id: 'dash', label: '📊', name: 'Today' },
|
||||
{ id: 'meals', label: '🍽️', name: 'Meals' },
|
||||
{ id: 'project', label: '📈', name: 'Goals' },
|
||||
{ id: 'recipes', label: '📖', name: 'Recipes' },
|
||||
]
|
||||
|
||||
const DAILY_CONSTANTS = { breakfast: { cals: 348, protein: 10, fat: 12, fibre: 9 }, shakes: { cals: 330, protein: 81, fat: 0, fibre: 0 } }
|
||||
const TARGETS = { cals: [1800, 2000], protein: [140, 160], fat: [55, 70], fibre: [28, 35], sodium: 2300 }
|
||||
|
||||
function useFetch(url) {
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true)
|
||||
fetch(url).then(r => r.json()).then(d => { setData(d); setLoading(false) }).catch(() => setLoading(false))
|
||||
}, [url])
|
||||
useEffect(() => { refresh() }, [refresh])
|
||||
return { data, loading, refresh }
|
||||
}
|
||||
|
||||
function MetricCard({ label, value, unit, target, icon, color }) {
|
||||
const pct = target ? Math.min((value / target) * 100, 100) : null
|
||||
const over = target && value > target
|
||||
return (
|
||||
<div className="metric-card">
|
||||
<div className="mc-top"><span className="mc-icon">{icon}</span><span className="mc-label">{label}</span></div>
|
||||
<div className="mc-value">{value}<small>{unit}</small></div>
|
||||
{pct !== null && (
|
||||
<div className="mc-bar"><div className="mc-fill" style={{ width: `${pct}%`, background: over ? '#ef4444' : color }} /></div>
|
||||
)}
|
||||
{target && <div className="mc-target" style={{ color: over ? '#ef4444' : '#94a3b8' }}>{over ? '⚠️ ' : ''}{value} / {target}{unit}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WeightChart({ data }) {
|
||||
if (!data || data.length === 0) return null
|
||||
const vals = data.map(d => d.value)
|
||||
const min = Math.min(...vals) - 1
|
||||
const max = Math.max(...vals) + 1
|
||||
const W = 300, H = 80, P = 5
|
||||
const pts = vals.map((v, i) => ({
|
||||
x: P + (i / (vals.length - 1 || 1)) * (W - P * 2),
|
||||
y: P + (1 - (v - min) / (max - min)) * (H - P * 2)
|
||||
}))
|
||||
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ')
|
||||
return (
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="weight-chart">
|
||||
<path d={line + `L${pts.at(-1).x},${H}L${pts[0].x},${H}Z`} fill="rgba(99,102,241,0.15)" />
|
||||
<path d={line} fill="none" stroke="#6366f1" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<circle cx={pts.at(-1).x} cy={pts.at(-1).y} r="4" fill="#6366f1" stroke="#0f172a" strokeWidth="2" />
|
||||
<text x={pts.at(-1).x - 8} y={pts.at(-1).y - 10} fill="#6366f1" fontSize="10" fontWeight="700">{vals.at(-1).toFixed(1)}</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TodaysMeal() {
|
||||
const dow = new Date().getDay()
|
||||
const idx = dow === 0 ? 6 : dow - 1
|
||||
const meal = MEALS[idx]
|
||||
return (
|
||||
<div className="meal-card">
|
||||
<div className="meal-header"><span className="meal-icon">{meal.icon}</span><div><h3>Tonight's Dinner</h3><p className="meal-day">{meal.day}'s rotation</p></div></div>
|
||||
<div className="meal-name">{meal.meal}</div>
|
||||
<div className="meal-desc">{meal.desc}</div>
|
||||
<div className="meal-macros"><span>~{meal.cals} cal</span><span>{meal.protein}g protein</span></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MacroBudget({ todayCals }) {
|
||||
const eaten = todayCals || 0
|
||||
const budget = TARGETS.cals[1]
|
||||
const remaining = Math.max(0, budget - eaten)
|
||||
const dinnerMeal = MEALS[new Date().getDay() === 0 ? 6 : new Date().getDay() - 1]
|
||||
|
||||
const alreadyPlanned = DAILY_CONSTANTS.breakfast.cals + DAILY_CONSTANTS.shakes.cals + dinnerMeal.cals
|
||||
const snackBudget = budget - alreadyPlanned
|
||||
|
||||
return (
|
||||
<div className="budget-card">
|
||||
<h3>📊 Today's Budget</h3>
|
||||
<div className="budget-rows">
|
||||
<div className="budget-row"><span>Breakfast (porridge+chia)</span><span>{DAILY_CONSTANTS.breakfast.cals} cal</span></div>
|
||||
<div className="budget-row"><span>3× NXR Shakes</span><span>{DAILY_CONSTANTS.shakes.cals} cal</span></div>
|
||||
<div className="budget-row"><span>Dinner ({dinnerMeal.meal})</span><span>~{dinnerMeal.cals} cal</span></div>
|
||||
<div className="budget-row highlight"><span>Snack allowance</span><span>~{snackBudget} cal</span></div>
|
||||
<div className="budget-row total"><span>Day total (planned)</span><span>~{alreadyPlanned + snackBudget} cal</span></div>
|
||||
</div>
|
||||
{eaten > 0 && <div className="budget-eaten">Logged so far: {eaten} cal • {remaining} remaining</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuickActions({ onSync }) {
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
const sync = async () => {
|
||||
setSyncing(true)
|
||||
try { await fetch(`${API}/fetch-loseit`, { method: 'POST', headers: { Authorization: 'my-health-data' } }) }
|
||||
catch {}
|
||||
setSyncing(false)
|
||||
onSync()
|
||||
}
|
||||
return (
|
||||
<div className="actions">
|
||||
<button onClick={sync} disabled={syncing}>{syncing ? '⏳ Syncing...' : '📧 Sync Lose It'}</button>
|
||||
<a href="https://diet-coach.psbailey.uk" target="_blank" rel="noreferrer"><button>📱 Diet Coach</button></a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const weekAgo = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10)
|
||||
|
||||
const weight = useFetch(`${API}/health-data?metric=weight_body_mass&from=${weekAgo}`)
|
||||
const bodyFat = useFetch(`${API}/health-data?metric=body_fat_percentage&from=${weekAgo}`)
|
||||
const hr = useFetch(`${API}/health-data?metric=resting_heart_rate&from=${weekAgo}`)
|
||||
const steps = useFetch(`${API}/health-data?metric=step_count&from=${today}`)
|
||||
const energy = useFetch(`${API}/health-data?metric=dietary_energy&from=${today}`)
|
||||
const protein = useFetch(`${API}/health-data?metric=protein&from=${today}`)
|
||||
|
||||
// Aggregate steps for today
|
||||
const todaySteps = steps.data?.data?.reduce((sum, d) => sum + (d.value || 0), 0) || 0
|
||||
// Aggregate cals (kJ → kcal)
|
||||
const todayCalsKj = energy.data?.data?.reduce((sum, d) => sum + (d.value || 0), 0) || 0
|
||||
const todayCals = Math.round(todayCalsKj / 4.184)
|
||||
// Aggregate protein
|
||||
const todayProtein = Math.round(protein.data?.data?.reduce((sum, d) => sum + (d.value || 0), 0) || 0)
|
||||
|
||||
// Latest weight
|
||||
const latestWeight = weight.data?.data?.[0]?.value?.toFixed(1) || '—'
|
||||
const latestBf = bodyFat.data?.data?.[0]?.value?.toFixed(1) || '—'
|
||||
const latestHr = hr.data?.data?.[0]?.value || '—'
|
||||
|
||||
// Weight chart data (dedupe by date, take first reading per day)
|
||||
const weightByDay = {}
|
||||
weight.data?.data?.forEach(d => {
|
||||
const day = d.date.slice(0, 10)
|
||||
if (!weightByDay[day]) weightByDay[day] = d.value
|
||||
})
|
||||
const weightChartData = Object.entries(weightByDay).sort().map(([date, value]) => ({ date, value }))
|
||||
|
||||
// Goal calc
|
||||
const paulStart = 114.9, paulTarget = 83.5
|
||||
const current = parseFloat(latestWeight) || 108.5
|
||||
const lost = paulStart - current
|
||||
const pct = ((lost / (paulStart - paulTarget)) * 100).toFixed(0)
|
||||
const daysIn = Math.floor((Date.now() - new Date('2026-04-27')) / 86400000)
|
||||
|
||||
const refreshAll = () => { weight.refresh(); energy.refresh(); protein.refresh(); steps.refresh() }
|
||||
const [tab, setTab] = useState('dash')
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header>
|
||||
<h1>🏠 Home Hub</h1>
|
||||
<p className="date">{new Date().toLocaleDateString('en-IE', { weekday: 'long', day: 'numeric', month: 'long' })} • Day {daysIn}</p>
|
||||
</header>
|
||||
|
||||
<QuickActions onSync={refreshAll} />
|
||||
|
||||
<section className="weight-section">
|
||||
<div className="weight-header">
|
||||
<div><h2>Weight Journey</h2><p className="weight-sub">{latestWeight}kg • {pct}% to goal • {(current - paulTarget).toFixed(1)}kg remaining</p></div>
|
||||
<div className="weight-badge">{latestBf}% BF</div>
|
||||
</div>
|
||||
<WeightChart data={weightChartData} />
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid">
|
||||
<MetricCard icon="👣" label="Steps" value={Math.round(todaySteps)} unit="" target={8000} color="#10b981" />
|
||||
<MetricCard icon="🔥" label="Calories" value={todayCals} unit=" cal" target={TARGETS.cals[1]} color="#f59e0b" />
|
||||
<MetricCard icon="🥩" label="Protein" value={todayProtein} unit="g" target={TARGETS.protein[1]} color="#6366f1" />
|
||||
<MetricCard icon="❤️" label="Resting HR" value={latestHr} unit=" bpm" color="#ef4444" />
|
||||
</section>
|
||||
|
||||
<TodaysMeal />
|
||||
<MacroBudget todayCals={todayCals} />
|
||||
|
||||
<section className="reminders">
|
||||
<h3>✅ Daily Checklist</h3>
|
||||
<Checklist />
|
||||
</section>
|
||||
|
||||
<footer>Home Hub • Refreshes from diet-coach-api (localhost:4003)</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const key = `checklist-${new Date().toISOString().slice(0, 10)}`
|
||||
const [checked, setChecked] = useState(() => JSON.parse(localStorage.getItem(key) || '{}'))
|
||||
const toggle = id => {
|
||||
const next = { ...checked, [id]: !checked[id] }
|
||||
setChecked(next)
|
||||
localStorage.setItem(key, JSON.stringify(next))
|
||||
}
|
||||
const items = [
|
||||
{ id: 'porridge', label: 'Breakfast: Porridge + chia + seeds' },
|
||||
{ id: 'shake1', label: 'NXR Shake #1 (+ creatine + psyllium)' },
|
||||
{ id: 'shake2', label: 'NXR Shake #2' },
|
||||
{ id: 'shake3', label: 'NXR Shake #3' },
|
||||
{ id: 'water', label: 'Water: 2L minimum' },
|
||||
{ id: 'steps', label: 'Steps: 8,000+ target' },
|
||||
{ id: 'veg', label: 'Broccoli / veg with dinner' },
|
||||
]
|
||||
const done = Object.values(checked).filter(Boolean).length
|
||||
return (
|
||||
<div className="checklist">
|
||||
<div className="cl-progress">{done}/{items.length} complete</div>
|
||||
{items.map(it => (
|
||||
<label key={it.id} className={`cl-item ${checked[it.id] ? 'done' : ''}`}>
|
||||
<input type="checkbox" checked={!!checked[it.id]} onChange={() => toggle(it.id)} />
|
||||
<span>{it.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="tab-content">
|
||||
{tab === 'dash' && <Dashboard />}
|
||||
{tab === 'meals' && <MealPlanner />}
|
||||
{tab === 'project' && <Projections />}
|
||||
{tab === 'recipes' && <Recipes />}
|
||||
</div>
|
||||
<nav className="tab-bar">
|
||||
{TABS.map(t => (
|
||||
<button key={t.id} className={`tab-btn ${tab === t.id ? 'active' : ''}`} onClick={() => setTab(t.id)}>
|
||||
<span className="tab-icon">{t.label}</span>
|
||||
<span className="tab-name">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,68 +0,0 @@
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#0f172a;--s:#1e293b;--s2:#334155;--t:#f8fafc;--tm:#94a3b8;--b:rgba(148,163,184,.12)}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--t);min-height:100vh;-webkit-font-smoothing:antialiased}
|
||||
.app{max-width:600px;margin:0 auto;padding:20px 16px 80px}
|
||||
|
||||
header{text-align:center;padding:24px 0 16px}
|
||||
header h1{font-size:1.6rem;font-weight:800}
|
||||
.date{font-size:.85rem;color:var(--tm);margin-top:4px}
|
||||
|
||||
.actions{display:flex;gap:10px;margin-bottom:18px}
|
||||
.actions button{flex:1;padding:12px;border:1px solid var(--b);border-radius:10px;background:var(--s);color:var(--t);font-size:.85rem;font-weight:600;cursor:pointer;transition:background .2s}
|
||||
.actions button:hover{background:var(--s2)}
|
||||
.actions button:disabled{opacity:.5}
|
||||
.actions a{flex:1;text-decoration:none}
|
||||
.actions a button{width:100%}
|
||||
|
||||
.weight-section{background:var(--s);border:1px solid var(--b);border-radius:14px;padding:18px;margin-bottom:16px}
|
||||
.weight-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:10px}
|
||||
.weight-header h2{font-size:1rem;font-weight:700}
|
||||
.weight-sub{font-size:.75rem;color:var(--tm);margin-top:2px}
|
||||
.weight-badge{background:rgba(99,102,241,.15);color:#a78bfa;font-size:.75rem;font-weight:700;padding:4px 10px;border-radius:8px}
|
||||
.weight-chart{width:100%;height:70px;display:block}
|
||||
|
||||
.metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
|
||||
.metric-card{background:var(--s);border:1px solid var(--b);border-radius:12px;padding:14px}
|
||||
.mc-top{display:flex;align-items:center;gap:6px;margin-bottom:6px}
|
||||
.mc-icon{font-size:1rem}
|
||||
.mc-label{font-size:.7rem;color:var(--tm);text-transform:uppercase;letter-spacing:.3px;font-weight:600}
|
||||
.mc-value{font-size:1.4rem;font-weight:800}
|
||||
.mc-value small{font-size:.7rem;font-weight:500;color:var(--tm)}
|
||||
.mc-bar{height:5px;background:var(--s2);border-radius:3px;margin-top:8px;overflow:hidden}
|
||||
.mc-fill{height:100%;border-radius:3px;transition:width .8s ease}
|
||||
.mc-target{font-size:.65rem;color:var(--tm);margin-top:4px}
|
||||
|
||||
.meal-card{background:var(--s);border:1px solid var(--b);border-radius:14px;padding:18px;margin-bottom:16px}
|
||||
.meal-header{display:flex;align-items:center;gap:12px;margin-bottom:10px}
|
||||
.meal-icon{font-size:2rem}
|
||||
.meal-header h3{font-size:.9rem;font-weight:700}
|
||||
.meal-day{font-size:.7rem;color:var(--tm)}
|
||||
.meal-name{font-size:1.2rem;font-weight:800;margin-bottom:6px}
|
||||
.meal-desc{font-size:.8rem;color:var(--tm);line-height:1.4;margin-bottom:8px}
|
||||
.meal-macros{display:flex;gap:12px;font-size:.75rem;font-weight:600;color:#10b981}
|
||||
|
||||
.budget-card{background:var(--s);border:1px solid var(--b);border-radius:14px;padding:18px;margin-bottom:16px}
|
||||
.budget-card h3{font-size:.9rem;margin-bottom:12px}
|
||||
.budget-rows{display:flex;flex-direction:column;gap:6px}
|
||||
.budget-row{display:flex;justify-content:space-between;font-size:.8rem;padding:6px 0;border-bottom:1px solid var(--b)}
|
||||
.budget-row:last-child{border:none}
|
||||
.budget-row span:first-child{color:var(--tm)}
|
||||
.budget-row span:last-child{font-weight:600}
|
||||
.budget-row.highlight{background:rgba(245,158,11,.08);margin:4px -8px;padding:8px;border-radius:8px;border:none}
|
||||
.budget-row.highlight span{color:#f59e0b}
|
||||
.budget-row.total{border:none;padding-top:10px}
|
||||
.budget-row.total span{font-weight:800}
|
||||
.budget-eaten{font-size:.75rem;color:var(--tm);margin-top:10px;text-align:center}
|
||||
|
||||
.reminders{background:var(--s);border:1px solid var(--b);border-radius:14px;padding:18px;margin-bottom:16px}
|
||||
.reminders h3{font-size:.9rem;margin-bottom:12px}
|
||||
.checklist{display:flex;flex-direction:column;gap:6px}
|
||||
.cl-progress{font-size:.7rem;color:var(--tm);margin-bottom:6px;font-weight:600}
|
||||
.cl-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:background .2s;font-size:.85rem}
|
||||
.cl-item:hover{background:var(--s2)}
|
||||
.cl-item.done span{text-decoration:line-through;color:var(--tm)}
|
||||
.cl-item input{width:18px;height:18px;accent-color:#6366f1}
|
||||
|
||||
footer{text-align:center;padding-top:16px;font-size:.7rem;color:var(--tm)}
|
||||
|
||||
@media(max-width:400px){.metrics-grid{grid-template-columns:1fr}}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>)
|
||||
|
||||
138
src/styles.css
Normal file
138
src/styles.css
Normal file
@@ -0,0 +1,138 @@
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
:root{--bg:#0f172a;--s:#1e293b;--s2:#334155;--t:#f8fafc;--tm:#94a3b8;--b:rgba(148,163,184,.1);--r:12px}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--t);min-height:100vh;-webkit-font-smoothing:antialiased;overscroll-behavior:none}
|
||||
.app{max-width:500px;margin:0 auto;padding-bottom:72px}
|
||||
.tab-content{padding:16px}
|
||||
.loading{text-align:center;padding:80px 0;color:var(--tm)}
|
||||
|
||||
/* Tab bar */
|
||||
.tab-bar{position:fixed;bottom:0;left:0;right:0;background:var(--s);border-top:1px solid var(--b);display:flex;justify-content:center;padding:8px 0 env(safe-area-inset-bottom,8px);z-index:100;backdrop-filter:blur(12px)}
|
||||
.tab-btn{flex:1;max-width:100px;display:flex;flex-direction:column;align-items:center;gap:2px;background:none;border:none;color:var(--tm);cursor:pointer;padding:6px}
|
||||
.tab-btn.active{color:#6366f1}
|
||||
.tab-icon{font-size:1.3rem}
|
||||
.tab-name{font-size:.6rem;font-weight:600;text-transform:uppercase;letter-spacing:.3px}
|
||||
|
||||
/* Dashboard */
|
||||
.dash-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}
|
||||
.dash-header h1{font-size:1.4rem;font-weight:800}
|
||||
.dash-date{font-size:.75rem;color:var(--tm);margin-top:2px}
|
||||
.sync-btn{width:40px;height:40px;border-radius:50%;background:var(--s);border:1px solid var(--b);font-size:1.1rem;cursor:pointer}
|
||||
.sync-btn:disabled{opacity:.5}
|
||||
|
||||
.vitals-row{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:18px}
|
||||
.vital{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:12px 8px;text-align:center}
|
||||
.vital-val{display:block;font-size:1.2rem;font-weight:800}
|
||||
.vital-val small{font-size:.6rem;color:var(--tm);font-weight:500}
|
||||
.vital-label{font-size:.6rem;color:var(--tm);text-transform:uppercase;letter-spacing:.3px}
|
||||
|
||||
.macros-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
|
||||
.macro-ring{text-align:center}
|
||||
.mr-ring-wrap{position:relative;width:54px;height:54px;margin:0 auto 4px}
|
||||
.ring{display:block}
|
||||
.mr-pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:700}
|
||||
.mr-val{font-size:.9rem;font-weight:800}
|
||||
.mr-val small{font-size:.55rem;color:var(--tm)}
|
||||
.mr-label{font-size:.55rem;color:var(--tm);text-transform:uppercase;letter-spacing:.2px}
|
||||
.mr-target{font-size:.55rem;color:var(--tm)}
|
||||
|
||||
.extras-row{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:18px}
|
||||
.extra{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:10px 12px;display:flex;align-items:center;gap:8px;font-size:.8rem;font-weight:600}
|
||||
.extra-icon{font-size:1rem}
|
||||
|
||||
.checklist-section{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px}
|
||||
.cl-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.cl-header h3{font-size:.9rem}
|
||||
.cl-count{font-size:.75rem;color:var(--tm);font-weight:700}
|
||||
.cl-item{display:flex;align-items:center;gap:10px;padding:10px;border-radius:8px;cursor:pointer;transition:background .15s}
|
||||
.cl-item:hover{background:var(--s2)}
|
||||
.cl-item.done span{text-decoration:line-through;opacity:.5}
|
||||
.cl-check{width:22px;height:22px;border-radius:6px;border:2px solid var(--s2);display:flex;align-items:center;justify-content:center;font-size:.7rem;color:#6366f1;font-weight:800;flex-shrink:0}
|
||||
.cl-check.checked{background:#6366f1;border-color:#6366f1;color:white}
|
||||
.cl-item span{font-size:.85rem}
|
||||
|
||||
/* Meal Planner */
|
||||
.meal-planner h1,.projections h1,.recipes h1{font-size:1.4rem;font-weight:800;margin-bottom:4px}
|
||||
.mp-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
|
||||
.mp-sub{font-size:.8rem;color:var(--tm);margin-bottom:16px}
|
||||
.shop-btn,.mp-back{background:var(--s);border:1px solid var(--b);border-radius:8px;padding:8px 14px;color:var(--t);font-size:.8rem;font-weight:600;cursor:pointer}
|
||||
.week-summary{font-size:.75rem;color:var(--tm);margin-bottom:12px;padding:8px 12px;background:var(--s);border-radius:8px}
|
||||
.week-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:6px;margin-bottom:16px}
|
||||
.day-card{background:var(--s);border:1px solid var(--b);border-radius:10px;padding:10px 6px;text-align:center;cursor:pointer;transition:all .2s}
|
||||
.day-card:hover,.day-card.selecting{border-color:#6366f1;transform:translateY(-2px)}
|
||||
.day-card.today{border-color:#f59e0b;box-shadow:0 0 12px rgba(245,158,11,.15)}
|
||||
.day-label{font-size:.6rem;font-weight:700;color:var(--tm);text-transform:uppercase;margin-bottom:4px}
|
||||
.day-icon{font-size:1.5rem;margin:4px 0}
|
||||
.day-meal{font-size:.6rem;font-weight:600;line-height:1.2;margin-bottom:2px}
|
||||
.day-macros{font-size:.5rem;color:var(--tm)}
|
||||
|
||||
.swap-panel{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:16px;margin-bottom:12px}
|
||||
.swap-panel h3{font-size:.85rem;margin-bottom:10px}
|
||||
.swap-options{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.swap-opt{display:flex;align-items:center;gap:8px;background:var(--s2);border:1px solid var(--b);border-radius:8px;padding:10px;cursor:pointer;color:var(--t);font-size:.8rem;font-weight:600}
|
||||
.swap-opt.current{border-color:#6366f1;background:rgba(99,102,241,.1)}
|
||||
.swap-opt small{color:var(--tm);font-size:.65rem;margin-left:auto}
|
||||
.reset-btn{width:100%;background:none;border:1px solid var(--b);border-radius:8px;padding:10px;color:var(--tm);font-size:.75rem;cursor:pointer}
|
||||
|
||||
.shop-list,.shop-extras{margin-bottom:16px}
|
||||
.shop-extras h4{font-size:.8rem;color:var(--tm);margin-bottom:8px}
|
||||
.shop-item{display:flex;justify-content:space-between;padding:8px 12px;background:var(--s);border:1px solid var(--b);border-radius:8px;margin-bottom:4px;font-size:.85rem}
|
||||
.shop-count{color:var(--tm);font-size:.75rem}
|
||||
|
||||
/* Projections */
|
||||
.proj-sub{font-size:.8rem;color:var(--tm);margin-bottom:16px}
|
||||
.proj-controls{margin-bottom:18px}
|
||||
.control-group{margin-bottom:14px}
|
||||
.control-group label{font-size:.75rem;font-weight:700;color:var(--tm);text-transform:uppercase;letter-spacing:.3px;display:block;margin-bottom:6px}
|
||||
.slider-row{display:flex;align-items:center;gap:12px}
|
||||
.slider-row input[type=range]{flex:1;accent-color:#6366f1;height:6px}
|
||||
.slider-val{font-size:1rem;font-weight:800;min-width:70px;text-align:right}
|
||||
.rate-note{font-size:.7rem;color:var(--tm);margin-top:4px}
|
||||
|
||||
.proj-summary{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:16px}
|
||||
.ps-item{background:var(--s);border:1px solid var(--b);border-radius:var(--r);padding:12px;text-align:center}
|
||||
.ps-val{display:block;font-size:1.1rem;font-weight:800}
|
||||
.ps-label{font-size:.6rem;color:var(--tm);text-transform:uppercase}
|
||||
|
||||
.proj-chart{margin-bottom:18px}
|
||||
.proj-chart svg{width:100%;height:120px;background:var(--s);border-radius:var(--r);display:block}
|
||||
|
||||
.milestones-list{margin-bottom:18px}
|
||||
.milestones-list h3{font-size:.9rem;margin-bottom:10px}
|
||||
.ms-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid var(--b);font-size:.85rem}
|
||||
.ms-row:last-child{border:none}
|
||||
.ms-icon{font-size:.85rem}
|
||||
.ms-name{flex:1;font-weight:600}
|
||||
.ms-row.reached .ms-name{color:var(--tm)}
|
||||
.ms-eta{font-size:.75rem;color:var(--tm)}
|
||||
|
||||
.scenarios h3{font-size:.9rem;margin-bottom:10px}
|
||||
.scenario-btns{display:flex;gap:6px}
|
||||
.scenario-btns button{flex:1;padding:10px;background:var(--s);border:1px solid var(--b);border-radius:8px;color:var(--t);font-size:.7rem;font-weight:600;cursor:pointer}
|
||||
.scenario-btns button.active{border-color:#6366f1;background:rgba(99,102,241,.1)}
|
||||
|
||||
/* Recipes */
|
||||
.recipe-filters{display:flex;gap:6px;margin-bottom:16px;overflow-x:auto}
|
||||
.filter-btn{padding:8px 14px;border-radius:20px;border:1px solid var(--b);background:var(--s);color:var(--tm);font-size:.75rem;font-weight:600;cursor:pointer;white-space:nowrap}
|
||||
.filter-btn.active{background:#6366f1;border-color:#6366f1;color:white}
|
||||
.recipe-list{display:flex;flex-direction:column;gap:8px}
|
||||
.recipe-card{background:var(--s);border:1px solid var(--b);border-radius:var(--r);overflow:hidden}
|
||||
.rc-header{display:flex;align-items:center;gap:12px;padding:14px;cursor:pointer}
|
||||
.rc-icon{font-size:1.5rem}
|
||||
.rc-info{flex:1}
|
||||
.rc-name{font-size:.9rem;font-weight:700}
|
||||
.rc-meta{font-size:.7rem;color:var(--tm)}
|
||||
.rc-arrow{color:var(--tm);font-size:.7rem}
|
||||
.rc-body{padding:0 14px 14px;border-top:1px solid var(--b)}
|
||||
.rc-macros-full{display:flex;flex-wrap:wrap;gap:8px;padding:12px 0;font-size:.75rem;font-weight:600}
|
||||
.rc-body h4{font-size:.8rem;color:var(--tm);margin:12px 0 6px;text-transform:uppercase;letter-spacing:.3px}
|
||||
.rc-ingredients{padding-left:16px;font-size:.85rem;line-height:1.8}
|
||||
.rc-method{padding-left:16px;font-size:.85rem;line-height:1.8}
|
||||
.rc-method li{margin-bottom:4px}
|
||||
.rc-serves{font-size:.7rem;color:var(--tm);margin-top:12px;font-style:italic}
|
||||
|
||||
@media(max-width:400px){
|
||||
.vitals-row{grid-template-columns:repeat(2,1fr)}
|
||||
.macros-row{grid-template-columns:repeat(2,1fr);gap:16px}
|
||||
.week-grid{grid-template-columns:repeat(4,1fr)}
|
||||
.proj-summary{grid-template-columns:repeat(2,1fr)}
|
||||
}
|
||||
156
src/tabs/Dashboard.jsx
Normal file
156
src/tabs/Dashboard.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
const API = 'http://localhost:4003'
|
||||
const TARGETS = { cals: 2000, protein: 160, fat: 70, fibre: 35, sodium: 2300, steps: 8000 }
|
||||
|
||||
function useLiveData() {
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const refresh = () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const week = new Date(Date.now() - 14 * 86400000).toISOString().slice(0, 10)
|
||||
Promise.all([
|
||||
fetch(`${API}/health-data?metric=weight_body_mass&from=${week}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=body_fat_percentage&from=${week}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=step_count&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=dietary_energy&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=protein&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=total_fat&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=fiber&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=sodium&from=${today}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=resting_heart_rate&from=${week}`).then(r => r.json()),
|
||||
fetch(`${API}/health-data?metric=active_energy&from=${today}`).then(r => r.json()),
|
||||
]).then(([wt, bf, steps, energy, prot, fat, fib, sod, hr, active]) => {
|
||||
setData({ wt, bf, steps, energy, prot, fat, fib, sod, hr, active })
|
||||
setLoading(false)
|
||||
}).catch(() => setLoading(false))
|
||||
}
|
||||
|
||||
useEffect(() => { refresh() }, [])
|
||||
return { data, loading, refresh }
|
||||
}
|
||||
|
||||
function Ring({ pct, size = 54, stroke = 5, color }) {
|
||||
const r = (size - stroke) / 2, c = 2 * Math.PI * r
|
||||
return (
|
||||
<svg width={size} height={size} className="ring">
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke="rgba(255,255,255,.06)" strokeWidth={stroke} />
|
||||
<circle cx={size/2} cy={size/2} r={r} fill="none" stroke={color} strokeWidth={stroke}
|
||||
strokeDasharray={c} strokeDashoffset={c - (Math.min(pct, 100) / 100) * c}
|
||||
strokeLinecap="round" transform={`rotate(-90 ${size/2} ${size/2})`} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MacroRing({ label, value, target, unit, color }) {
|
||||
const pct = target ? (value / target) * 100 : 0
|
||||
const over = value > target
|
||||
return (
|
||||
<div className="macro-ring">
|
||||
<div className="mr-ring-wrap">
|
||||
<Ring pct={pct} color={over ? '#ef4444' : color} />
|
||||
<span className="mr-pct">{Math.round(pct)}%</span>
|
||||
</div>
|
||||
<div className="mr-val">{value}<small>{unit}</small></div>
|
||||
<div className="mr-label">{label}</div>
|
||||
<div className="mr-target">/ {target}{unit}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const { data, loading, refresh } = useLiveData()
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
const sync = async () => {
|
||||
setSyncing(true)
|
||||
await fetch(`${API}/fetch-loseit`, { method: 'POST', headers: { Authorization: 'my-health-data' } }).catch(() => {})
|
||||
setTimeout(() => { refresh(); setSyncing(false) }, 2000)
|
||||
}
|
||||
|
||||
if (loading) return <div className="loading">Loading...</div>
|
||||
|
||||
const sum = (arr) => (arr || []).reduce((a, d) => a + (d.value || 0), 0)
|
||||
const latest = (obj) => obj?.data?.[0]?.value
|
||||
|
||||
const steps = Math.round(sum(data?.steps?.data))
|
||||
const calsKj = sum(data?.energy?.data)
|
||||
const cals = Math.round(calsKj / 4.184)
|
||||
const protein = Math.round(sum(data?.prot?.data))
|
||||
const fat = Math.round(sum(data?.fat?.data))
|
||||
const fibre = Math.round(sum(data?.fib?.data))
|
||||
const sodium = Math.round(sum(data?.sod?.data))
|
||||
const activeKj = sum(data?.active?.data)
|
||||
const activeCals = Math.round(activeKj / 4.184)
|
||||
const weight = latest(data?.wt)?.toFixed(1) || '—'
|
||||
const bf = latest(data?.bf)?.toFixed(1) || '—'
|
||||
const hr = latest(data?.hr) || '—'
|
||||
const netCals = cals - activeCals
|
||||
|
||||
const daysIn = Math.floor((Date.now() - new Date('2026-04-27')) / 86400000)
|
||||
const lost = (114.9 - parseFloat(weight || 114.9)).toFixed(1)
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dash-header">
|
||||
<div>
|
||||
<h1>Home Hub</h1>
|
||||
<p className="dash-date">{new Date().toLocaleDateString('en-IE', { weekday: 'long', day: 'numeric', month: 'long' })} • Day {daysIn}</p>
|
||||
</div>
|
||||
<button className="sync-btn" onClick={sync} disabled={syncing}>{syncing ? '⏳' : '🔄'}</button>
|
||||
</header>
|
||||
|
||||
<div className="vitals-row">
|
||||
<div className="vital"><span className="vital-val">{weight}<small>kg</small></span><span className="vital-label">Weight</span></div>
|
||||
<div className="vital"><span className="vital-val">{bf}<small>%</small></span><span className="vital-label">Body Fat</span></div>
|
||||
<div className="vital"><span className="vital-val">{lost}<small>kg</small></span><span className="vital-label">Lost</span></div>
|
||||
<div className="vital"><span className="vital-val">{hr}<small>bpm</small></span><span className="vital-label">RHR</span></div>
|
||||
</div>
|
||||
|
||||
<div className="macros-row">
|
||||
<MacroRing label="Calories" value={cals} target={TARGETS.cals} unit="" color="#f59e0b" />
|
||||
<MacroRing label="Protein" value={protein} target={TARGETS.protein} unit="g" color="#6366f1" />
|
||||
<MacroRing label="Fat" value={fat} target={TARGETS.fat} unit="g" color="#ec4899" />
|
||||
<MacroRing label="Fibre" value={fibre} target={TARGETS.fibre} unit="g" color="#10b981" />
|
||||
</div>
|
||||
|
||||
<div className="extras-row">
|
||||
<div className="extra"><span className="extra-icon">👣</span><span>{steps.toLocaleString()} / {TARGETS.steps.toLocaleString()}</span></div>
|
||||
<div className="extra"><span className="extra-icon">🔥</span><span>{activeCals} burned</span></div>
|
||||
<div className="extra"><span className="extra-icon">📉</span><span>Net: {netCals} cal</span></div>
|
||||
<div className="extra"><span className="extra-icon">🧂</span><span>{sodium} / {TARGETS.sodium}mg</span></div>
|
||||
</div>
|
||||
|
||||
<Checklist />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Checklist() {
|
||||
const key = `checklist-${new Date().toISOString().slice(0, 10)}`
|
||||
const [checked, setChecked] = useState(() => JSON.parse(localStorage.getItem(key) || '{}'))
|
||||
const toggle = id => { const n = { ...checked, [id]: !checked[id] }; setChecked(n); localStorage.setItem(key, JSON.stringify(n)) }
|
||||
const items = [
|
||||
{ id: 'bk', label: '🥣 Porridge + chia + seeds' },
|
||||
{ id: 's1', label: '🥤 NXR Shake #1 + creatine + psyllium' },
|
||||
{ id: 's2', label: '🥤 NXR Shake #2' },
|
||||
{ id: 's3', label: '🥤 NXR Shake #3' },
|
||||
{ id: 'wt', label: '💧 2L+ water' },
|
||||
{ id: 'vg', label: '🥦 Veg with dinner' },
|
||||
{ id: 'wk', label: '🚶 8000+ steps' },
|
||||
{ id: 'lg', label: '📝 Log all food in Lose It' },
|
||||
]
|
||||
const done = Object.values(checked).filter(Boolean).length
|
||||
return (
|
||||
<div className="checklist-section">
|
||||
<div className="cl-header"><h3>Daily Checklist</h3><span className="cl-count">{done}/{items.length}</span></div>
|
||||
{items.map(it => (
|
||||
<label key={it.id} className={`cl-item ${checked[it.id] ? 'done' : ''}`} onClick={() => toggle(it.id)}>
|
||||
<div className={`cl-check ${checked[it.id] ? 'checked' : ''}`}>{checked[it.id] ? '✓' : ''}</div>
|
||||
<span>{it.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
126
src/tabs/MealPlanner.jsx
Normal file
126
src/tabs/MealPlanner.jsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const ROTATION = [
|
||||
{ id: 'thai', name: 'Thai Red Curry', icon: '🍛', protein: 45, cals: 520, fat: 14, fibre: 6, ingredients: ['Chicken breast 160g', 'Blue Dragon Thai Red 120g', 'Boil-in-bag rice 62.5g', 'Broccoli 100g', 'Spray oil'] },
|
||||
{ id: 'rice', name: 'Rice Bowl', icon: '🍚', protein: 42, cals: 480, fat: 8, fibre: 5, ingredients: ['Chicken breast 150g', 'Rice 0.7 cup', 'Broccoli 100g', "Nando's hot peri-peri", 'Sesame oil 1 tsp'] },
|
||||
{ id: 'chow', name: 'Chow Mein', icon: '🍜', protein: 50, cals: 530, fat: 12, fibre: 4, ingredients: ['Chicken breast 200g', 'Noodles (Amoy pack)', 'Blue Dragon Chow Mein 60g', 'Red pepper 1', 'Sesame oil 1 tsp'] },
|
||||
{ id: 'sweet', name: 'Sweet & Sour', icon: '🍗', protein: 43, cals: 490, fat: 6, fibre: 5, ingredients: ["Chicken breast 160g", "Ben's S&S No Added Sugar 120g", 'Boil-in-bag rice 62.5g', 'Broccoli 100g'] },
|
||||
{ id: 'curry', name: 'Curry (Takeaway)', icon: '🥘', protein: 30, cals: 650, fat: 28, fibre: 3, ingredients: ['Half butter chicken', 'Half pilau rice', '(Optional: half peshwari naan on run days)'] },
|
||||
{ id: 'meatballs', name: 'Meatballs', icon: '🍝', protein: 38, cals: 510, fat: 18, fibre: 5, ingredients: ['SuperValu meatballs 2.5 srv', 'Spaghetti 60g', 'Pizza sauce 35g', 'Cheddar 10g', 'Broccoli 100g'] },
|
||||
{ id: 'satay', name: 'Satay', icon: '🥜', protein: 48, cals: 560, fat: 16, fibre: 5, ingredients: ['Chicken breast 160g', 'Noodles (Amoy pack)', 'Blue Dragon Satay 120g', 'Broccoli 100g'] },
|
||||
{ id: 'pizza', name: 'Pizza Night', icon: '🍕', protein: 35, cals: 580, fat: 22, fibre: 4, ingredients: ["Piero's base 105g", 'Mutti sauce', 'Pepperoni', 'Ham', 'Mozzarella/cheddar 68g'] },
|
||||
{ id: 'katsu', name: 'Katsu', icon: '🍱', protein: 44, cals: 500, fat: 10, fibre: 5, ingredients: ['Chicken breast 160g', 'Blue Dragon Katsu 120g', 'Noodles (Amoy 150g)', 'Broccoli 100g'] },
|
||||
]
|
||||
|
||||
const DEFAULT_WEEK = ['thai', 'rice', 'chow', 'sweet', 'curry', 'curry', 'meatballs']
|
||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
|
||||
export function MealPlanner() {
|
||||
const [week, setWeek] = useState(() => JSON.parse(localStorage.getItem('meal-week') || 'null') || DEFAULT_WEEK)
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [showShop, setShowShop] = useState(false)
|
||||
|
||||
const save = (w) => { setWeek(w); localStorage.setItem('meal-week', JSON.stringify(w)) }
|
||||
|
||||
const swap = (dayIdx, mealId) => {
|
||||
const next = [...week]
|
||||
next[dayIdx] = mealId
|
||||
save(next)
|
||||
setSelected(null)
|
||||
}
|
||||
|
||||
const getMeal = id => ROTATION.find(m => m.id === id)
|
||||
const today = new Date().getDay()
|
||||
const todayIdx = today === 0 ? 6 : today - 1
|
||||
|
||||
// Generate shopping list
|
||||
const ingredientCounts = {}
|
||||
week.forEach(id => {
|
||||
const meal = getMeal(id)
|
||||
if (meal) meal.ingredients.forEach(ing => { ingredientCounts[ing] = (ingredientCounts[ing] || 0) + 1 })
|
||||
})
|
||||
|
||||
// Weekly macro summary
|
||||
const weekMacros = week.reduce((acc, id) => {
|
||||
const m = getMeal(id)
|
||||
if (m) { acc.cals += m.cals; acc.protein += m.protein; acc.fat += m.fat }
|
||||
return acc
|
||||
}, { cals: 0, protein: 0, fat: 0 })
|
||||
|
||||
if (showShop) {
|
||||
return (
|
||||
<div className="meal-planner">
|
||||
<div className="mp-header">
|
||||
<h1>🛒 Shopping List</h1>
|
||||
<button className="mp-back" onClick={() => setShowShop(false)}>← Back</button>
|
||||
</div>
|
||||
<p className="mp-sub">Based on this week's plan</p>
|
||||
<div className="shop-list">
|
||||
{Object.entries(ingredientCounts).sort().map(([item, count]) => (
|
||||
<div key={item} className="shop-item">
|
||||
<span>{item}</span>
|
||||
{count > 1 && <span className="shop-count">×{count}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="shop-extras">
|
||||
<h4>Weekly Constants</h4>
|
||||
<div className="shop-item"><span>Flahavan's oats</span></div>
|
||||
<div className="shop-item"><span>Chia seeds</span></div>
|
||||
<div className="shop-item"><span>Sunflower seeds</span></div>
|
||||
<div className="shop-item"><span>Honey</span></div>
|
||||
<div className="shop-item"><span>NXR Beef Protein (21 scoops)</span></div>
|
||||
<div className="shop-item"><span>Psyllium husk</span></div>
|
||||
<div className="shop-item"><span>Creatine</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="meal-planner">
|
||||
<div className="mp-header">
|
||||
<h1>🍽️ Meal Planner</h1>
|
||||
<button className="shop-btn" onClick={() => setShowShop(true)}>🛒 List</button>
|
||||
</div>
|
||||
|
||||
<div className="week-summary">
|
||||
<span>Week totals (dinners): {weekMacros.cals} cal • {weekMacros.protein}g protein • {weekMacros.fat}g fat</span>
|
||||
</div>
|
||||
|
||||
<div className="week-grid">
|
||||
{DAYS.map((day, i) => {
|
||||
const meal = getMeal(week[i])
|
||||
const isToday = i === todayIdx
|
||||
return (
|
||||
<div key={day} className={`day-card ${isToday ? 'today' : ''} ${selected === i ? 'selecting' : ''}`}
|
||||
onClick={() => setSelected(selected === i ? null : i)}>
|
||||
<div className="day-label">{day}{isToday && ' ⭐'}</div>
|
||||
<div className="day-icon">{meal?.icon}</div>
|
||||
<div className="day-meal">{meal?.name}</div>
|
||||
<div className="day-macros">{meal?.cals} cal • {meal?.protein}g P</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selected !== null && (
|
||||
<div className="swap-panel">
|
||||
<h3>Swap {DAYS[selected]}'s meal:</h3>
|
||||
<div className="swap-options">
|
||||
{ROTATION.map(m => (
|
||||
<button key={m.id} className={`swap-opt ${week[selected] === m.id ? 'current' : ''}`}
|
||||
onClick={() => swap(selected, m.id)}>
|
||||
<span>{m.icon}</span>
|
||||
<span>{m.name}</span>
|
||||
<small>{m.cals} cal</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="reset-btn" onClick={() => save(DEFAULT_WEEK)}>Reset to default rotation</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
src/tabs/Projections.jsx
Normal file
125
src/tabs/Projections.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const START = 114.9
|
||||
const START_DATE = new Date('2026-04-27')
|
||||
const CURRENT = 108.4 // latest from API
|
||||
const TARGET = 83.5
|
||||
|
||||
export function Projections() {
|
||||
const [rate, setRate] = useState(0.8)
|
||||
const [customTarget, setCustomTarget] = useState(TARGET)
|
||||
|
||||
const daysIn = Math.floor((Date.now() - START_DATE) / 86400000)
|
||||
const lost = START - CURRENT
|
||||
const actualRate = lost / (daysIn / 7)
|
||||
const remaining = CURRENT - customTarget
|
||||
const weeksLeft = Math.ceil(remaining / rate)
|
||||
const goalDate = new Date(Date.now() + weeksLeft * 7 * 86400000)
|
||||
const pct = ((lost / (START - customTarget)) * 100).toFixed(0)
|
||||
|
||||
// Generate projection data points
|
||||
const projections = []
|
||||
let w = CURRENT
|
||||
for (let i = 0; i <= weeksLeft && i < 60; i++) {
|
||||
projections.push({ week: i, weight: Math.max(w, customTarget) })
|
||||
w -= rate
|
||||
}
|
||||
|
||||
// Milestones
|
||||
const milestones = [
|
||||
{ label: 'Under 105kg', target: 105 },
|
||||
{ label: 'Under 100kg 🎉', target: 100 },
|
||||
{ label: '20kg lost', target: START - 20 },
|
||||
{ label: 'Under 90kg', target: 90 },
|
||||
{ label: '25kg lost', target: START - 25 },
|
||||
{ label: 'Goal weight', target: customTarget },
|
||||
].map(m => {
|
||||
const kgToGo = CURRENT - m.target
|
||||
const weeks = kgToGo > 0 ? Math.ceil(kgToGo / rate) : 0
|
||||
const date = kgToGo > 0 ? new Date(Date.now() + weeks * 7 * 86400000) : null
|
||||
return { ...m, kgToGo, weeks, date, reached: CURRENT <= m.target }
|
||||
})
|
||||
|
||||
// Chart
|
||||
const W = 320, H = 140, P = 10
|
||||
const minW = customTarget - 2, maxW = START
|
||||
const rangeW = maxW - minW
|
||||
const pts = projections.map((p, i) => ({
|
||||
x: P + (i / (projections.length - 1 || 1)) * (W - P * 2),
|
||||
y: P + (1 - (p.weight - minW) / rangeW) * (H - P * 2)
|
||||
}))
|
||||
const line = pts.map((p, i) => `${i ? 'L' : 'M'}${p.x},${p.y}`).join(' ')
|
||||
const targetY = P + (1 - (customTarget - minW) / rangeW) * (H - P * 2)
|
||||
const currentY = P + (1 - (CURRENT - minW) / rangeW) * (H - P * 2)
|
||||
|
||||
return (
|
||||
<div className="projections">
|
||||
<h1>📈 Goal Projections</h1>
|
||||
<p className="proj-sub">Model your path to goal weight</p>
|
||||
|
||||
<div className="proj-controls">
|
||||
<div className="control-group">
|
||||
<label>Weekly loss rate</label>
|
||||
<div className="slider-row">
|
||||
<input type="range" min="0.3" max="1.5" step="0.1" value={rate} onChange={e => setRate(+e.target.value)} />
|
||||
<span className="slider-val">{rate} kg/wk</span>
|
||||
</div>
|
||||
<div className="rate-note">
|
||||
{rate < 0.5 ? '🐢 Conservative' : rate < 0.9 ? '👍 Sustainable' : rate < 1.2 ? '🔥 Aggressive' : '⚠️ Very aggressive'}
|
||||
{' • '}Actual rate: {actualRate.toFixed(2)} kg/wk
|
||||
</div>
|
||||
</div>
|
||||
<div className="control-group">
|
||||
<label>Target weight</label>
|
||||
<div className="slider-row">
|
||||
<input type="range" min="75" max="95" step="0.5" value={customTarget} onChange={e => setCustomTarget(+e.target.value)} />
|
||||
<span className="slider-val">{customTarget} kg</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proj-summary">
|
||||
<div className="ps-item"><span className="ps-val">{remaining.toFixed(1)}kg</span><span className="ps-label">to go</span></div>
|
||||
<div className="ps-item"><span className="ps-val">{weeksLeft}</span><span className="ps-label">weeks</span></div>
|
||||
<div className="ps-item"><span className="ps-val">{goalDate.toLocaleDateString('en-IE', { month: 'short', year: 'numeric' })}</span><span className="ps-label">ETA</span></div>
|
||||
<div className="ps-item"><span className="ps-val">{pct}%</span><span className="ps-label">complete</span></div>
|
||||
</div>
|
||||
|
||||
<div className="proj-chart">
|
||||
<svg viewBox={`0 0 ${W} ${H}`}>
|
||||
<line x1={P} y1={targetY} x2={W-P} y2={targetY} stroke="#10b981" strokeWidth="1.5" strokeDasharray="6 3" opacity="0.7" />
|
||||
<line x1={P} y1={currentY} x2={P+20} y2={currentY} stroke="#6366f1" strokeWidth="2" opacity="0.5" />
|
||||
<path d={line} fill="none" stroke="#6366f1" strokeWidth="2.5" strokeLinecap="round" />
|
||||
<circle cx={pts[0].x} cy={pts[0].y} r="4" fill="#6366f1" />
|
||||
<circle cx={pts.at(-1).x} cy={pts.at(-1).y} r="4" fill="#10b981" />
|
||||
<text x={W - P} y={targetY - 6} textAnchor="end" fill="#10b981" fontSize="9" fontWeight="700">{customTarget}kg</text>
|
||||
<text x={P} y={currentY - 6} fill="#6366f1" fontSize="9" fontWeight="700">{CURRENT}kg</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="milestones-list">
|
||||
<h3>📍 Milestone Estimates</h3>
|
||||
{milestones.map(m => (
|
||||
<div key={m.label} className={`ms-row ${m.reached ? 'reached' : ''}`}>
|
||||
<span className="ms-icon">{m.reached ? '✅' : '⏳'}</span>
|
||||
<span className="ms-name">{m.label}</span>
|
||||
<span className="ms-eta">
|
||||
{m.reached ? 'Done!' : m.date ? `${m.date.toLocaleDateString('en-IE', { day: 'numeric', month: 'short' })} (~${m.weeks} wks)` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="scenarios">
|
||||
<h3>⚡ Quick Scenarios</h3>
|
||||
<div className="scenario-btns">
|
||||
{[{ label: 'Conservative (0.5)', r: 0.5 }, { label: 'Current pace', r: +actualRate.toFixed(1) }, { label: 'Aggressive (1.0)', r: 1.0 }].map(s => (
|
||||
<button key={s.label} className={rate === s.r ? 'active' : ''} onClick={() => setRate(s.r)}>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
src/tabs/Recipes.jsx
Normal file
130
src/tabs/Recipes.jsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
const RECIPES = [
|
||||
{
|
||||
id: 'porridge', name: 'Morning Porridge', icon: '🥣', category: 'breakfast',
|
||||
servings: 1, time: '5 min',
|
||||
macros: { cals: 348, protein: 10, fat: 12, fibre: 9, carbs: 48 },
|
||||
ingredients: ['45g Flahavan\'s oats', '15g chia seeds', '10g sunflower seeds', '15g honey', 'Water/milk'],
|
||||
method: ['Add oats + water to bowl, microwave 2.5 min', 'Stir in chia seeds and sunflower seeds', 'Drizzle honey on top', 'Serve with tea']
|
||||
},
|
||||
{
|
||||
id: 'cake', name: 'Protein Drizzle Cake', icon: '🍰', category: 'baking',
|
||||
servings: 6, time: '35 min',
|
||||
macros: { cals: 144, protein: 9, fat: 3, fibre: 3, carbs: 19 },
|
||||
ingredients: ['90g oats', '30g NXR Strawberry protein', '2 eggs', '1 banana (~120g)', '100g strawberries', '15g chia seeds', '10g honey (batter)', '1 tsp vanilla', '20g honey + 1 tbsp warm water (drizzle)'],
|
||||
method: ['Preheat oven to 180°C. Line a loaf tin.', 'Blend oats, banana, eggs, 50g strawberries, 10g honey, vanilla until smooth.', 'Stir in NXR protein and chia seeds (don\'t blend).', 'Pour into tin. Lay sliced strawberries on top.', 'Bake 25–30 min until firm and golden.', 'Poke holes with skewer. Pour honey+water drizzle over.', 'Leave 10 min to soak, then cool on rack.']
|
||||
},
|
||||
{
|
||||
id: 'satay', name: 'Chicken Satay Noodles', icon: '🥜', category: 'dinner',
|
||||
servings: 1, time: '20 min',
|
||||
macros: { cals: 560, protein: 48, fat: 16, fibre: 5, carbs: 52 },
|
||||
ingredients: ['160g chicken breast', '1 Amoy noodle pack', '120g Blue Dragon Satay sauce', '100g broccoli', 'Spray oil'],
|
||||
method: ['Slice chicken, air-fry at 200°C for 12 min.', 'Cook noodles per pack instructions.', 'Steam/microwave broccoli 3 min.', 'Heat satay sauce in pan. Add chicken + noodles, toss.', 'Serve with broccoli on side.']
|
||||
},
|
||||
{
|
||||
id: 'chowmein', name: 'Chicken Chow Mein', icon: '🍜', category: 'dinner',
|
||||
servings: 1, time: '20 min',
|
||||
macros: { cals: 530, protein: 50, fat: 12, fibre: 4, carbs: 48 },
|
||||
ingredients: ['200g chicken breast', '1 Amoy noodle pack', '60g Blue Dragon Chow Mein sauce', '1 red pepper, sliced', '1 tsp sesame oil'],
|
||||
method: ['Slice chicken, air-fry at 200°C for 12 min.', 'Cook noodles. Slice pepper.', 'Heat sesame oil in wok, stir-fry pepper 2 min.', 'Add noodles, chicken, sauce. Toss 1 min.', 'Serve immediately.']
|
||||
},
|
||||
{
|
||||
id: 'thaicurry', name: 'Thai Red Chicken', icon: '🍛', category: 'dinner',
|
||||
servings: 1, time: '20 min',
|
||||
macros: { cals: 520, protein: 45, fat: 14, fibre: 6, carbs: 50 },
|
||||
ingredients: ['160g chicken breast', '120g Blue Dragon Thai Red sauce', '62.5g boil-in-bag rice (dry)', '100g broccoli', 'Spray oil'],
|
||||
method: ['Put rice on to boil (12 min).', 'Slice chicken, air-fry at 200°C for 12 min.', 'Steam broccoli 3 min.', 'Heat Thai Red sauce, add chicken, simmer 2 min.', 'Serve curry over rice with broccoli.']
|
||||
},
|
||||
{
|
||||
id: 'meatballs', name: 'Meatball Spaghetti', icon: '🍝', category: 'dinner',
|
||||
servings: 1, time: '15 min',
|
||||
macros: { cals: 510, protein: 38, fat: 18, fibre: 5, carbs: 48 },
|
||||
ingredients: ['SuperValu meatballs (2.5 srv)', '60g spaghetti', '35g pizza sauce', '10g cheddar, grated', '100g broccoli'],
|
||||
method: ['Cook spaghetti per pack (10 min).', 'Heat meatballs in oven/microwave.', 'Steam broccoli.', 'Drain pasta, top with meatballs + pizza sauce + cheddar.', 'Serve with broccoli on side.']
|
||||
},
|
||||
{
|
||||
id: 'katsu', name: 'Chicken Katsu Noodles', icon: '🍱', category: 'dinner',
|
||||
servings: 1, time: '20 min',
|
||||
macros: { cals: 500, protein: 44, fat: 10, fibre: 5, carbs: 50 },
|
||||
ingredients: ['160g chicken breast', '120g Blue Dragon Katsu sauce', '150g Amoy noodles', '100g broccoli'],
|
||||
method: ['Slice chicken, air-fry at 200°C for 12 min.', 'Cook noodles per pack.', 'Steam broccoli.', 'Heat katsu sauce, add chicken.', 'Serve over noodles with broccoli.']
|
||||
},
|
||||
{
|
||||
id: 'sweetnsour', name: 'Sweet & Sour Chicken', icon: '🍗', category: 'dinner',
|
||||
servings: 1, time: '20 min',
|
||||
macros: { cals: 490, protein: 43, fat: 6, fibre: 5, carbs: 55 },
|
||||
ingredients: ['160g chicken breast', "120g Ben's Original No Added Sugar Sweet & Sour", '62.5g boil-in-bag rice (dry)', '100g broccoli'],
|
||||
method: ['Put rice on to boil (12 min).', 'Slice chicken, air-fry at 200°C for 12 min.', 'Steam broccoli.', 'Heat sauce, add chicken, simmer 2 min.', 'Serve over rice with broccoli.']
|
||||
},
|
||||
{
|
||||
id: 'pizza', name: 'Pizza Night', icon: '🍕', category: 'dinner',
|
||||
servings: 1, time: '15 min',
|
||||
macros: { cals: 580, protein: 35, fat: 22, fibre: 4, carbs: 55 },
|
||||
ingredients: ["Piero's base 105g", 'Mutti pizza sauce', 'Pepperoni', 'Ham', 'Mozzarella/cheddar 68g'],
|
||||
method: ['Preheat oven to 220°C.', 'Spread Mutti sauce on base.', 'Top with pepperoni, ham, cheese.', 'Bake 10–12 min until cheese golden.', 'Snack: tuna + light mayo + carrot batons (swap Huel).']
|
||||
},
|
||||
{
|
||||
id: 'shake', name: 'NXR Protein Shake', icon: '🥤', category: 'snack',
|
||||
servings: 1, time: '1 min',
|
||||
macros: { cals: 110, protein: 27, fat: 0, fibre: 0, carbs: 0 },
|
||||
ingredients: ['30g NXR Beef Protein Isolate', '300ml cold water', '(Optional: 5g creatine)', '(Optional: 4g psyllium husk)'],
|
||||
method: ['Add water to shaker.', 'Add protein powder (+ creatine + psyllium for shake #1).', 'Shake 15 seconds.', 'Drink immediately.']
|
||||
},
|
||||
]
|
||||
|
||||
export function Recipes() {
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
|
||||
const categories = ['all', 'dinner', 'breakfast', 'snack', 'baking']
|
||||
const filtered = filter === 'all' ? RECIPES : RECIPES.filter(r => r.category === filter)
|
||||
|
||||
return (
|
||||
<div className="recipes">
|
||||
<h1>📖 Recipe Book</h1>
|
||||
<div className="recipe-filters">
|
||||
{categories.map(c => (
|
||||
<button key={c} className={`filter-btn ${filter === c ? 'active' : ''}`} onClick={() => setFilter(c)}>
|
||||
{c === 'all' ? 'All' : c[0].toUpperCase() + c.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="recipe-list">
|
||||
{filtered.map(r => (
|
||||
<div key={r.id} className={`recipe-card ${expanded === r.id ? 'open' : ''}`}>
|
||||
<div className="rc-header" onClick={() => setExpanded(expanded === r.id ? null : r.id)}>
|
||||
<span className="rc-icon">{r.icon}</span>
|
||||
<div className="rc-info">
|
||||
<div className="rc-name">{r.name}</div>
|
||||
<div className="rc-meta">{r.macros.cals} cal • {r.macros.protein}g protein • {r.time}</div>
|
||||
</div>
|
||||
<span className="rc-arrow">{expanded === r.id ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded === r.id && (
|
||||
<div className="rc-body">
|
||||
<div className="rc-macros-full">
|
||||
<span>🔥 {r.macros.cals} cal</span>
|
||||
<span>🥩 {r.macros.protein}g protein</span>
|
||||
<span>🧈 {r.macros.fat}g fat</span>
|
||||
<span>🌾 {r.macros.carbs}g carbs</span>
|
||||
<span>🌿 {r.macros.fibre}g fibre</span>
|
||||
</div>
|
||||
<h4>Ingredients</h4>
|
||||
<ul className="rc-ingredients">
|
||||
{r.ingredients.map((ing, i) => <li key={i}>{ing}</li>)}
|
||||
</ul>
|
||||
<h4>Method</h4>
|
||||
<ol className="rc-method">
|
||||
{r.method.map((step, i) => <li key={i}>{step}</li>)}
|
||||
</ol>
|
||||
{r.servings > 1 && <div className="rc-serves">Makes {r.servings} servings (macros shown per serving)</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user