Files
gravl/frontend/src/App.jsx
T

331 lines
10 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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