v2: Program research, Groq extraction, and workout tracking (REVIEW — not deployed)

Backend:
- New models: ProgramCatalog, CatalogWorkout, CrawlQueue, WorkoutLog
- Program.py: added catalog_id, current_week, current_day columns
- program_research.py: ttp-crawler integration, Groq Llama 3.3 70B extraction, known sources map, validation
- crawl_scheduler.py: priority queue with off-peak/weekend scheduling
- catalog.py router: research, catalog browse, adopt, schedule, complete, progress endpoints
- Points: 75pts/workout, 50pts weekly bonus, 200pts completion bonus
- config.py: added groq_api_key setting
- main.py: catalog router, background scheduler, v2 migration

Frontend:
- ProgramSearch.jsx: search, research submission, catalog browsing, adopt programs
- ProgramDetail.jsx: schedule view, today's workout, completion modal, progress tracking
- App.jsx: Programs nav tab, new routes
- api.js: catalog and tracking API functions

Config:
- docker-compose.yml: GROQ_API_KEY env var

NOT DEPLOYED — requires Groq API key and Scot review
This commit is contained in:
claude 2026-04-03 16:22:53 +00:00
parent a81ca9c275
commit 93f24fff83
13 changed files with 1738 additions and 9 deletions

View file

@ -9,6 +9,8 @@ import Rewards from './pages/Rewards'
import Settings from './pages/Settings'
import Onboarding from './pages/Onboarding'
import WeekView from './pages/WeekView'
import ProgramSearch from './pages/ProgramSearch'
import ProgramDetail from './pages/ProgramDetail'
function ProtectedRoute({ children }) {
if (!hasToken()) return <Navigate to="/login" />
@ -24,8 +26,8 @@ function NavBar() {
<NavLink to="/log" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">💪</span> Log
</NavLink>
<NavLink to="/food" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🍽</span> Food
<NavLink to="/programs" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">📋</span> Programs
</NavLink>
<NavLink to="/rewards" className={({ isActive }) => `nav-item ${isActive ? 'active' : ''}`}>
<span className="nav-icon">🎮</span> Rewards
@ -46,6 +48,8 @@ export default function App() {
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/log" element={<ProtectedRoute><LogActivity /></ProtectedRoute>} />
<Route path="/food" element={<ProtectedRoute><LogFood /></ProtectedRoute>} />
<Route path="/programs" element={<ProtectedRoute><ProgramSearch /></ProtectedRoute>} />
<Route path="/programs/:id" element={<ProtectedRoute><ProgramDetail /></ProtectedRoute>} />
<Route path="/rewards" element={<ProtectedRoute><Rewards /></ProtectedRoute>} />
<Route path="/week" element={<ProtectedRoute><WeekView /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />

View file

@ -64,11 +64,30 @@ export const api = {
createReward: (data) => request('/rewards', { method: 'POST', body: JSON.stringify(data) }),
redeemReward: (id, date) => request(`/rewards/${id}/redeem`, { method: 'POST', body: JSON.stringify({ date }) }),
// Programs
// Programs (v1 — existing)
listPrograms: () => request('/programs'),
createProgram: (data) => request('/programs', { method: 'POST', body: JSON.stringify(data) }),
getProgram: (id) => request(`/programs/${id}`),
// Program Catalog (v2 — new)
searchCatalog: (q) => request(`/programs/catalog${q ? `?q=${encodeURIComponent(q)}` : ''}`),
getCatalogProgram: (id) => request(`/programs/catalog/${id}`),
researchProgram: (name) =>
request('/programs/research', { method: 'POST', body: JSON.stringify({ name }) }),
adoptProgram: (catalogId, startDate) =>
request(`/programs/catalog/${catalogId}/adopt`, {
method: 'POST', body: JSON.stringify({ start_date: startDate }),
}),
// Program Tracking (v2 — new)
getProgramSchedule: (id) => request(`/programs/${id}/schedule`),
getWorkoutDetail: (progId, workoutId) => request(`/programs/${progId}/workout/${workoutId}`),
completeWorkout: (progId, workoutId, data) =>
request(`/programs/${progId}/workout/${workoutId}/complete`, {
method: 'POST', body: JSON.stringify(data),
}),
getProgramProgress: (id) => request(`/programs/${id}/progress`),
// Integrations
integrationStatus: () => request('/integrations'),
stravaAuth: () => request('/integrations/strava/auth'),
@ -78,7 +97,7 @@ export const api = {
disconnect: (provider) => request(`/integrations/${provider}`, { method: 'DELETE' }),
// Resources
getRecommendations: () => request('/onboarding/recommendations'),
getResourceRecommendations: () => request('/onboarding/recommendations'),
};
export function setToken(token) {

View file

@ -0,0 +1,303 @@
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function ProgramDetail() {
const { id } = useParams()
const navigate = useNavigate()
const [schedule, setSchedule] = useState(null)
const [progress, setProgress] = useState(null)
const [loading, setLoading] = useState(true)
const [completing, setCompleting] = useState(null) // workout id being completed
const [showComplete, setShowComplete] = useState(null) // workout to show completion modal
const [completionResult, setCompletionResult] = useState(null)
useEffect(() => { loadData() }, [id])
async function loadData() {
try {
const [sched, prog] = await Promise.all([
api.getProgramSchedule(id),
api.getProgramProgress(id),
])
setSchedule(sched)
setProgress(prog)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function handleComplete(workoutId) {
setCompleting(workoutId)
try {
const result = await api.completeWorkout(id, workoutId, {})
setCompletionResult(result)
setShowComplete(null)
await loadData()
} catch (err) {
alert(err.message)
}
finally { setCompleting(null) }
}
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-3)' }}>Loading...</div>
if (!schedule) return <div style={{ padding: '2rem', textAlign: 'center' }}>Program not found</div>
const pct = progress ? progress.completion_pct : 0
return (
<div style={{ padding: '1rem', maxWidth: '600px', margin: '0 auto' }}>
{/* Back */}
<button onClick={() => navigate('/programs')} style={{
background: 'transparent', color: 'var(--text-3)', padding: '8px 0',
fontSize: '0.85rem', marginBottom: '0.5rem',
}}> Programs</button>
{/* Header */}
<div style={{
background: 'var(--card)', borderRadius: 'var(--r-lg)', padding: '20px',
boxShadow: 'var(--shadow-2)', marginBottom: '1.5rem',
}}>
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '1.5rem', fontWeight: 900 }}>
{schedule.program_name}
</h1>
<div style={{ display: 'flex', gap: '1.5rem', marginTop: '10px', fontSize: '0.82rem', color: 'var(--text-2)' }}>
<span>Week {schedule.current_week}</span>
<span>{progress?.completed_workouts || 0}/{progress?.total_workouts || 0} workouts</span>
<span style={{ color: 'var(--orange)', fontWeight: 700 }}>{pct}%</span>
</div>
{/* Progress bar */}
<div style={{ marginTop: '12px', height: '8px', borderRadius: '4px', background: 'var(--divider)' }}>
<div style={{
height: '100%', borderRadius: '4px',
background: pct >= 100 ? 'var(--green)' : 'var(--orange)',
width: `${Math.min(100, pct)}%`, transition: 'width 0.4s ease',
}} />
</div>
{schedule.progression_rules && (
<p style={{
marginTop: '12px', fontSize: '0.8rem', color: 'var(--text-3)',
lineHeight: 1.5, borderTop: '1px solid var(--divider)', paddingTop: '12px',
}}>
📈 {schedule.progression_rules}
</p>
)}
</div>
{/* Completion celebration */}
{completionResult && (
<div style={{
background: 'var(--green-ghost)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '1rem', textAlign: 'center',
}}>
<div style={{ fontSize: '1.8rem', marginBottom: '4px' }}>🎉</div>
<strong style={{ color: 'var(--green-dark)' }}>
+{completionResult.total_points} points!
</strong>
<div style={{ fontSize: '0.8rem', color: 'var(--text-2)', marginTop: '4px' }}>
Workout: +{completionResult.points_earned}
{completionResult.weekly_bonus > 0 && ` • Week bonus: +${completionResult.weekly_bonus}`}
{completionResult.completion_bonus > 0 && ` • Program complete: +${completionResult.completion_bonus}!`}
</div>
<button onClick={() => setCompletionResult(null)} style={{
marginTop: '10px', background: 'transparent', color: 'var(--green-dark)',
fontSize: '0.8rem', padding: '6px 16px', border: '1px solid var(--green)',
}}>Dismiss</button>
</div>
)}
{/* Today's Workout */}
{schedule.today_workout && !schedule.today_workout.completed && !schedule.today_workout.rest_day && (
<div style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '1.5rem', boxShadow: 'var(--shadow-2)',
border: '2px solid var(--orange-glow)',
}}>
<div style={{
fontSize: '0.72rem', fontWeight: 700, color: 'var(--orange)',
textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: '6px',
}}>Today's Workout</div>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem' }}>
{schedule.today_workout.workout_name || `Day ${schedule.today_workout.day_number}`}
</strong>
<div style={{ marginTop: '12px' }}>
{(schedule.today_workout.exercises || []).map((ex, i) => (
<ExerciseRow key={i} exercise={ex} />
))}
</div>
<button
onClick={() => handleComplete(schedule.today_workout.id)}
disabled={completing === schedule.today_workout.id}
style={{
marginTop: '14px', width: '100%', padding: '14px',
background: completing ? 'var(--text-3)' : 'var(--green)',
color: '#fff', fontSize: '0.95rem', fontWeight: 800,
boxShadow: 'var(--shadow-green)',
}}
>
{completing === schedule.today_workout.id ? 'Logging...' : '✓ Complete Workout — 75 pts'}
</button>
</div>
)}
{/* Full Schedule */}
<h2 style={{
fontFamily: 'var(--font-display)', fontSize: '1.1rem', fontWeight: 800,
marginBottom: '0.75rem',
}}>Full Schedule</h2>
{groupByWeek(schedule.schedule).map(([week, workouts]) => (
<div key={week} style={{ marginBottom: '1.25rem' }}>
<div style={{
fontSize: '0.78rem', fontWeight: 700, color: 'var(--text-3)',
textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.5rem',
display: 'flex', alignItems: 'center', gap: '0.5rem',
}}>
Week {week}
{week === schedule.current_week && (
<span style={{
fontSize: '0.65rem', background: 'var(--orange)', color: '#fff',
padding: '2px 8px', borderRadius: 'var(--r-full)',
}}>Current</span>
)}
</div>
{workouts.map(w => (
<div key={w.id} style={{
background: 'var(--card)', borderRadius: 'var(--r-sm)', padding: '12px 14px',
marginBottom: '0.4rem', boxShadow: 'var(--shadow-1)',
opacity: w.completed ? 0.7 : 1,
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}>
<div>
<span style={{ fontSize: '0.85rem', fontWeight: 600 }}>
{w.rest_day ? '😴 Rest Day' : (w.workout_name || `Day ${w.day_number}`)}
</span>
{!w.rest_day && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginLeft: '8px' }}>
{(w.exercises || []).length} exercises
</span>
)}
</div>
<div>
{w.completed ? (
<span style={{ color: 'var(--green)', fontSize: '0.85rem', fontWeight: 700 }}></span>
) : !w.rest_day ? (
<button
onClick={() => setShowComplete(w)}
style={{
background: 'var(--green-ghost)', color: 'var(--green-dark)',
padding: '6px 12px', fontSize: '0.75rem', fontWeight: 700,
}}
>
Do it
</button>
) : null}
</div>
</div>
))}
</div>
))}
{/* Workout Detail Modal */}
{showComplete && (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
display: 'flex', alignItems: 'flex-end', justifyContent: 'center', zIndex: 100,
}} onClick={() => setShowComplete(null)}>
<div style={{
background: 'var(--card)', borderRadius: 'var(--r-lg) var(--r-lg) 0 0',
padding: '24px 20px', width: '100%', maxWidth: '600px', maxHeight: '80vh',
overflow: 'auto',
}} onClick={e => e.stopPropagation()}>
<h3 style={{ fontFamily: 'var(--font-display)', fontSize: '1.2rem', fontWeight: 800, marginBottom: '4px' }}>
{showComplete.workout_name || `Week ${showComplete.week_number}, Day ${showComplete.day_number}`}
</h3>
<p style={{ color: 'var(--text-3)', fontSize: '0.82rem', marginBottom: '16px' }}>
Complete the exercises below and tap "Done" when finished.
</p>
{(showComplete.exercises || []).map((ex, i) => (
<ExerciseRow key={i} exercise={ex} detailed />
))}
{showComplete.notes && (
<p style={{
marginTop: '12px', fontSize: '0.8rem', color: 'var(--text-2)',
background: 'var(--blue-ghost)', padding: '10px 12px', borderRadius: 'var(--r-sm)',
}}>
💡 {showComplete.notes}
</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '20px' }}>
<button onClick={() => setShowComplete(null)} style={{
flex: 1, background: 'var(--divider)', color: 'var(--text-2)', padding: '14px',
}}>Cancel</button>
<button
onClick={() => handleComplete(showComplete.id)}
disabled={completing === showComplete.id}
style={{
flex: 2, background: 'var(--green)', color: '#fff', padding: '14px',
fontWeight: 800, boxShadow: 'var(--shadow-green)',
}}
>
{completing === showComplete.id ? 'Logging...' : '✓ Done — 75 pts'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
function ExerciseRow({ exercise, detailed }) {
return (
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: detailed ? '10px 0' : '6px 0',
borderBottom: '1px solid var(--divider)',
}}>
<div>
<span style={{ fontWeight: 600, fontSize: detailed ? '0.9rem' : '0.82rem' }}>
{exercise.name}
</span>
{detailed && exercise.notes && (
<div style={{ fontSize: '0.75rem', color: 'var(--text-3)', marginTop: '2px' }}>
{exercise.notes}
</div>
)}
{detailed && exercise.weight_instruction && (
<div style={{ fontSize: '0.75rem', color: 'var(--blue)', marginTop: '2px' }}>
🏋 {exercise.weight_instruction}
</div>
)}
</div>
<div style={{
textAlign: 'right', fontSize: '0.8rem', color: 'var(--text-2)', fontWeight: 600,
whiteSpace: 'nowrap',
}}>
{exercise.sets && exercise.reps && `${exercise.sets}×${exercise.reps}`}
{exercise.rest_seconds && detailed && (
<div style={{ fontSize: '0.7rem', color: 'var(--text-3)' }}>
{exercise.rest_seconds >= 60
? `${Math.floor(exercise.rest_seconds / 60)}m rest`
: `${exercise.rest_seconds}s rest`}
</div>
)}
</div>
</div>
)
}
function groupByWeek(schedule) {
const groups = {}
for (const w of (schedule || [])) {
const week = w.week_number
if (!groups[week]) groups[week] = []
groups[week].push(w)
}
return Object.entries(groups).sort(([a], [b]) => Number(a) - Number(b))
}

View file

@ -0,0 +1,292 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
const DIFFICULTY_COLORS = {
beginner: 'var(--green)',
intermediate: 'var(--blue)',
advanced: 'var(--purple)',
}
const CATEGORY_ICONS = {
strength: '🏋️',
cardio: '🏃',
flexibility: '🧘',
hybrid: '⚡',
}
const STATUS_LABELS = {
pending: { label: 'Queued', color: 'var(--amber)' },
crawling: { label: 'Fetching...', color: 'var(--blue)' },
extracting: { label: 'Analyzing...', color: 'var(--purple)' },
ready: { label: 'Ready', color: 'var(--green)' },
failed: { label: 'Failed', color: 'var(--red)' },
}
export default function ProgramSearch() {
const [query, setQuery] = useState('')
const [catalog, setCatalog] = useState([])
const [userPrograms, setUserPrograms] = useState([])
const [loading, setLoading] = useState(true)
const [researching, setResearching] = useState(false)
const [message, setMessage] = useState(null)
const navigate = useNavigate()
useEffect(() => { loadData() }, [])
async function loadData() {
try {
const [cat, progs] = await Promise.all([
api.searchCatalog(''),
api.listPrograms(),
])
setCatalog(cat)
setUserPrograms(progs)
} catch (err) { console.error(err) }
finally { setLoading(false) }
}
async function handleSearch(e) {
e.preventDefault()
if (!query.trim()) return
try {
const results = await api.searchCatalog(query)
setCatalog(results)
} catch (err) { console.error(err) }
}
async function handleResearch() {
if (!query.trim()) return
setResearching(true)
setMessage(null)
try {
const result = await api.researchProgram(query)
if (result.already_exists) {
setMessage({ type: 'info', text: `"${result.name}" is already in the catalog.` })
} else {
setMessage({
type: 'success',
text: result.message || 'Program queued for research!',
})
}
await loadData()
} catch (err) {
setMessage({ type: 'error', text: err.message })
}
finally { setResearching(false) }
}
async function handleAdopt(catalogId) {
const today = new Date().toISOString().slice(0, 10)
try {
await api.adoptProgram(catalogId, today)
setMessage({ type: 'success', text: 'Program started! Check your dashboard.' })
await loadData()
} catch (err) {
setMessage({ type: 'error', text: err.message })
}
}
// Check if user already has a catalog program active
function isAdopted(catalogId) {
return userPrograms.some(p => p.status === 'active' && p.catalog_id === catalogId)
}
if (loading) return <div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-3)' }}>Loading...</div>
return (
<div style={{ padding: '1rem', maxWidth: '600px', margin: '0 auto' }}>
{/* Header */}
<h1 style={{ fontFamily: 'var(--font-display)', fontSize: '1.6rem', fontWeight: 900, marginBottom: '0.5rem' }}>
Programs
</h1>
<p style={{ color: 'var(--text-3)', fontSize: '0.85rem', marginBottom: '1.5rem' }}>
Find a program or tell us what you're doing — we'll set it up for you.
</p>
{/* Search / Research */}
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder='e.g. "StrongLifts 5x5" or "Couch to 5K"'
style={{
flex: 1, padding: '12px 16px', borderRadius: 'var(--r)', border: '1px solid var(--divider)',
fontFamily: 'var(--font)', fontSize: '0.9rem', background: 'var(--card)',
}}
/>
<button type="submit" style={{
background: 'var(--blue)', color: '#fff', padding: '12px 18px',
boxShadow: 'var(--shadow-1)',
}}>Search</button>
</form>
<button
onClick={handleResearch}
disabled={researching || !query.trim()}
style={{
width: '100%', background: researching ? 'var(--text-3)' : 'var(--orange)',
color: '#fff', padding: '14px', marginBottom: '1rem',
boxShadow: researching ? 'none' : 'var(--shadow-orange)',
opacity: !query.trim() ? 0.5 : 1,
}}
>
{researching ? 'Researching...' : `Research "${query || '...'}" for me`}
</button>
{/* Message */}
{message && (
<div style={{
padding: '12px 16px', borderRadius: 'var(--r-sm)', marginBottom: '1rem',
fontSize: '0.85rem', fontWeight: 600,
background: message.type === 'error' ? 'var(--red-ghost)' : message.type === 'success' ? 'var(--green-ghost)' : 'var(--blue-ghost)',
color: message.type === 'error' ? 'var(--red)' : message.type === 'success' ? 'var(--green-dark)' : 'var(--blue)',
}}>
{message.text}
</div>
)}
{/* Active Programs */}
{userPrograms.filter(p => p.status === 'active').length > 0 && (
<>
<h2 style={{ fontFamily: 'var(--font-display)', fontSize: '1.1rem', fontWeight: 800, marginBottom: '0.75rem' }}>
Your Active Programs
</h2>
{userPrograms.filter(p => p.status === 'active').map(p => (
<div
key={p.id}
onClick={() => navigate(`/programs/${p.id}`)}
style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '0.75rem', boxShadow: 'var(--shadow-1)', cursor: 'pointer',
border: '2px solid var(--orange-glow)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1rem' }}>{p.name}</strong>
<span style={{ fontSize: '0.8rem', color: 'var(--orange)', fontWeight: 700 }}>
Day {p.current_day}/{p.total_days}
</span>
</div>
<div style={{
marginTop: '8px', height: '6px', borderRadius: '3px', background: 'var(--divider)',
}}>
<div style={{
height: '100%', borderRadius: '3px', background: 'var(--orange)',
width: `${Math.min(100, (p.current_day / p.total_days) * 100)}%`,
transition: 'width 0.3s ease',
}} />
</div>
</div>
))}
</>
)}
{/* Catalog */}
<h2 style={{
fontFamily: 'var(--font-display)', fontSize: '1.1rem', fontWeight: 800,
marginTop: '1.5rem', marginBottom: '0.75rem',
}}>
Program Catalog
</h2>
{catalog.length === 0 && (
<p style={{ color: 'var(--text-3)', fontSize: '0.85rem', textAlign: 'center', padding: '2rem 0' }}>
No programs found. Try searching or researching a program above.
</p>
)}
{catalog.map(p => {
const status = STATUS_LABELS[p.crawl_status] || STATUS_LABELS.pending
const adopted = isAdopted(p.id)
return (
<div key={p.id} style={{
background: 'var(--card)', borderRadius: 'var(--r)', padding: '16px',
marginBottom: '0.75rem', boxShadow: 'var(--shadow-1)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<strong style={{ fontFamily: 'var(--font-display)', fontSize: '1rem' }}>
{CATEGORY_ICONS[p.category] || '📋'} {p.name}
</strong>
{p.description && (
<p style={{ color: 'var(--text-2)', fontSize: '0.82rem', marginTop: '4px' }}>
{p.description}
</p>
)}
</div>
<span style={{
fontSize: '0.7rem', fontWeight: 700, padding: '3px 8px',
borderRadius: 'var(--r-full)', color: '#fff', background: status.color,
whiteSpace: 'nowrap',
}}>
{status.label}
</span>
</div>
{/* Meta tags */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '10px' }}>
{p.duration_weeks && (
<span style={tagStyle}>{p.duration_weeks} weeks</span>
)}
{p.frequency_per_week && (
<span style={tagStyle}>{p.frequency_per_week}x/week</span>
)}
{p.difficulty && (
<span style={{
...tagStyle,
color: DIFFICULTY_COLORS[p.difficulty] || 'var(--text-3)',
background: `${DIFFICULTY_COLORS[p.difficulty] || 'var(--text-3)'}11`,
}}>
{p.difficulty}
</span>
)}
{(p.equipment || []).slice(0, 3).map(eq => (
<span key={eq} style={tagStyle}>{eq}</span>
))}
</div>
{/* Action button */}
{p.crawl_status === 'ready' && !adopted && (
<button
onClick={() => handleAdopt(p.id)}
style={{
marginTop: '12px', width: '100%', background: 'var(--green)', color: '#fff',
padding: '10px', fontSize: '0.85rem', boxShadow: 'var(--shadow-green)',
}}
>
Start Program
</button>
)}
{adopted && (
<div style={{
marginTop: '12px', textAlign: 'center', fontSize: '0.82rem',
color: 'var(--green-dark)', fontWeight: 700,
}}>
Active
</div>
)}
{p.crawl_status === 'ready' && (
<button
onClick={() => navigate(`/catalog/${p.id}`)}
style={{
marginTop: adopted ? '4px' : '8px', width: '100%', background: 'transparent',
color: 'var(--blue)', padding: '8px', fontSize: '0.82rem',
border: '1px solid var(--divider)',
}}
>
View Details
</button>
)}
</div>
)
})}
</div>
)
}
const tagStyle = {
fontSize: '0.72rem', fontWeight: 600, padding: '3px 10px',
borderRadius: 'var(--r-full)', background: 'var(--divider)', color: 'var(--text-2)',
}