Add ProfilePage and ProgressPage
ProfilePage: - View/edit user info (name, age, height, goal, level) - Show current measurements (weight, body fat, waist, neck) - Show strength records (bench/squat/deadlift 1RM) ProgressPage: - Tab navigation (weight, body fat, strength) - SVG line charts for progress visualization - Stats showing current, first, and change - Trend indicators (up/down) Dashboard: - Navigation icons for profile (👤) and progress (📊) - Connected navigation to App.jsx routing
This commit is contained in:
@@ -773,3 +773,380 @@
|
||||
.upcoming-arrow {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROFILE PAGE STYLES
|
||||
============================================ */
|
||||
|
||||
.profile-page,
|
||||
.progress-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.profile-page.loading,
|
||||
.progress-page.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
background: var(--bg-secondary);
|
||||
padding: 1rem 1.25rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Page Main */
|
||||
.page-main {
|
||||
padding: 1rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Profile Section */
|
||||
.profile-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Info Grid */
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Edit Form */
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Measurements Grid */
|
||||
.measurements-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.measurement-card {
|
||||
background: var(--bg);
|
||||
padding: 1rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.measurement-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.measurement-value {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.measurement-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Strength Grid */
|
||||
.strength-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.strength-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.strength-exercise {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.strength-value {
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
PROGRESS PAGE STYLES
|
||||
============================================ */
|
||||
|
||||
.progress-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* Chart Section */
|
||||
.chart-section {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chart-section h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.line-chart {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chart-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
/* Strength Charts */
|
||||
.strength-charts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.strength-chart-item h3 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* Progress Stats */
|
||||
.progress-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.progress-stats .stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.progress-stats .stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.progress-stats .stat-value {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trend-up {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.trend-down {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.trend-neutral {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
+18
-1
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from './context/AuthContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ProfilePage from './pages/ProfilePage'
|
||||
import ProgressPage from './pages/ProgressPage'
|
||||
import './App.css'
|
||||
|
||||
const API_URL = '/api'
|
||||
@@ -95,7 +97,22 @@ function App() {
|
||||
|
||||
// Dashboard view (default after login)
|
||||
if (view === 'dashboard') {
|
||||
return <Dashboard onStartWorkout={startWorkout} />
|
||||
return (
|
||||
<Dashboard
|
||||
onStartWorkout={startWorkout}
|
||||
onNavigate={setView}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Profile page
|
||||
if (view === 'profile') {
|
||||
return <ProfilePage onBack={() => setView('dashboard')} />
|
||||
}
|
||||
|
||||
// Progress page
|
||||
if (view === 'progress') {
|
||||
return <ProgressPage onBack={() => setView('dashboard')} />
|
||||
}
|
||||
|
||||
// Workout view
|
||||
|
||||
@@ -30,7 +30,7 @@ const getCoachGreeting = (user, todayWorkout) => {
|
||||
// Get weekday names
|
||||
const weekdays = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
|
||||
|
||||
function Dashboard({ onStartWorkout }) {
|
||||
function Dashboard({ onStartWorkout, onNavigate }) {
|
||||
const { user, logout } = useAuth()
|
||||
const [program, setProgram] = useState(null)
|
||||
const [todayWorkout, setTodayWorkout] = useState(null)
|
||||
@@ -81,8 +81,8 @@ function Dashboard({ onStartWorkout }) {
|
||||
<h1>🏋️ Gravl</h1>
|
||||
<nav className="nav-menu">
|
||||
<button className="nav-btn active">Hem</button>
|
||||
<button className="nav-btn">Historik</button>
|
||||
<button className="nav-btn">Profil</button>
|
||||
<button className="nav-btn" onClick={() => onNavigate('progress')}>📊</button>
|
||||
<button className="nav-btn" onClick={() => onNavigate('profile')}>👤</button>
|
||||
<button className="nav-btn logout" onClick={logout}>↪</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function ProfilePage({ onBack }) {
|
||||
const { user, logout } = useAuth()
|
||||
const [profile, setProfile] = useState(null)
|
||||
const [measurements, setMeasurements] = useState(null)
|
||||
const [strength, setStrength] = useState(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Edit form state
|
||||
const [form, setForm] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [profileRes, measurementsRes, strengthRes] = await Promise.all([
|
||||
fetch(`${API_URL}/user/profile/${user?.id || 1}`),
|
||||
fetch(`${API_URL}/user/measurements/${user?.id || 1}`),
|
||||
fetch(`${API_URL}/user/strength/${user?.id || 1}`)
|
||||
])
|
||||
|
||||
const profileData = await profileRes.json()
|
||||
const measurementsData = await measurementsRes.json()
|
||||
const strengthData = await strengthRes.json()
|
||||
|
||||
setProfile(profileData)
|
||||
setMeasurements(measurementsData)
|
||||
setStrength(strengthData)
|
||||
setForm(profileData)
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch profile:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/user/profile/${user?.id || 1}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form)
|
||||
})
|
||||
const updated = await res.json()
|
||||
setProfile(updated)
|
||||
setEditing(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save profile:', err)
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setForm(prev => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="profile-page loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laddar profil...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const latestMeasurement = measurements?.[0]
|
||||
const latestStrength = strength?.[0]
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<header className="page-header">
|
||||
<button className="back-btn" onClick={onBack}>← Tillbaka</button>
|
||||
<h1>Min profil</h1>
|
||||
<button className="logout-btn" onClick={logout}>↪</button>
|
||||
</header>
|
||||
|
||||
<main className="page-main">
|
||||
{/* Profile Info */}
|
||||
<section className="profile-section">
|
||||
<div className="section-header">
|
||||
<h2>Personuppgifter</h2>
|
||||
{!editing && (
|
||||
<button className="edit-btn" onClick={() => setEditing(true)}>
|
||||
✏️ Redigera
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="edit-form">
|
||||
<div className="form-group">
|
||||
<label>Namn</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name || ''}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Ålder</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.age || ''}
|
||||
onChange={(e) => handleChange('age', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Kön</label>
|
||||
<select
|
||||
value={form.gender || ''}
|
||||
onChange={(e) => handleChange('gender', e.target.value)}
|
||||
>
|
||||
<option value="male">Man</option>
|
||||
<option value="female">Kvinna</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Längd (cm)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.height_cm || ''}
|
||||
onChange={(e) => handleChange('height_cm', parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Pass/vecka</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="7"
|
||||
value={form.workouts_per_week || ''}
|
||||
onChange={(e) => handleChange('workouts_per_week', parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Mål</label>
|
||||
<select
|
||||
value={form.goal || ''}
|
||||
onChange={(e) => handleChange('goal', e.target.value)}
|
||||
>
|
||||
<option value="muscle">Bygga muskler</option>
|
||||
<option value="strength">Öka styrka</option>
|
||||
<option value="fat_loss">Fettförbränning</option>
|
||||
<option value="general">Allmän fitness</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Erfarenhetsnivå</label>
|
||||
<select
|
||||
value={form.experience_level || ''}
|
||||
onChange={(e) => handleChange('experience_level', e.target.value)}
|
||||
>
|
||||
<option value="beginner">Nybörjare</option>
|
||||
<option value="intermediate">Medel</option>
|
||||
<option value="advanced">Avancerad</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="cancel-btn" onClick={() => { setEditing(false); setForm(profile) }}>
|
||||
Avbryt
|
||||
</button>
|
||||
<button className="save-btn" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Sparar...' : 'Spara'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="profile-info">
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<span className="info-label">Namn</span>
|
||||
<span className="info-value">{profile?.name || '-'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Ålder</span>
|
||||
<span className="info-value">{profile?.age || '-'} år</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Längd</span>
|
||||
<span className="info-value">{profile?.height_cm || '-'} cm</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Kön</span>
|
||||
<span className="info-value">{profile?.gender === 'male' ? 'Man' : 'Kvinna'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Mål</span>
|
||||
<span className="info-value">{getGoalLabel(profile?.goal)}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Nivå</span>
|
||||
<span className="info-value">{getLevelLabel(profile?.experience_level)}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<span className="info-label">Pass/vecka</span>
|
||||
<span className="info-value">{profile?.workouts_per_week || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Current Measurements */}
|
||||
<section className="profile-section">
|
||||
<h2>Aktuella mätningar</h2>
|
||||
{latestMeasurement ? (
|
||||
<div className="measurements-grid">
|
||||
<div className="measurement-card">
|
||||
<span className="measurement-icon">⚖️</span>
|
||||
<span className="measurement-value">{latestMeasurement.weight} kg</span>
|
||||
<span className="measurement-label">Vikt</span>
|
||||
</div>
|
||||
{latestMeasurement.body_fat_pct && (
|
||||
<div className="measurement-card">
|
||||
<span className="measurement-icon">📊</span>
|
||||
<span className="measurement-value">{latestMeasurement.body_fat_pct}%</span>
|
||||
<span className="measurement-label">Kroppsfett</span>
|
||||
</div>
|
||||
)}
|
||||
{latestMeasurement.waist_cm && (
|
||||
<div className="measurement-card">
|
||||
<span className="measurement-icon">📏</span>
|
||||
<span className="measurement-value">{latestMeasurement.waist_cm} cm</span>
|
||||
<span className="measurement-label">Midja</span>
|
||||
</div>
|
||||
)}
|
||||
{latestMeasurement.neck_cm && (
|
||||
<div className="measurement-card">
|
||||
<span className="measurement-icon">📏</span>
|
||||
<span className="measurement-value">{latestMeasurement.neck_cm} cm</span>
|
||||
<span className="measurement-label">Nacke</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-data">Inga mätningar registrerade</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Strength Records */}
|
||||
<section className="profile-section">
|
||||
<h2>Styrkerekord (1RM)</h2>
|
||||
{latestStrength ? (
|
||||
<div className="strength-grid">
|
||||
<div className="strength-card">
|
||||
<span className="strength-exercise">Bänkpress</span>
|
||||
<span className="strength-value">{latestStrength.bench_1rm || '-'} kg</span>
|
||||
</div>
|
||||
<div className="strength-card">
|
||||
<span className="strength-exercise">Knäböj</span>
|
||||
<span className="strength-value">{latestStrength.squat_1rm || '-'} kg</span>
|
||||
</div>
|
||||
<div className="strength-card">
|
||||
<span className="strength-exercise">Marklyft</span>
|
||||
<span className="strength-value">{latestStrength.deadlift_1rm || '-'} kg</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="no-data">Inga styrkerekord registrerade</p>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getGoalLabel(goal) {
|
||||
const labels = {
|
||||
muscle: 'Bygga muskler',
|
||||
strength: 'Öka styrka',
|
||||
fat_loss: 'Fettförbränning',
|
||||
general: 'Allmän fitness'
|
||||
}
|
||||
return labels[goal] || goal || '-'
|
||||
}
|
||||
|
||||
function getLevelLabel(level) {
|
||||
const labels = {
|
||||
beginner: 'Nybörjare',
|
||||
intermediate: 'Medel',
|
||||
advanced: 'Avancerad'
|
||||
}
|
||||
return labels[level] || level || '-'
|
||||
}
|
||||
|
||||
export default ProfilePage
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
const API_URL = '/api'
|
||||
|
||||
function ProgressPage({ onBack }) {
|
||||
const { user } = useAuth()
|
||||
const [measurements, setMeasurements] = useState([])
|
||||
const [strength, setStrength] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState('weight')
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [measurementsRes, strengthRes] = await Promise.all([
|
||||
fetch(`${API_URL}/user/measurements/${user?.id || 1}`),
|
||||
fetch(`${API_URL}/user/strength/${user?.id || 1}`)
|
||||
])
|
||||
|
||||
const measurementsData = await measurementsRes.json()
|
||||
const strengthData = await strengthRes.json()
|
||||
|
||||
// Sort by date ascending for charts
|
||||
setMeasurements([...measurementsData].reverse())
|
||||
setStrength([...strengthData].reverse())
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch progress:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="progress-page loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Laddar progress...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="progress-page">
|
||||
<header className="page-header">
|
||||
<button className="back-btn" onClick={onBack}>← Tillbaka</button>
|
||||
<h1>Min progress</h1>
|
||||
<div style={{ width: 40 }}></div>
|
||||
</header>
|
||||
|
||||
<main className="page-main">
|
||||
{/* Tab Navigation */}
|
||||
<div className="progress-tabs">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'weight' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('weight')}
|
||||
>
|
||||
⚖️ Vikt
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'bodyfat' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('bodyfat')}
|
||||
>
|
||||
📊 Kroppsfett
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'strength' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('strength')}
|
||||
>
|
||||
💪 Styrka
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weight Chart */}
|
||||
{activeTab === 'weight' && (
|
||||
<section className="chart-section">
|
||||
<h2>Viktutveckling</h2>
|
||||
{measurements.length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
color="var(--accent)"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements}
|
||||
valueKey="weight"
|
||||
unit="kg"
|
||||
label="Vikt"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga viktmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Body Fat Chart */}
|
||||
{activeTab === 'bodyfat' && (
|
||||
<section className="chart-section">
|
||||
<h2>Kroppsfett</h2>
|
||||
{measurements.filter(m => m.body_fat_pct).length > 0 ? (
|
||||
<>
|
||||
<SimpleLineChart
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
color="#10b981"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={measurements.filter(m => m.body_fat_pct)}
|
||||
valueKey="body_fat_pct"
|
||||
unit="%"
|
||||
label="Kroppsfett"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyState message="Inga kroppsfettmätningar registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Strength Charts */}
|
||||
{activeTab === 'strength' && (
|
||||
<section className="chart-section">
|
||||
<h2>Styrkerekord (1RM)</h2>
|
||||
{strength.length > 0 ? (
|
||||
<div className="strength-charts">
|
||||
<div className="strength-chart-item">
|
||||
<h3>🏋️ Bänkpress</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
color="#f59e0b"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.bench_1rm)}
|
||||
valueKey="bench_1rm"
|
||||
unit="kg"
|
||||
label="Bänkpress"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3>🦵 Knäböj</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.squat_1rm)}
|
||||
valueKey="squat_1rm"
|
||||
unit="kg"
|
||||
label="Knäböj"
|
||||
/>
|
||||
</div>
|
||||
<div className="strength-chart-item">
|
||||
<h3>💀 Marklyft</h3>
|
||||
<SimpleLineChart
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
color="#ef4444"
|
||||
/>
|
||||
<ProgressStats
|
||||
data={strength.filter(s => s.deadlift_1rm)}
|
||||
valueKey="deadlift_1rm"
|
||||
unit="kg"
|
||||
label="Marklyft"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState message="Inga styrkerekord registrerade" />
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simple SVG Line Chart Component
|
||||
function SimpleLineChart({ data, valueKey, unit, color }) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const values = data.map(d => d[valueKey]).filter(v => v != null)
|
||||
if (values.length === 0) return null
|
||||
|
||||
const min = Math.min(...values) * 0.95
|
||||
const max = Math.max(...values) * 1.05
|
||||
const range = max - min || 1
|
||||
|
||||
const width = 320
|
||||
const height = 160
|
||||
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
|
||||
const chartWidth = width - padding.left - padding.right
|
||||
const chartHeight = height - padding.top - padding.bottom
|
||||
|
||||
// Generate points
|
||||
const points = data.map((d, i) => {
|
||||
const x = padding.left + (i / Math.max(data.length - 1, 1)) * chartWidth
|
||||
const y = padding.top + chartHeight - ((d[valueKey] - min) / range) * chartHeight
|
||||
return { x, y, value: d[valueKey], date: d.created_at }
|
||||
}).filter(p => p.value != null)
|
||||
|
||||
const pathD = points.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`).join(' ')
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = [min, (min + max) / 2, max].map(v => v.toFixed(1))
|
||||
|
||||
return (
|
||||
<div className="chart-container">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="line-chart">
|
||||
{/* Grid lines */}
|
||||
{[0, 0.5, 1].map((ratio, i) => (
|
||||
<line
|
||||
key={i}
|
||||
x1={padding.left}
|
||||
y1={padding.top + chartHeight * (1 - ratio)}
|
||||
x2={width - padding.right}
|
||||
y2={padding.top + chartHeight * (1 - ratio)}
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="4"
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Y-axis labels */}
|
||||
{yLabels.map((label, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={padding.left - 8}
|
||||
y={padding.top + chartHeight * (1 - i * 0.5) + 4}
|
||||
textAnchor="end"
|
||||
fontSize="10"
|
||||
fill="var(--text-muted)"
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* Line */}
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Points */}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
fill={color}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
<div className="chart-labels">
|
||||
<span>{formatDate(data[0]?.created_at)}</span>
|
||||
<span>{formatDate(data[data.length - 1]?.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Progress Statistics Component
|
||||
function ProgressStats({ data, valueKey, unit, label }) {
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
const values = data.map(d => d[valueKey]).filter(v => v != null)
|
||||
if (values.length === 0) return null
|
||||
|
||||
const current = values[values.length - 1]
|
||||
const first = values[0]
|
||||
const change = current - first
|
||||
const changePercent = ((change / first) * 100).toFixed(1)
|
||||
const trend = change > 0 ? '↑' : change < 0 ? '↓' : '→'
|
||||
const trendClass = change > 0 ? 'up' : change < 0 ? 'down' : 'neutral'
|
||||
|
||||
return (
|
||||
<div className="progress-stats">
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Nuvarande</span>
|
||||
<span className="stat-value">{current} {unit}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Första</span>
|
||||
<span className="stat-value">{first} {unit}</span>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span className="stat-label">Förändring</span>
|
||||
<span className={`stat-value trend-${trendClass}`}>
|
||||
{trend} {Math.abs(change).toFixed(1)} {unit} ({changePercent}%)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyState({ message }) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-icon">📊</span>
|
||||
<p>{message}</p>
|
||||
<p className="empty-hint">Logga mätningar för att se din progress</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export default ProgressPage
|
||||
Reference in New Issue
Block a user