Files
gravl/frontend/src/pages/Dashboard.jsx
T
clawd 1f2a892391 feat(frontend): Kinetic Precision design system — new lime theme, glassmorphism, redesigned pages
- New design system: Stitch (kinetic-precision.css) with lime (#cafd00) accent
- New Google Fonts: Lexend, Plus Jakarta Sans, Space Grotesk
- New page: BenchmarksPage with strength/endurance/body tracking
- Redesigned: Dashboard, ProgressPage, WorkoutPage, LoginPage + LoginPage.css
- Add shared glassmorphism nav, kinetic buttons, intensity indicators
- Build: 265KB JS / 88KB CSS / 2.54s (clean)
2026-04-27 08:49:23 +02:00

664 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
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 { Icon, getActivityIconName } from '../components/Icons'
import Logo from '../components/Logo'
import '../styles/kinetic-precision.css'
const API_URL = '/api'
// Coach greetings based on context
const getCoachGreeting = (user, todayWorkout) => {
const hour = new Date().getHours()
const name = user?.name?.split(' ')[0] || 'du'
if (todayWorkout) {
if (hour < 10) {
return `Godmorgon ${name}! Idag kör vi ${todayWorkout.name.toLowerCase()}. Redo?`
} else if (hour < 14) {
return `${todayWorkout.name} står på schemat idag. Dags att köra!`
} else if (hour < 18) {
return `Eftermiddagspass? ${todayWorkout.name} väntar på dig.`
} else {
return `Kvällspass ${name}? ${todayWorkout.name} perfekt för att avsluta dagen.`
}
} else {
if (hour < 10) {
return `Godmorgon ${name}! Vilodag idag perfekt för återhämtning.`
} else if (hour < 14) {
return `Ingen träning schemalagd. Ta en promenad eller stretcha lite?`
} else if (hour < 18) {
return `Vila är också träning! Lätt rörelse eller mobilitet idag?`
} else {
return `Lugn kväll ${name}. Ladda batterierna till nästa pass!`
}
}
}
const restDayTips = [
{ iconName: 'walking', text: 'Promenad' },
{ iconName: 'yoga', text: 'Stretching' },
{ iconName: 'swimming', text: 'Simning' },
{ iconName: 'cycling', text: 'Cykling' },
]
const weekdays = ['Mån', 'Tis', 'Ons', 'Tor', 'Fre', 'Lör', 'Sön']
// Format volume number
function formatVolume(kg) {
if (kg >= 1000) return `${(kg / 1000).toFixed(1).replace('.0', '')} 000`
return `${kg}`
}
// Format session date
function formatSessionDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })
}
// Placeholder recent sessions
const PLACEHOLDER_SESSIONS = [
{ id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 2 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true },
{ id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 4 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false },
{ id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 6 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false },
]
const PLACEHOLDER_MONTHLY = {
stronger_pct: 15,
streak: 14,
total_volume: 124500,
}
function Dashboard({ onStartWorkout, onNavigate }) {
const { user, logout } = useAuth()
const [program, setProgram] = useState(null)
const [todayWorkout, setTodayWorkout] = useState(null)
const [loading, setLoading] = useState(true)
const [currentWeekStart, setCurrentWeekStart] = useState(getWeekStart(new Date()))
const [recentSessions, setRecentSessions] = useState(PLACEHOLDER_SESSIONS)
const [monthlyStats, setMonthlyStats] = useState(PLACEHOLDER_MONTHLY)
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
try {
const res = await fetch(`${API_URL}/programs/1`)
const data = await res.json()
setProgram(data)
const dayOfWeek = new Date().getDay()
const adjustedDay = dayOfWeek === 0 ? 7 : dayOfWeek
const todayDay = data.days?.find(d => d.day_number === adjustedDay)
setTodayWorkout(todayDay || null)
setLoading(false)
} catch (err) {
console.error('Failed to fetch data:', err)
setLoading(false)
}
// Fetch workout history (graceful fallback)
try {
const histRes = await fetch(`${API_URL}/user/workout-history?user_id=1&limit=5`)
if (histRes.ok) {
const histData = await histRes.json()
if (Array.isArray(histData) && histData.length > 0) {
setRecentSessions(histData.slice(0, 4))
// Calculate monthly stats from history
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthSessions = histData.filter(s => new Date(s.date) >= monthStart)
const totalVol = monthSessions.reduce((sum, s) => sum + (s.volume || 0), 0)
setMonthlyStats(prev => ({ ...prev, total_volume: totalVol || prev.total_volume }))
}
}
} catch (_) {
// use placeholder data
}
}
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0e0e0e' }}>
<div className="spinner"></div>
</div>
)
}
const workoutDays = program?.days?.map(d => d.day_number) || []
return (
<div style={{ minHeight: '100vh', background: '#0e0e0e', paddingBottom: '80px' }}>
{/* TOP HEADER */}
<header style={{
background: '#0e0e0e',
padding: '1rem 1.25rem 0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
position: 'sticky',
top: 0,
zIndex: 50,
}}>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 800,
fontSize: '1.25rem',
letterSpacing: '0.12em',
color: '#cafd00',
textTransform: 'uppercase',
}}>KINETIC</span>
<button
onClick={() => onNavigate('profile')}
style={{
width: 38, height: 38,
borderRadius: '50%',
background: '#1a1a1a',
border: '1px solid #262626',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer',
color: '#adaaaa',
}}
>
<Icon name="user" size={18} />
</button>
</header>
<main style={{ padding: '0 1.25rem' }}>
{/* MONTHLY HERO */}
<section style={{ marginTop: '1.25rem', marginBottom: '1.5rem' }}>
<div style={{
background: '#131313',
borderRadius: '12px',
padding: '1.5rem 1.25rem 1.25rem',
position: 'relative',
overflow: 'hidden',
}}>
{/* Subtle lime glow top-right */}
<div style={{
position: 'absolute', top: 0, right: 0,
width: 120, height: 120,
background: 'radial-gradient(circle at top right, rgba(202,253,0,0.08), transparent 70%)',
pointerEvents: 'none',
}} />
<div style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 800,
fontSize: '1.4rem',
lineHeight: 1.15,
color: '#ffffff',
textTransform: 'uppercase',
letterSpacing: '0.02em',
marginBottom: '1rem',
}}>
<span style={{ color: '#cafd00' }}>{monthlyStats.stronger_pct}%</span>{' '}
STARKARE ÄN{' '}
<span style={{ color: '#adaaaa', fontWeight: 600 }}>FÖRRA MÅNADEN</span>
</div>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
{/* Streak badge */}
<div style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.35rem 0.75rem',
background: 'rgba(202,253,0,0.1)',
borderRadius: '6px',
border: '1px solid rgba(202,253,0,0.2)',
}}>
<Icon name="fire" size={14} />
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
fontWeight: 700,
color: '#cafd00',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>{monthlyStats.streak} DAGARS STREAK</span>
</div>
{/* Volume */}
<div>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: '#767575',
letterSpacing: '0.06em',
textTransform: 'uppercase',
display: 'block',
}}>Denna månad</span>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '1rem',
color: '#ffffff',
}}>{formatVolume(monthlyStats.total_volume)} <span style={{ color: '#767575', fontSize: '0.75rem', fontFamily: 'Space Grotesk' }}>KG</span></span>
</div>
</div>
</div>
</section>
{/* WEEK CALENDAR */}
<section style={{
background: '#1a1a1a',
borderRadius: '10px',
padding: '0.875rem',
marginBottom: '1.5rem',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
<button
onClick={() => setCurrentWeekStart(addDays(currentWeekStart, -7))}
style={{ background: 'none', border: 'none', color: '#adaaaa', cursor: 'pointer', padding: '0.25rem' }}
>
<Icon name="chevronLeft" size={16} />
</button>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
{formatWeekRange(currentWeekStart)}
</span>
<button
onClick={() => setCurrentWeekStart(addDays(currentWeekStart, 7))}
style={{ background: 'none', border: 'none', color: '#adaaaa', cursor: 'pointer', padding: '0.25rem' }}
>
<Icon name="chevronRight" size={16} />
</button>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '0.25rem' }}>
{weekdays.map((name, idx) => {
const date = addDays(currentWeekStart, idx)
const dayNum = idx + 1
const isToday = isSameDay(date, new Date())
const hasWorkout = workoutDays.includes(dayNum)
const workout = program?.days?.find(d => d.day_number === dayNum)
return (
<div
key={idx}
onClick={() => hasWorkout && workout && onStartWorkout(workout)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
padding: '0.5rem 0.25rem',
borderRadius: '8px',
background: isToday ? 'rgba(202,253,0,0.1)' : 'transparent',
border: isToday ? '1px solid rgba(202,253,0,0.25)' : '1px solid transparent',
cursor: hasWorkout ? 'pointer' : 'default',
}}
>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: isToday ? '#cafd00' : '#767575',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}}>{name}</span>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: isToday ? 700 : 500,
fontSize: '0.9rem',
color: isToday ? '#cafd00' : '#ffffff',
}}>{date.getDate()}</span>
{hasWorkout && (
<span style={{
width: 4, height: 4, borderRadius: '50%',
background: isToday ? '#cafd00' : '#adaaaa',
}} />
)}
</div>
)
})}
</div>
</section>
{/* COACH GREETING */}
<section style={{ marginBottom: '1.25rem' }}>
<div style={{
display: 'flex',
gap: '0.875rem',
alignItems: 'flex-start',
}}>
<div style={{
width: 40, height: 40,
borderRadius: '50%',
background: '#1a1a1a',
border: '1px solid #262626',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
color: '#cafd00',
}}>
<Icon name="coach" size={22} />
</div>
<div style={{
background: '#1a1a1a',
borderRadius: '10px',
padding: '0.75rem 1rem',
flex: 1,
}}>
<p style={{
fontFamily: 'Plus Jakarta Sans, sans-serif',
fontSize: '0.875rem',
color: '#adaaaa',
lineHeight: 1.5,
}}>{getCoachGreeting(user, todayWorkout)}</p>
</div>
</div>
</section>
{/* TODAY'S WORKOUT CARD */}
<section style={{ marginBottom: '1.75rem' }}>
{todayWorkout ? (
<div
onClick={() => onStartWorkout(todayWorkout)}
style={{
background: 'linear-gradient(135deg, #1a1a1a 0%, #131313 100%)',
border: '1px solid rgba(202,253,0,0.15)',
borderRadius: '12px',
padding: '1.25rem',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Accent bar */}
<div style={{
position: 'absolute', top: 0, left: 0, right: 0,
height: 3,
background: 'linear-gradient(90deg, #cafd00, transparent)',
}} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
<div>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: '#cafd00',
letterSpacing: '0.08em',
textTransform: 'uppercase',
display: 'block',
marginBottom: '0.25rem',
}}>Dagens pass</span>
<h3 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '1.25rem',
color: '#ffffff',
}}>{todayWorkout.name}</h3>
</div>
<div style={{
width: 36, height: 36,
borderRadius: '8px',
background: '#cafd00',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#516700',
}}>
<Icon name="arrowRight" size={18} />
</div>
</div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.25rem' }}>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
letterSpacing: '0.03em',
}}>
{todayWorkout.exercises?.filter(e => e.name).length || 0} övningar
</span>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
letterSpacing: '0.03em',
}}>~45 min</span>
</div>
<button className="btn-kinetic" style={{ width: '100%', fontSize: '0.875rem', padding: '0.875rem' }}>
STARTA PASS
</button>
</div>
) : (
<div style={{
background: '#1a1a1a',
borderRadius: '12px',
padding: '1.25rem',
}}>
<h3 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 600,
fontSize: '1rem',
color: '#ffffff',
marginBottom: '0.875rem',
}}>Vilodag</h3>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
{restDayTips.map((tip, i) => (
<span key={i} style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.35rem 0.75rem',
background: '#131313',
borderRadius: '6px',
border: '1px solid #262626',
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
}}>
<Icon name={tip.iconName} size={14} />
{tip.text}
</span>
))}
</div>
<button
onClick={() => onNavigate('select-workout')}
style={{
width: '100%',
padding: '0.75rem',
background: '#131313',
border: '1px solid #262626',
borderRadius: '8px',
color: '#adaaaa',
fontFamily: 'Plus Jakarta Sans, sans-serif',
fontSize: '0.875rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
<Icon name="plus" size={16} />
Lägg till pass
</button>
</div>
)}
</section>
{/* RECENT SESSIONS */}
<section style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.875rem' }}>
<h2 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.875rem',
color: '#ffffff',
textTransform: 'uppercase',
letterSpacing: '0.06em',
}}>Senaste pass</h2>
<button
onClick={() => onNavigate('progress')}
style={{
background: 'none',
border: 'none',
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#cafd00',
cursor: 'pointer',
letterSpacing: '0.03em',
}}
>Se alla </button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{recentSessions.map((session) => (
<div
key={session.id}
className={session.is_pr ? 'intensity-bar-orange' : 'intensity-bar-lime'}
style={{
background: '#1a1a1a',
borderRadius: '10px',
padding: '0.875rem 0.875rem 0.875rem 1.25rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.9375rem',
color: '#ffffff',
}}>{session.name}</span>
{session.is_pr && (
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
fontWeight: 700,
color: '#516700',
background: '#cafd00',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
letterSpacing: '0.04em',
}}>PR</span>
)}
</div>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.72rem',
color: '#767575',
letterSpacing: '0.03em',
}}>
{formatSessionDate(session.date)} · {session.duration} min · {session.exercise_count} övningar
</span>
</div>
<div style={{ textAlign: 'right' }}>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.9rem',
color: '#cafd00',
}}>{formatVolume(session.volume)}</span>
<span style={{
display: 'block',
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: '#767575',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>kg</span>
</div>
</div>
))}
</div>
</section>
</main>
{/* BOTTOM GLASSMORPHISM NAV */}
<nav
className="glass-nav"
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: '0.625rem 0 0.75rem',
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
borderTop: '1px solid rgba(255,255,255,0.06)',
zIndex: 100,
}}
>
{[
{ icon: 'home', label: 'Idag', nav: null, active: true },
{ icon: 'chart', label: 'Framsteg', nav: 'progress', active: false },
{ icon: 'target', label: 'Mål', nav: 'benchmarks', active: false },
{ icon: 'search', label: 'Övningar', nav: 'encyclopedia', active: false },
{ icon: 'user', label: 'Profil', nav: 'profile', active: false },
].map((item) => (
<button
key={item.label}
onClick={() => item.nav ? onNavigate(item.nav) : undefined}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '0.25rem',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem 0.75rem',
}}
>
<span style={{ color: item.active ? '#cafd00' : '#767575' }}>
<Icon name={item.icon} size={20} />
</span>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.6rem',
color: item.active ? '#cafd00' : '#767575',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>{item.label}</span>
</button>
))}
</nav>
</div>
)
}
// Helper functions
function getWeekStart(date) {
const d = new Date(date)
const day = d.getDay()
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
return new Date(d.setDate(diff))
}
function addDays(date, days) {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d
}
function isSameDay(d1, d2) {
return d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear()
}
function formatWeekRange(weekStart) {
const end = addDays(weekStart, 6)
const startMonth = weekStart.toLocaleDateString('sv-SE', { month: 'short' })
const endMonth = end.toLocaleDateString('sv-SE', { month: 'short' })
if (startMonth === endMonth) {
return `${weekStart.getDate()} - ${end.getDate()} ${startMonth}`
}
return `${weekStart.getDate()} ${startMonth} - ${end.getDate()} ${endMonth}`
}
export default Dashboard