feat(06-01): Exercise recommendations API endpoint + frontend components (coach-assisted suggestions)

This commit is contained in:
2026-03-03 03:54:12 +01:00
parent f580fa81a6
commit fbba2d894d
12 changed files with 1313 additions and 93 deletions
@@ -0,0 +1,88 @@
import './exerciseRecommendations.css'
const difficultyTokens = {
easy: { label: 'Easy', className: 'difficulty-easy' },
medium: { label: 'Medium', className: 'difficulty-medium' },
med: { label: 'Medium', className: 'difficulty-medium' },
hard: { label: 'Hard', className: 'difficulty-hard' }
}
const normalizeDifficulty = (difficulty) => {
if (!difficulty) return null
const key = String(difficulty).trim().toLowerCase()
return difficultyTokens[key] || { label: difficulty, className: 'difficulty-custom' }
}
const formatDuration = (exercise) => {
const value = exercise?.duration ?? exercise?.duration_min ?? exercise?.durationMinutes
if (!value) return null
return `${value} min`
}
const formatReps = (exercise) => {
const { reps, reps_min, reps_max, repsMin, repsMax } = exercise || {}
if (reps) return `${reps} reps`
const min = reps_min ?? repsMin
const max = reps_max ?? repsMax
if (min && max) return `${min}-${max} reps`
if (min) return `${min}+ reps`
return null
}
function ExerciseCard({
exercise,
onSelect,
className = '',
compact = false,
showMeta = true
}) {
if (!exercise) return null
const difficulty = normalizeDifficulty(exercise.difficulty)
const duration = formatDuration(exercise)
const reps = formatReps(exercise)
const imageSrc = exercise.image_url || exercise.image || exercise.imageUrl
const Element = onSelect ? 'button' : 'article'
return (
<Element
type={onSelect ? 'button' : undefined}
className={`exercise-recommendation-card ${compact ? 'is-compact' : ''} ${className}`}
onClick={onSelect ? () => onSelect(exercise) : undefined}
>
<div className="exercise-card-media">
{imageSrc ? (
<img src={imageSrc} alt={exercise.name} loading="lazy" />
) : (
<div className="exercise-card-placeholder" aria-hidden="true">
<span>{exercise.name?.slice(0, 1) || 'E'}</span>
</div>
)}
</div>
<div className="exercise-card-content">
<div className="exercise-card-header">
<h3>{exercise.name}</h3>
{difficulty && (
<span className={`difficulty-badge ${difficulty.className}`}>
{difficulty.label}
</span>
)}
</div>
{exercise.description && !compact && (
<p className="exercise-card-description">{exercise.description}</p>
)}
{showMeta && (duration || reps) && (
<div className="exercise-card-meta">
{duration && <span className="exercise-meta-pill">{duration}</span>}
{reps && <span className="exercise-meta-pill">{reps}</span>}
</div>
)}
</div>
</Element>
)
}
export default ExerciseCard
@@ -0,0 +1,70 @@
import './exerciseRecommendations.css'
const resolveStatus = (level, index, activeIndex) => {
if (level.status) return level.status
if (activeIndex == null) return 'available'
if (index < activeIndex) return 'completed'
if (index === activeIndex) return 'current'
return 'locked'
}
function ProgressionTracker({
title = 'Progression Path',
levels = [],
activeLevelId,
activeIndex,
onSelect,
className = ''
}) {
const resolvedActiveIndex = activeIndex != null
? activeIndex
: levels.findIndex(level => level.id === activeLevelId)
return (
<section className={`progression-tracker ${className}`}>
<header className="progression-tracker-header">
<h2>{title}</h2>
</header>
<div className="progression-track">
{levels.map((level, index) => {
const status = resolveStatus(level, index, resolvedActiveIndex)
const levelClass = `progression-level is-${status}`
const content = (
<>
<div className="progression-node" aria-hidden="true">
{index + 1}
</div>
<div className="progression-info">
<h3>{level.label}</h3>
{level.description && <p>{level.description}</p>}
</div>
</>
)
return (
<div
key={level.id || level.label}
className={levelClass}
aria-current={status === 'current' ? 'step' : undefined}
>
{onSelect ? (
<button
type="button"
className="progression-level-button"
onClick={() => onSelect(level, index)}
>
{content}
</button>
) : (
content
)}
</div>
)
})}
</div>
</section>
)
}
export default ProgressionTracker
@@ -0,0 +1,79 @@
import ExerciseCard from './ExerciseCard'
import './exerciseRecommendations.css'
const normalizeGroupLabel = (item) => {
return item.group || item.category || item.level || item.progression_level || 'Recommended'
}
const groupRecommendations = (items) => {
if (!Array.isArray(items)) return []
const groups = items.reduce((acc, item) => {
const label = normalizeGroupLabel(item)
if (!acc[label]) acc[label] = []
acc[label].push(item)
return acc
}, {})
return Object.entries(groups).map(([title, recommendations]) => ({
id: title,
title,
recommendations
}))
}
function RecommendationPanel({
title = 'Recommended Exercises',
subtitle,
recommendations = [],
groups,
layout = 'grid',
onSelect,
emptyMessage = 'No recommendations available yet.',
className = ''
}) {
const resolvedGroups = Array.isArray(groups) && groups.length > 0
? groups
: groupRecommendations(recommendations)
const hasContent = resolvedGroups.some(group => group.recommendations?.length)
return (
<section className={`recommendation-panel ${className}`}>
<div className="recommendation-panel-header">
<div>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
</div>
{!hasContent && (
<div className="recommendation-empty">{emptyMessage}</div>
)}
{hasContent && (
<div className="recommendation-panel-body">
{resolvedGroups.map(group => (
<div key={group.id || group.title} className="recommendation-group">
<div className="recommendation-group-header">
<h3>{group.title}</h3>
{group.description && <span>{group.description}</span>}
</div>
<div className={`recommendation-list recommendation-list--${layout}`}>
{(group.recommendations || group.items || []).map(item => (
<ExerciseCard
key={item.id || `${group.title}-${item.name}`}
exercise={item}
onSelect={onSelect}
compact={layout === 'list'}
/>
))}
</div>
</div>
))}
</div>
)}
</section>
)
}
export default RecommendationPanel
@@ -0,0 +1,324 @@
.recommendation-panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.recommendation-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.recommendation-panel-header h2 {
font-size: var(--font-xl);
margin-bottom: var(--space-1);
}
.recommendation-panel-header p {
color: var(--text-secondary);
font-size: var(--font-sm);
}
.recommendation-panel-body {
display: flex;
flex-direction: column;
gap: var(--space-6);
}
.recommendation-empty {
color: var(--text-secondary);
font-size: var(--font-sm);
padding: var(--space-4);
border-radius: var(--radius-lg);
background: var(--bg-secondary);
border: 1px dashed var(--border);
}
.recommendation-group-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
margin-bottom: var(--space-3);
}
.recommendation-group-header h3 {
font-size: var(--font-lg);
}
.recommendation-group-header span {
color: var(--text-muted);
font-size: var(--font-xs);
}
.recommendation-list {
display: grid;
gap: var(--space-3);
}
.recommendation-list--grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.recommendation-list--list {
grid-template-columns: 1fr;
}
.exercise-recommendation-card {
display: flex;
gap: var(--space-3);
align-items: stretch;
padding: var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
text-align: left;
transition: transform var(--transition-base), border-color var(--transition-base), box-shadow var(--transition-base);
}
.exercise-recommendation-card:hover {
transform: translateY(-2px);
border-color: var(--border-hover);
box-shadow: var(--shadow-md);
}
.exercise-recommendation-card.is-compact {
align-items: center;
}
.exercise-card-media {
width: 72px;
height: 72px;
flex: 0 0 auto;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-secondary);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
}
.exercise-card-media img {
width: 100%;
height: 100%;
object-fit: cover;
}
.exercise-card-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-weight: 700;
font-size: var(--font-lg);
background: linear-gradient(135deg, var(--bg-card), var(--bg-secondary));
}
.exercise-card-content {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.exercise-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.exercise-card-header h3 {
font-size: var(--font-base);
}
.exercise-card-description {
color: var(--text-secondary);
font-size: var(--font-xs);
}
.exercise-card-meta {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.exercise-meta-pill {
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background: var(--bg-secondary);
border: 1px solid var(--border);
font-size: var(--font-xs);
color: var(--text-secondary);
}
.difficulty-badge {
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: var(--font-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.difficulty-easy {
background: var(--success-subtle);
color: var(--success);
}
.difficulty-medium {
background: var(--warning-subtle);
color: var(--warning);
}
.difficulty-hard {
background: var(--error-subtle);
color: var(--error);
}
.difficulty-custom {
background: var(--accent-subtle);
color: var(--accent);
}
.progression-tracker {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
padding: var(--space-5);
box-shadow: var(--shadow-card);
}
.progression-tracker-header {
margin-bottom: var(--space-4);
}
.progression-tracker-header h2 {
font-size: var(--font-lg);
}
.progression-track {
display: grid;
gap: var(--space-3);
}
.progression-level {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
}
.progression-node {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
color: var(--text-secondary);
background: var(--bg-secondary);
position: relative;
}
.progression-node::after {
content: '';
position: absolute;
top: 34px;
left: 50%;
width: 2px;
height: calc(100% + var(--space-3));
transform: translateX(-50%);
background: var(--border);
}
.progression-level:last-child .progression-node::after {
display: none;
}
.progression-level.is-completed .progression-node,
.progression-level.is-current .progression-node {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-subtle);
}
.progression-level.is-completed .progression-node {
color: var(--success);
border-color: var(--success);
background: var(--success-subtle);
}
.progression-level.is-locked .progression-node {
opacity: 0.5;
}
.progression-info h3 {
font-size: var(--font-base);
margin-bottom: var(--space-1);
}
.progression-info p {
color: var(--text-secondary);
font-size: var(--font-sm);
}
.progression-level.is-current .progression-info h3 {
color: var(--accent);
}
.progression-level.is-completed .progression-info h3 {
color: var(--success);
}
.progression-level-button {
background: transparent;
border: none;
padding: 0;
text-align: left;
color: inherit;
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
align-items: center;
width: 100%;
}
@media (min-width: 720px) {
.progression-track {
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
.progression-level {
grid-template-columns: 1fr;
text-align: center;
}
.progression-node::after {
top: 50%;
left: 36px;
width: calc(100% + var(--space-3));
height: 2px;
transform: translateY(-50%);
}
.progression-level:last-child .progression-node::after {
display: none;
}
.progression-level,
.progression-level-button {
justify-items: center;
}
}
@@ -0,0 +1,50 @@
export type Difficulty = 'Easy' | 'Medium' | 'Hard' | 'Beginner' | 'Intermediate' | 'Advanced'
export interface ExerciseRecommendation {
id?: string | number
name: string
description?: string
difficulty?: Difficulty | string
duration?: number
duration_min?: number
durationMinutes?: number
reps?: string | number
reps_min?: number
reps_max?: number
repsMin?: number
repsMax?: number
image_url?: string
image?: string
imageUrl?: string
group?: string
category?: string
level?: string
progression_level?: string
equipment?: string[]
tags?: string[]
rationale?: string
}
export interface RecommendationGroup {
id?: string
title: string
description?: string
recommendations?: ExerciseRecommendation[]
items?: ExerciseRecommendation[]
}
export type ProgressionStatus = 'completed' | 'current' | 'available' | 'locked'
export interface ProgressionLevel {
id?: string
label: string
description?: string
status?: ProgressionStatus
}
export interface ExerciseRecommendationResponse {
recommendations: ExerciseRecommendation[]
groups?: RecommendationGroup[]
progression?: ProgressionLevel[]
meta?: Record<string, unknown>
}