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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<title>Gravl - Träning</title>
<script type="module" crossorigin src="/assets/index-hhKetRGz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-my_lGtI5.css">
<script type="module" crossorigin src="/assets/index-aU0r4U2I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KaIXgP3q.css">
</head>
<body>
<div id="root"></div>
@@ -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>
}