331 lines
10 KiB
React
331 lines
10 KiB
React
import { useState, useEffect } from 'react'
|
||
import { useAuth } from './context/AuthContext'
|
||
import './App.css'
|
||
|
||
const API_URL = '/api'
|
||
|
||
function App() {
|
||
const { user, logout } = useAuth()
|
||
const [view, setView] = useState('program')
|
||
const [program, setProgram] = useState(null)
|
||
const [selectedDay, setSelectedDay] = useState(null)
|
||
const [currentWeek, setCurrentWeek] = useState(1)
|
||
const [logs, setLogs] = useState({})
|
||
const [loading, setLoading] = useState(true)
|
||
|
||
const userId = user?.id || 1
|
||
const today = new Date().toISOString().split('T')[0]
|
||
|
||
useEffect(() => {
|
||
fetchProgram()
|
||
}, [])
|
||
|
||
const fetchProgram = async () => {
|
||
try {
|
||
const res = await fetch(`${API_URL}/programs/1`)
|
||
const data = await res.json()
|
||
setProgram(data)
|
||
setLoading(false)
|
||
} catch (err) {
|
||
console.error('Failed to fetch program:', err)
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const fetchLogs = async (dayId) => {
|
||
try {
|
||
const day = program.days.find(d => d.id === dayId)
|
||
if (!day) return
|
||
|
||
const newLogs = {}
|
||
for (const exercise of day.exercises) {
|
||
if (!exercise.id) continue
|
||
const res = await fetch(`${API_URL}/logs?user_id=${userId}&date=${today}&program_exercise_id=${exercise.id}`)
|
||
const data = await res.json()
|
||
newLogs[exercise.id] = data
|
||
}
|
||
setLogs(newLogs)
|
||
} catch (err) {
|
||
console.error('Failed to fetch logs:', err)
|
||
}
|
||
}
|
||
|
||
const fetchProgression = async (programExerciseId) => {
|
||
try {
|
||
const res = await fetch(`${API_URL}/progression/${programExerciseId}?user_id=${userId}`)
|
||
return await res.json()
|
||
} catch (err) {
|
||
console.error('Failed to fetch progression:', err)
|
||
return null
|
||
}
|
||
}
|
||
|
||
const logSet = async (programExerciseId, setNumber, weight, reps, completed) => {
|
||
try {
|
||
const res = await fetch(`${API_URL}/logs`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
user_id: userId,
|
||
program_exercise_id: programExerciseId,
|
||
date: today,
|
||
set_number: setNumber,
|
||
weight: parseFloat(weight) || 0,
|
||
reps: parseInt(reps) || 0,
|
||
completed
|
||
})
|
||
})
|
||
const data = await res.json()
|
||
|
||
// Update local logs
|
||
setLogs(prev => ({
|
||
...prev,
|
||
[programExerciseId]: [
|
||
...(prev[programExerciseId] || []).filter(l => l.set_number !== setNumber),
|
||
data
|
||
].sort((a, b) => a.set_number - b.set_number)
|
||
}))
|
||
} catch (err) {
|
||
console.error('Failed to log set:', err)
|
||
}
|
||
}
|
||
|
||
const startWorkout = (day) => {
|
||
setSelectedDay(day)
|
||
setView('workout')
|
||
fetchLogs(day.id)
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="app loading">
|
||
<div className="spinner"></div>
|
||
<p>Laddar program...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (view === 'workout' && selectedDay) {
|
||
return (
|
||
<WorkoutView
|
||
day={selectedDay}
|
||
week={currentWeek}
|
||
logs={logs}
|
||
onLogSet={logSet}
|
||
onBack={() => setView('program')}
|
||
fetchProgression={fetchProgression}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="app">
|
||
<header className="header">
|
||
<div className="header-left">
|
||
<h1>🏋️ Gravl</h1>
|
||
<button className="logout-btn" onClick={logout}>Logga ut</button>
|
||
</div>
|
||
<div className="week-selector">
|
||
<button
|
||
onClick={() => setCurrentWeek(w => Math.max(1, w - 1))}
|
||
disabled={currentWeek === 1}
|
||
>
|
||
←
|
||
</button>
|
||
<span>Vecka {currentWeek}</span>
|
||
<button
|
||
onClick={() => setCurrentWeek(w => Math.min(program?.weeks || 6, w + 1))}
|
||
disabled={currentWeek === (program?.weeks || 6)}
|
||
>
|
||
→
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="main">
|
||
<section className="program-info">
|
||
<h2>{program?.name || 'Laddar...'}</h2>
|
||
<p>{program?.description}</p>
|
||
</section>
|
||
|
||
<section className="days-list">
|
||
<h3>Veckans pass</h3>
|
||
{program?.days?.map((day, idx) => (
|
||
<div key={day.id} className="day-card" onClick={() => startWorkout(day)}>
|
||
<div className="day-header">
|
||
<span className="day-number">Dag {day.day_number}</span>
|
||
<span className="day-name">{day.name}</span>
|
||
</div>
|
||
<div className="day-exercises">
|
||
{day.exercises?.filter(e => e.name).slice(0, 3).map((ex, i) => (
|
||
<span key={i} className="exercise-tag">{ex.name}</span>
|
||
))}
|
||
{day.exercises?.filter(e => e.name).length > 3 && (
|
||
<span className="exercise-tag more">+{day.exercises.filter(e => e.name).length - 3}</span>
|
||
)}
|
||
</div>
|
||
<div className="day-action">
|
||
Starta →
|
||
</div>
|
||
</div>
|
||
))}
|
||
</section>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WorkoutView({ day, week, logs, onLogSet, onBack, fetchProgression }) {
|
||
const [progressions, setProgressions] = useState({})
|
||
const [expandedExercise, setExpandedExercise] = useState(null)
|
||
|
||
useEffect(() => {
|
||
loadProgressions()
|
||
}, [day])
|
||
|
||
const loadProgressions = async () => {
|
||
const progs = {}
|
||
for (const exercise of day.exercises) {
|
||
if (exercise.id) {
|
||
progs[exercise.id] = await fetchProgression(exercise.id)
|
||
}
|
||
}
|
||
setProgressions(progs)
|
||
}
|
||
|
||
const exercises = day.exercises?.filter(e => e.name) || []
|
||
|
||
return (
|
||
<div className="app workout-view">
|
||
<header className="header workout-header">
|
||
<button className="back-btn" onClick={onBack}>← Tillbaka</button>
|
||
<div className="header-title">
|
||
<h1>{day.name}</h1>
|
||
<span className="header-subtitle">Vecka {week} • Dag {day.day_number}</span>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="main workout-main">
|
||
{exercises.map((exercise, idx) => (
|
||
<ExerciseCard
|
||
key={exercise.id || idx}
|
||
exercise={exercise}
|
||
logs={logs[exercise.id] || []}
|
||
progression={progressions[exercise.id]}
|
||
expanded={expandedExercise === exercise.id}
|
||
onToggle={() => setExpandedExercise(
|
||
expandedExercise === exercise.id ? null : exercise.id
|
||
)}
|
||
onLogSet={onLogSet}
|
||
/>
|
||
))}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet }) {
|
||
const [setInputs, setSetInputs] = useState({})
|
||
|
||
useEffect(() => {
|
||
// Initialize with suggested weight or last logged
|
||
const initial = {}
|
||
for (let i = 1; i <= exercise.sets; i++) {
|
||
const existingLog = logs.find(l => l.set_number === i)
|
||
initial[i] = {
|
||
weight: existingLog?.weight || progression?.suggestedWeight || '',
|
||
reps: existingLog?.reps || '',
|
||
completed: existingLog?.completed || false
|
||
}
|
||
}
|
||
setSetInputs(initial)
|
||
}, [exercise, logs, progression])
|
||
|
||
const handleInputChange = (setNum, field, value) => {
|
||
setSetInputs(prev => ({
|
||
...prev,
|
||
[setNum]: { ...prev[setNum], [field]: value }
|
||
}))
|
||
}
|
||
|
||
const handleComplete = (setNum) => {
|
||
const input = setInputs[setNum]
|
||
const newCompleted = !input.completed
|
||
setSetInputs(prev => ({
|
||
...prev,
|
||
[setNum]: { ...prev[setNum], completed: newCompleted }
|
||
}))
|
||
onLogSet(exercise.id, setNum, input.weight, input.reps, newCompleted)
|
||
}
|
||
|
||
const completedSets = Object.values(setInputs).filter(s => s.completed).length
|
||
|
||
return (
|
||
<div className={`exercise-card ${expanded ? 'expanded' : ''}`}>
|
||
<div className="exercise-header" onClick={onToggle}>
|
||
<div className="exercise-info">
|
||
<h3>{exercise.name}</h3>
|
||
<span className="muscle-group">{exercise.muscle_group}</span>
|
||
</div>
|
||
<div className="exercise-meta">
|
||
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
|
||
<span className={`progress-badge ${completedSets === exercise.sets ? 'complete' : ''}`}>
|
||
{completedSets}/{exercise.sets}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{expanded && (
|
||
<div className="exercise-body">
|
||
{progression && (
|
||
<div className="progression-hint">
|
||
💡 {progression.reason}
|
||
{progression.suggestedWeight && (
|
||
<strong> → {progression.suggestedWeight} kg</strong>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="sets-list">
|
||
{Array.from({ length: exercise.sets }, (_, i) => i + 1).map(setNum => {
|
||
const input = setInputs[setNum] || { weight: '', reps: '', completed: false }
|
||
return (
|
||
<div key={setNum} className={`set-row ${input.completed ? 'completed' : ''}`}>
|
||
<span className="set-number">Set {setNum}</span>
|
||
<div className="set-inputs">
|
||
<input
|
||
type="number"
|
||
placeholder="kg"
|
||
value={input.weight}
|
||
onChange={(e) => handleInputChange(setNum, 'weight', e.target.value)}
|
||
className="weight-input"
|
||
inputMode="decimal"
|
||
/>
|
||
<span className="input-separator">×</span>
|
||
<input
|
||
type="number"
|
||
placeholder="reps"
|
||
value={input.reps}
|
||
onChange={(e) => handleInputChange(setNum, 'reps', e.target.value)}
|
||
className="reps-input"
|
||
inputMode="numeric"
|
||
/>
|
||
</div>
|
||
<button
|
||
className={`complete-btn ${input.completed ? 'done' : ''}`}
|
||
onClick={() => handleComplete(setNum)}
|
||
>
|
||
{input.completed ? '✓' : '○'}
|
||
</button>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default App
|