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:
parent
a81ca9c275
commit
93f24fff83
13 changed files with 1738 additions and 9 deletions
|
|
@ -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>} />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
303
frontend/src/pages/ProgramDetail.jsx
Normal file
303
frontend/src/pages/ProgramDetail.jsx
Normal 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))
|
||||
}
|
||||
292
frontend/src/pages/ProgramSearch.jsx
Normal file
292
frontend/src/pages/ProgramSearch.jsx
Normal 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)',
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue