Deliver Step 9: agent-card.json, SKILL.md, USDA lookup, integrations dynamic config, Welcome/Integrations/MealPlan pages, API methods, README, routes

This commit is contained in:
Claude 2026-06-15 14:40:34 +00:00
parent 6fb5d759ef
commit c133a463ec
10 changed files with 751 additions and 33 deletions

View file

@ -10,6 +10,9 @@ import Rewards from './pages/Rewards'
import Settings from './pages/Settings'
import Onboarding from './pages/Onboarding'
import WeekView from './pages/WeekView'
import Welcome from './pages/Welcome'
import SettingsIntegrations from './pages/SettingsIntegrations'
import MealPlan from './pages/MealPlan'
import ProgramSearch from './pages/ProgramSearch'
import ProgramDetail from './pages/ProgramDetail'
import CatalogDetail from './pages/CatalogDetail'

View file

@ -122,6 +122,23 @@ export const api = {
polarSync: () => request('/integrations/polar/sync', { method: 'POST' }),
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
// Meal Plans (v3)
listMealPlans: () => request('/meal-plans'),
createMealPlan: (data) => request('/meal-plans', { method: 'POST', body: JSON.stringify(data) }),
getMealPlanToday: () => request('/meal-plans/today'),
getMealPlan: (id) => request('/meal-plans/' + id),
updateMealPlanStatus: (id, status) =>
request('/meal-plans/' + id, { method: 'PATCH', body: JSON.stringify({ status }) }),
logMealCompliance: (data) =>
request('/meal-plans/compliance', { method: 'POST', body: JSON.stringify(data) }),
getMealPlanProgress: (id) => request('/meal-plans/' + id + '/progress'),
// Dynamic Integrations (v3)
fullIntegrationStatus: () => request('/integrations/status'),
listProviders: () => request('/integrations/providers'),
configureIntegration: (provider, credentials) =>
request('/integrations/configure', { method: 'POST', body: JSON.stringify({ provider, credentials }) }),
// Resources
getResourceRecommendations: () => request('/onboarding/recommendations'),
};

View file

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const MEAL_ICONS = { breakfast: '🌅', lunch: '🌞', dinner: '🌙', snack: '🍎' }
export default function MealPlan() {
const [plan, setPlan] = useState(null)
const [plans, setPlans] = useState([])
const [loading, setLoading] = useState(true)
const [compliance, setCompliance] = useState({}) // plan_item_id status
useEffect(() => { loadAll() }, [])
async function loadAll() {
try {
const [today, all] = await Promise.all([api.getMealPlanToday(), api.listMealPlans()])
setPlan(today)
setPlans(all)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function markCompliance(itemId, status) {
try {
await api.logMealCompliance({ plan_item_id: itemId, status })
setCompliance(prev => ({ ...prev, [itemId]: status }))
} catch (err) { alert(`Failed: ${err.message}`) }
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
return (
<div className="page" style={{ maxWidth: 600, margin: '0 auto' }}>
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 8 }}>Meal Plan</h1>
{!plan?.active_plan ? (
<div style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 32,
textAlign: 'center', color: 'var(--text-2)',
}}>
<div style={{ fontSize: '2rem', marginBottom: 12 }}>🍽</div>
<p style={{ marginBottom: 8 }}>No active meal plan.</p>
<p style={{ fontSize: '0.85rem' }}>
Ask your AI agent to create one: "Generate a 7-day keto meal plan for me"
</p>
</div>
) : (
<>
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
marginBottom: 20, color: 'var(--text-2)', fontSize: '0.9rem',
}}>
<span>{plan.active_plan}</span>
<span style={{
background: 'var(--accent)', color: '#fff', padding: '3px 10px',
borderRadius: 20, fontSize: '0.75rem', fontWeight: 600,
}}>
Day {plan.day} / {plan.duration_days}
</span>
</div>
{plan.daily_calories && (
<div style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-sm)', padding: 12,
marginBottom: 16, textAlign: 'center', fontSize: '0.85rem', color: 'var(--text-2)',
}}>
Target: {plan.daily_calories} cal · {plan.diet_type || 'balanced'}
</div>
)}
{plan.meals?.length > 0 ? (
plan.meals.map(meal => {
const itemStatus = compliance[meal.id]
return (
<div key={meal.id} style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 16,
marginBottom: 10,
borderLeft: itemStatus === 'followed' ? '4px solid #4CAF50' :
itemStatus === 'skipped' ? '4px solid #F44336' :
itemStatus === 'substituted' ? '4px solid #FF9800' : '4px solid transparent',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', textTransform: 'uppercase', marginBottom: 4 }}>
{MEAL_ICONS[meal.meal_type] || '🍽️'} {meal.meal_type}
</div>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{meal.food_name}</div>
{meal.description && (
<div style={{ fontSize: '0.85rem', color: 'var(--text-2)' }}>{meal.description}</div>
)}
</div>
{meal.calories && (
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-2)', flexShrink: 0, marginLeft: 12 }}>
{meal.calories} cal
</div>
)}
</div>
{meal.protein_g && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: 6 }}>
P: {meal.protein_g}g · C: {meal.carbs_g}g · F: {meal.fat_g}g
</div>
)}
{!itemStatus && (
<div style={{ display: 'flex', gap: 6, marginTop: 10 }}>
{['followed', 'substituted', 'skipped'].map(s => (
<button key={s} onClick={() => markCompliance(meal.id, s)}
style={{
background: 'transparent', border: '1px solid var(--border)',
padding: '4px 12px', borderRadius: 'var(--r-sm)', fontSize: '0.75rem',
cursor: 'pointer', color: 'var(--text-2)', textTransform: 'capitalize',
}}>
{s === 'followed' ? '✅' : s === 'skipped' ? '⏭️' : '🔄'} {s}
</button>
))}
</div>
)}
</div>
)
})
) : (
<p style={{ color: 'var(--text-2)', textAlign: 'center' }}>{plan.message || 'No meals for today.'}</p>
)}
</>
)}
{plans.length > 0 && (
<div style={{ marginTop: 32 }}>
<h2 style={{ fontSize: '1.1rem', fontWeight: 600, marginBottom: 12 }}>All Plans</h2>
{plans.map(p => (
<div key={p.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 0', borderBottom: '1px solid var(--border)',
}}>
<div>
<div style={{ fontWeight: 500 }}>{p.name}</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-3)' }}>
{p.diet_type} · {p.duration_days} days · started {p.start_date}
</div>
</div>
<span style={{
fontSize: '0.75rem', padding: '2px 8px', borderRadius: 12,
background: p.status === 'active' ? '#4CAF5022' : '#9E9E9E22',
color: p.status === 'active' ? '#4CAF50' : '#9E9E9E',
fontWeight: 600,
}}>
{p.status}
</span>
</div>
))}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,137 @@
import { useState, useEffect } from 'react'
import { api } from '../api'
const STATUS_COLORS = {
connected: '#4CAF50',
configured: '#FF9800',
not_configured: '#9E9E9E',
}
const STATUS_LABELS = {
connected: 'Connected',
configured: 'Configured',
not_configured: 'Not configured',
}
export default function SettingsIntegrations() {
const [providers, setProviders] = useState({})
const [status, setStatus] = useState({})
const [loading, setLoading] = useState(true)
const [configuring, setConfiguring] = useState(null) // provider key being configured
const [formData, setFormData] = useState({})
const [saving, setSaving] = useState(false)
useEffect(() => { loadAll() }, [])
async function loadAll() {
try {
const [p, s] = await Promise.all([api.listProviders(), api.fullIntegrationStatus()])
setProviders(p)
setStatus(s)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
function startConfigure(key) {
setConfiguring(key)
const fields = providers[key]?.fields || []
setFormData(Object.fromEntries(fields.map(f => [f, ''])))
}
async function handleSave(key) {
setSaving(true)
try {
await api.configureIntegration(key, formData)
setConfiguring(null)
await loadAll()
} catch (err) { alert(`Failed: ${err.message}`) }
finally { setSaving(false) }
}
if (loading) return <div className="page"><div className="loading">Loading...</div></div>
return (
<div className="page" style={{ maxWidth: 700, margin: '0 auto' }}>
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 24 }}>Integrations</h1>
<p style={{ color: 'var(--text-2)', marginBottom: 24, fontSize: '0.9rem' }}>
Configure your fitness devices and services. Credentials are encrypted and stored locally never sent to any external server.
</p>
{Object.entries(providers).map(([key, info]) => (
<div key={key} style={{
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 20, marginBottom: 12,
border: status[key] === 'connected' ? '2px solid var(--accent)' : '1px solid var(--border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ fontSize: '1rem' }}>{info.name}</strong>
<span style={{
fontSize: '0.75rem', padding: '3px 10px', borderRadius: 20, fontWeight: 600,
background: STATUS_COLORS[status[key] || 'not_configured'] + '22',
color: STATUS_COLORS[status[key] || 'not_configured'],
}}>
{STATUS_LABELS[status[key] || 'not_configured']}
</span>
</div>
<p style={{ color: 'var(--text-2)', fontSize: '0.85rem', margin: '0 0 12px' }}>
{info.help_text}
</p>
{info.help_url && (
<a href={info.help_url} target="_blank" rel="noopener noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--accent)' }}>
Developer portal
</a>
)}
{configuring === key ? (
<div style={{ marginTop: 12, padding: 16, background: 'var(--surface)', borderRadius: 'var(--r-sm)' }}>
{info.fields.map(field => (
<div key={field} style={{ marginBottom: 10 }}>
<label style={{ fontSize: '0.8rem', fontWeight: 600, display: 'block', marginBottom: 4 }}>
{field.replace(/_/g, ' ')}
</label>
<input
type={field.includes('secret') || field.includes('token') || field.includes('key') ? 'password' : 'text'}
value={formData[field] || ''}
onChange={e => setFormData({ ...formData, [field]: e.target.value })}
style={{
width: '100%', padding: '8px 12px', borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)', fontSize: '0.9rem', boxSizing: 'border-box',
}}
placeholder={`Enter ${field.replace(/_/g, ' ')}`}
/>
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button onClick={() => handleSave(key)} disabled={saving}
style={{
background: 'var(--accent)', color: '#fff', border: 'none',
padding: '8px 20px', borderRadius: 'var(--r-sm)', cursor: 'pointer', fontWeight: 600,
}}>
{saving ? 'Saving...' : 'Save'}
</button>
<button onClick={() => setConfiguring(null)}
style={{
background: 'transparent', color: 'var(--text-2)', border: '1px solid var(--border)',
padding: '8px 20px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
}}>
Cancel
</button>
</div>
</div>
) : (
<button onClick={() => startConfigure(key)}
style={{
marginTop: 8, background: 'transparent', color: 'var(--accent)',
border: '1px solid var(--accent)', padding: '6px 16px', borderRadius: 'var(--r-sm)',
cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
}}>
{status[key] === 'not_configured' ? 'Configure' : 'Reconfigure'}
</button>
)}
</div>
))}
</div>
)
}

View file

@ -0,0 +1,61 @@
import { useNavigate } from 'react-router-dom'
export default function Welcome() {
const navigate = useNavigate()
return (
<div className="page" style={{ maxWidth: 600, margin: '0 auto', padding: '40px 20px', textAlign: 'center' }}>
<div style={{ fontSize: '3rem', marginBottom: 16 }}>💪</div>
<h1 style={{ fontSize: '1.8rem', fontWeight: 700, marginBottom: 8 }}>Earn Before You Spend</h1>
<p style={{ color: 'var(--text-2)', fontSize: '1rem', lineHeight: 1.6, marginBottom: 32 }}>
This app runs on a simple idea: you earn points by doing healthy things
(workouts, logging meals, hitting step goals), and you spend points on
guilty pleasures you configure yourself.
</p>
<div style={{ background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 24, marginBottom: 24, textAlign: 'left' }}>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<span style={{ fontSize: '1.5rem' }}>🔒</span>
<div>
<strong>Daily Gate</strong>
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
Until you earn enough points today, your rewards stay locked. Hit your target, and the gate opens.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<span style={{ fontSize: '1.5rem' }}>🎮</span>
<div>
<strong>Your Rules</strong>
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
You set the rewards gaming time, takeout, screen time, whatever you want. You hold yourself accountable.
</p>
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<span style={{ fontSize: '1.5rem' }}>🤖</span>
<div>
<strong>AI Agent (Optional)</strong>
<p style={{ color: 'var(--text-2)', margin: '4px 0 0', fontSize: '0.9rem' }}>
Connect an AI agent to log activities by voice, get nudged when you're behind, and have a fitness companion that knows your goals.
</p>
</div>
</div>
</div>
<button
onClick={() => navigate('/register')}
style={{
background: 'var(--accent)', color: '#fff', border: 'none', borderRadius: 'var(--r-sm)',
padding: '14px 32px', fontSize: '1rem', fontWeight: 600, cursor: 'pointer', width: '100%',
}}
>
Get Started
</button>
<p style={{ color: 'var(--text-3)', fontSize: '0.8rem', marginTop: 16 }}>
Points reset weekly. Each week is a fresh start.
</p>
</div>
)
}