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."""
result = {}
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] = {
"name": info["name"],
"type": info["type"],
"category": info.get("category", "other"),
"fields": info["fields"],
"help_url": info.get("help_url", ""),
"help_text": info.get("help_text", "").replace("{BASE_URL}", settings.base_url),
"warning": info.get("warning", ""),
"sync_status": sync_status,
}
return result

View file

@ -107,6 +107,18 @@ export default function App() {
return (
<>
{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>
<Route path="/login" element={<Login />} />
<Route path="/onboarding" element={<ProtectedRoute><Onboarding /></ProtectedRoute>} />

View file

@ -69,6 +69,12 @@ export default function AgentConnect() {
return (
<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' }}>
<h1 style={{
fontFamily: 'var(--font-display)', fontWeight: 800,

View file

@ -94,17 +94,22 @@ export default function ChatCoach() {
<h1 className="page-title">AI Coach</h1>
<div className="card" style={{ textAlign: 'center', padding: '32px 20px' }}>
<div style={{ fontSize: '2rem', marginBottom: '12px' }}>🤖</div>
<h3 style={{ marginBottom: '8px' }}>No AI provider connected</h3>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '16px' }}>
Connect OpenAI, OpenRouter, Claude, Ollama, or any other provider to start chatting with your AI fitness coach.
<h3 style={{ marginBottom: '8px' }}>Set up your AI coach</h3>
<p style={{ color: 'var(--text-2)', fontSize: '0.9rem', marginBottom: '20px' }}>
Connect an AI provider to chat with your fitness coach right here. OpenRouter, Groq, and Hugging Face all have free tiers.
</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)',
background: 'var(--accent)', color: 'var(--text-inv)', textDecoration: 'none',
fontWeight: 600, fontSize: '0.88rem',
}}>
Configure AI Provider
Set up AI Provider
</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>
)

View file

@ -32,6 +32,7 @@ function Tip({ text, children }) {
export default function Dashboard() {
const [status, setStatus] = useState(null)
const [integrations, setIntegrations] = useState(null)
const [aiStatus, setAiStatus] = useState(null)
const [loading, setLoading] = useState(true)
const [syncing, setSyncing] = useState(null)
const navigate = useNavigate()
@ -39,9 +40,10 @@ export default function Dashboard() {
useEffect(() => { loadAll() }, [])
async function loadAll() {
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)
setIntegrations(intg)
setAiStatus(ai)
} catch (err) { console.error(err) }
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.' },
]
// 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 (
<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 */}
{status.program_name && (
<div

View file

@ -127,6 +127,22 @@ export default function Settings() {
</div>
<span style={{ color: 'var(--text-3)', fontSize: '1.1rem' }}></span>
</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>
{/* Point Rules */}
@ -192,7 +208,7 @@ export default function Settings() {
onClick={() => navigate('/settings/integrations')}
style={{ padding: '12px 0', textAlign: 'center', cursor: 'pointer', color: 'var(--accent)', fontSize: '0.88rem', fontWeight: 600 }}
>
Configure all 11 providers
All integrations
</div>
</div>

View file

@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { api } from '../api'
const STATUS_COLORS = {
@ -13,16 +14,51 @@ const STATUS_LABELS = {
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() {
const [providers, setProviders] = useState({})
const [status, setStatus] = useState({})
const [loading, setLoading] = useState(true)
const [configuring, setConfiguring] = useState(null) // provider key being configured
const [configuring, setConfiguring] = useState(null)
const [formData, setFormData] = useState({})
const [saving, setSaving] = useState(false)
const location = useLocation()
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() {
try {
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>
// 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 (
<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' }}>
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'],
{CATEGORY_ORDER.map(cat => {
const items = grouped[cat]
if (!items || items.length === 0) return null
const catInfo = CATEGORY_INFO[cat] || { title: cat, description: '' }
const sectionId = catInfo.title.toLowerCase().replace(/\s+/g, '-')
return (
<div key={cat} id={sectionId} style={{ marginBottom: 32 }}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, textTransform: 'uppercase',
letterSpacing: '0.08em', color: 'var(--accent)',
fontFamily: 'var(--font-mono)', marginBottom: 4,
}}>
{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>
{catInfo.title}
</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>
))}
<p style={{ color: 'var(--text-3)', fontSize: '0.82rem', marginBottom: 12 }}>
{catInfo.description}
{cat === 'ai_provider' && (
<span> <a href="/agent" style={{ color: 'var(--accent)', textDecoration: 'none', fontWeight: 600 }}>
Or connect Claude Desktop / Cursor via MCP
</a></span>
)}
</p>
{items.map(info => {
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>
)
}