UX overhaul: navigation, category grouping, setup guidance, checklist

Fix 1: list_providers returns category, warning, sync_status
Fix 2: Gear icon in header (offset from HelpButton) links to /settings
Fix 3: Integrations page grouped by category (Devices → AI → Nutrition →
       Notifications) with section headers, explanatory text, sync status
       badges, COROS/coming-soon handling, link to /agent, hash-scroll
Fix 4: Coach page two-path setup (built-in prominent, external agent
       subtle link to /agent), anchor to #ai-coaching
Fix 5: Settings 'all 11 providers' → 'All integrations', AI Agent link
Fix 6: AgentConnect back-link to integrations
Fix 7: Dashboard getting-started checklist (dismiss to localStorage)
This commit is contained in:
Claude 2026-07-13 23:01:57 +00:00
parent c56d6d5816
commit 04e2af1a85
7 changed files with 303 additions and 82 deletions

View file

@ -235,12 +235,23 @@ async def list_providers():
"""Return the provider registry with setup instructions.""" """Return the provider registry with setup instructions."""
result = {} result = {}
for key, info in PROVIDER_REGISTRY.items(): for key, info in PROVIDER_REGISTRY.items():
# Determine sync status for device providers
if info.get("type") == "mcp_bridge":
sync_status = "mcp_bridge"
elif info.get("category") == "device" and not info.get("sync_service"):
sync_status = "coming_soon"
else:
sync_status = "active"
result[key] = { result[key] = {
"name": info["name"], "name": info["name"],
"type": info["type"], "type": info["type"],
"category": info.get("category", "other"),
"fields": info["fields"], "fields": info["fields"],
"help_url": info.get("help_url", ""), "help_url": info.get("help_url", ""),
"help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url), "help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url),
"warning": info.get("warning", ""),
"sync_status": sync_status,
} }
return result return result

View file

@ -107,6 +107,18 @@ export default function App() {
return ( return (
<> <>
{hasToken() && <HelpButton />} {hasToken() && <HelpButton />}
{hasToken() && (
<NavLink to="/settings" style={{
position: 'fixed', top: '12px', right: '52px', zIndex: 90,
width: '36px', height: '36px', borderRadius: '50%',
background: 'var(--card)', border: '1px solid var(--card-border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
textDecoration: 'none', fontSize: '1.1rem', boxShadow: 'var(--shadow-1)',
color: 'var(--text-3)',
}}>
</NavLink>
)}
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} /> <Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />

View file

@ -69,6 +69,12 @@ export default function AgentConnect() {
return ( return (
<div className="page"> <div className="page">
<a href="/settings/integrations" style={{
fontSize: '0.82rem', color: 'var(--accent)', textDecoration: 'none',
display: 'inline-block', marginBottom: '12px',
}}>
Back to Integrations
</a>
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<h1 style={{ <h1 style={{
fontFamily: 'var(--font-display)', fontWeight: 800, fontFamily: 'var(--font-display)', fontWeight: 800,

View file

@ -94,17 +94,22 @@ export default function ChatCoach() {
<h1 className="page-title">AI Coach</h1> <h1 className="page-title">AI Coach</h1>
<div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}> <div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}>
<div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div> <div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div>
<h3 style={{ marginBottom: '8px' }}>No AI provider connected</h3> <h3 style={{ marginBottom: '8px' }}>Set up your AI coach</h3>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '16px' }}> <p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '20px' }}>
Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach. Connect an AI provider to chat with your fitness coach right here. OpenRouter, Groq, and Hugging Face all have free tiers.
</p> </p>
<a href="/settings/integrations" className="btn-primary" style={{ <a href="/settings/integrations#ai-coaching" className="btn-primary" style={{
display: 'inline-block', padding: '12px 24px', borderRadius: 'var(--r)', display: 'inline-block', padding: '12px 24px', borderRadius: 'var(--r)',
background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none', background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none',
fontWeight: 600, fontSize: '0.88rem', fontWeight: 600, fontSize: '0.88rem',
}}> }}>
Configure AI Provider Set up AI Provider
</a> </a>
<p style={{ marginTop: '16px', fontSize: '0.78rem', color: 'var(--text-3)' }}>
Advanced: <a href="/agent" style={{ color: 'var(--text-3)', textDecoration: 'underline' }}>
connect Claude Desktop, Cursor, or Claude Code via MCP
</a>
</p>
</div> </div>
</div> </div>
) )

View file

@ -32,6 +32,7 @@ function Tip({ text, children }) {
export default function Dashboard() { export default function Dashboard() {
const [status, setStatus] = useState(null) const [status, setStatus] = useState(null)
const [integrations, setIntegrations] = useState(null) const [integrations, setIntegrations] = useState(null)
const [aiStatus, setAiStatus] = useState(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(null) const [syncing, setSyncing] = useState(null)
const navigate = useNavigate() const navigate = useNavigate()
@ -39,9 +40,10 @@ export default function Dashboard() {
useEffect(() => { loadAll() }, []) useEffect(() => { loadAll() }, [])
async function loadAll() { async function loadAll() {
try { try {
const [s, intg] = await Promise.all([api.today(), api.integrationStatus()]) const [s, intg, ai] = await Promise.all([api.today(), api.integrationStatus(), api.getAIStatus().catch(() => ({ configured: false }))])
setStatus(s) setStatus(s)
setIntegrations(intg) setIntegrations(intg)
setAiStatus(ai)
} catch (err) { console.error(err) } } catch (err) { console.error(err) }
finally { setLoading(false) } finally { setLoading(false) }
} }
@ -83,8 +85,77 @@ export default function Dashboard() {
{ key: 'daily_checkin', label: 'Check-in', icon: '✅', pts: 10, color: '#00BCD4', tip: 'Just show up and check in. The easiest points — consistency matters more than intensity.' }, { key: 'daily_checkin', label: 'Check-in', icon: '✅', pts: 10, color: '#00BCD4', tip: 'Just show up and check in. The easiest points — consistency matters more than intensity.' },
] ]
// Getting-started checklist shows until dismissed
const [showChecklist, setShowChecklist] = useState(() => {
return localStorage.getItem('diligence_setup_dismissed') !== 'true'
})
const setupItems = [
{ done: true, label: 'Create account', link: null },
{ done: !!status.program_name, label: 'Join a program', link: '/programs' },
{ done: aiStatus?.configured, label: 'Connect an AI coach', link: '/settings/integrations#ai-coaching' },
{ done: Object.values(integrations || {}).some(v => v?.connected), label: 'Connect a fitness device', link: '/settings/integrations#fitness-devices' },
{ done: false, label: 'Set up rewards', link: '/rewards' },
]
const completedCount = setupItems.filter(i => i.done).length
const allDone = completedCount === setupItems.length
return ( return (
<div className="page"> <div className="page">
{/* Getting started checklist */}
{showChecklist && !allDone && (
<div style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px 18px',
marginBottom: '14px', border: '1px solid var(--card-border)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.08em', color: 'var(--accent)', fontFamily: 'var(--font-mono)',
}}>
Getting Started
</div>
<button onClick={() => { setShowChecklist(false); localStorage.setItem('diligence_setup_dismissed', 'true') }}
style={{ background: 'none', border: 'none', color: 'var(--text-3)', cursor: 'pointer', fontSize: '1rem', padding: 0 }}>
</button>
</div>
{setupItems.map((item, i) => (
<div key={i}
onClick={() => item.link && !item.done && navigate(item.link)}
style={{
display: 'flex', alignItems: 'center', gap: '10px',
padding: '7px 0',
cursor: item.link && !item.done ? 'pointer' : 'default',
}}
>
<span style={{
width: '18px', height: '18px', borderRadius: '50%', display: 'flex',
alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', flexShrink: 0,
background: item.done ? 'var(--green)' : 'transparent',
border: item.done ? 'none' : '2px solid var(--card-border)',
color: item.done ? 'var(--text-inv)' : 'var(--text-3)',
}}>
{item.done ? '✓' : ''}
</span>
<span style={{
fontSize: '0.88rem', fontWeight: 500,
color: item.done ? 'var(--text-3)' : 'var(--text)',
textDecoration: item.done ? 'line-through' : 'none',
}}>
{item.label}
</span>
{item.link && !item.done && (
<span style={{ marginLeft: 'auto', color: 'var(--accent)', fontSize: '0.78rem', fontWeight: 600 }}></span>
)}
</div>
))}
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: '8px', fontFamily: 'var(--font-mono)' }}>
{completedCount} of {setupItems.length} complete
</div>
</div>
)}
{/* Program bar */} {/* Program bar */}
{status.program_name && ( {status.program_name && (
<div <div

View file

@ -127,6 +127,22 @@ export default function Settings() {
</div> </div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span> <span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div> </div>
<div
onClick={() => navigate('/agent')}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', cursor: 'pointer',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: '1.2rem' }}>🤖</span>
<div>
<div style={{ fontWeight: 700, fontSize: '0.95rem' }}>Connect AI Agent</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-3)', fontWeight: 500 }}>Claude Desktop, Cursor, Claude Code setup</div>
</div>
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</div>
</div> </div>
{/* Point Rules */} {/* Point Rules */}
@ -192,7 +208,7 @@ export default function Settings() {
onClick={() => navigate('/settings/integrations')} onClick={() => navigate('/settings/integrations')}
style={{ padding: '12px 0', textAlign: 'center', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.88rem', fontWeight: 600 }} style={{ padding: '12px 0', textAlign: 'center', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.88rem', fontWeight: 600 }}
> >
Configure all 11 providers All integrations
</div> </div>
</div> </div>

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { api } from '../api' import { api } from '../api'
const STATUS_COLORS = { const STATUS_COLORS = {
@ -13,16 +14,51 @@ const STATUS_LABELS = {
not_configured: 'Not configured', not_configured: 'Not configured',
} }
const CATEGORY_ORDER = ['device', 'ai_provider', 'nutrition', 'notifications']
const CATEGORY_INFO = {
device: {
title: 'Fitness Devices',
description: 'Sync workouts automatically from your watch or tracker.',
},
ai_provider: {
title: 'AI Coaching',
description: 'Connect an LLM to power the built-in AI coach in the Coach tab. One provider is enough.',
},
nutrition: {
title: 'Nutrition',
description: 'Food databases for accurate macro tracking.',
},
notifications: {
title: 'Notifications',
description: 'Get alerts when things happen.',
},
}
const SYNC_BADGE = {
coming_soon: { label: 'Coming soon', color: 'var(--text-3)' },
mcp_bridge: { label: 'MCP bridge', color: 'var(--accent)' },
}
export default function SettingsIntegrations() { export default function SettingsIntegrations() {
const [providers, setProviders] = useState({}) const [providers, setProviders] = useState({})
const [status, setStatus] = useState({}) const [status, setStatus] = useState({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [configuring, setConfiguring] = useState(null) // provider key being configured const [configuring, setConfiguring] = useState(null)
const [formData, setFormData] = useState({}) const [formData, setFormData] = useState({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const location = useLocation()
useEffect(() => { loadAll() }, []) useEffect(() => { loadAll() }, [])
// Handle hash scrolling (e.g. /settings/integrations#ai-coaching)
useEffect(() => {
if (!loading && location.hash) {
const el = document.getElementById(location.hash.slice(1))
if (el) setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'start' }), 100)
}
}, [loading, location.hash])
async function loadAll() { async function loadAll() {
try { try {
const [p, s] = await Promise.all([api.listProviders(), api.fullIntegrationStatus()]) const [p, s] = await Promise.all([api.listProviders(), api.fullIntegrationStatus()])
@ -50,88 +86,152 @@ export default function SettingsIntegrations() {
if (loading) return <div className="page"><div className="loading">Loading...</div></div> if (loading) return <div className="page"><div className="loading">Loading...</div></div>
// Group providers by category
const grouped = {}
for (const [key, info] of Object.entries(providers)) {
const cat = info.category || 'other'
if (!grouped[cat]) grouped[cat] = []
grouped[cat].push({ key, ...info })
}
return ( return (
<div className="page" style={{ maxWidth: 700, margin: '0 auto' }}> <div className="page" style={{ maxWidth: 700, margin: '0 auto' }}>
<h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 24 }}>Integrations</h1> <h1 style={{ fontSize: '1.4rem', fontWeight: 700, marginBottom: 8 }}>Integrations</h1>
<p style={{ color: 'var(--text-2)', marginBottom: 24, fontSize: '0.9rem' }}> <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. Configure your fitness devices and services. Credentials are encrypted and stored locally never sent to any external server.
</p> </p>
{Object.entries(providers).map(([key, info]) => ( {CATEGORY_ORDER.map(cat => {
<div key={key} style={{ const items = grouped[cat]
background: 'var(--surface-2)', borderRadius: 'var(--r-md)', padding: 20, marginBottom: 12, if (!items || items.length === 0) return null
border: status[key] === 'connected' ? '2px solid var(--accent)' : '1px solid var(--border)', const catInfo = CATEGORY_INFO[cat] || { title: cat, description: '' }
}}> const sectionId = catInfo.title.toLowerCase().replace(/\s+/g, '-')
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong style={{ fontSize: '1rem' }}>{info.name}</strong> return (
<span style={{ <div key={cat} id={sectionId} style={{ marginBottom: 32 }}>
fontSize: '0.75rem', padding: '3px 10px', borderRadius: 20, fontWeight: 600, <div style={{
background: STATUS_COLORS[status[key] || 'not_configured'] + '22', fontSize: '0.72rem', fontWeight: 700, textTransform: 'uppercase',
color: STATUS_COLORS[status[key] || 'not_configured'], letterSpacing: '0.08em', color: 'var(--accent)',
fontFamily: 'var(--font-mono)', marginBottom: 4,
}}> }}>
{STATUS_LABELS[status[key] || 'not_configured']} {catInfo.title}
</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> </div>
) : ( <p style={{ color: 'var(--text-3)', fontSize: '0.82rem', marginBottom: 12 }}>
<button onClick={() => startConfigure(key)} {catInfo.description}
style={{ {cat === 'ai_provider' && (
marginTop: 8, background: 'transparent', color: 'var(--accent)', <span> <a href="/agent" style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
border: '1px solid var(--accent)', padding: '6px 16px', borderRadius: 'var(--r-sm)', Or connect Claude Desktop / Cursor via MCP
cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600, </a></span>
}}> )}
{status[key] === 'not_configured' ? 'Configure' : 'Reconfigure'} </p>
</button>
)} {items.map(info => {
</div> const { key } = info
))} const badge = SYNC_BADGE[info.sync_status]
const isMcpBridge = info.sync_status === 'mcp_bridge'
const isComingSoon = info.sync_status === 'coming_soon'
return (
<div key={key} style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: 20, marginBottom: 10,
border: status[key] === 'connected' ? '2px solid var(--accent)' : '1px solid var(--card-border)',
opacity: isComingSoon ? 0.7 : 1,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<strong style={{ fontSize: '1rem' }}>{info.name}</strong>
{badge && (
<span style={{
fontSize: '0.65rem', padding: '2px 8px', borderRadius: 20,
fontWeight: 600, fontFamily: 'var(--font-mono)',
background: badge.color + '18', color: badge.color,
}}>
{badge.label}
</span>
)}
</div>
<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 8px' }}>
{info.help_text}
</p>
{info.warning && (
<p style={{
fontSize: '0.78rem', color: 'var(--amber)', margin: '0 0 8px',
padding: '6px 10px', background: 'var(--amber-bg)', borderRadius: 'var(--r-sm)',
}}>
{info.warning}
</p>
)}
{info.help_url && (
<a href={info.help_url} target="_blank" rel="noopener noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--accent)' }}>
{isMcpBridge ? 'MCP setup docs →' : 'Developer portal →'}
</a>
)}
{/* Configuration form — skip for mcp_bridge and coming_soon */}
{!isMcpBridge && !isComingSoon && configuring === key ? (
<div style={{ marginTop: 12, padding: 16, background: 'var(--bg)', 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(--card-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: 'var(--text-inv)', 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(--card-border)',
padding: '8px 20px', borderRadius: 'var(--r-sm)', cursor: 'pointer',
}}>
Cancel
</button>
</div>
</div>
) : !isMcpBridge && !isComingSoon ? (
<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' || !status[key] ? 'Configure' : 'Reconfigure'}
</button>
) : null}
</div>
)
})}
</div>
)
})}
</div> </div>
) )
} }