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:
@@ -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