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:
2026-02-01 11:50:52 +01:00
parent b034bb7b11
commit 66812f9db2
5 changed files with 1024 additions and 4 deletions
+3 -3
View File
@@ -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>
+299
View File
@@ -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
+327
View File
@@ -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