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)
This commit is contained in:
2026-04-27 08:49:07 +02:00
parent b6c39574c2
commit 1f2a892391
20 changed files with 2380 additions and 516 deletions
+6
View File
@@ -7,6 +7,7 @@ import WorkoutPage from './pages/WorkoutPage'
import WorkoutSelectPage from './pages/WorkoutSelectPage'
import ChatOnboarding from './pages/ChatOnboarding'
import ExerciseEncyclopediaPage from './pages/ExerciseEncyclopediaPage'
import BenchmarksPage from './pages/BenchmarksPage'
import './App.css'
const API_URL = '/api'
@@ -150,6 +151,11 @@ function App() {
return <ExerciseEncyclopediaPage onBack={() => setView('dashboard')} />
}
// Benchmarks page
if (view === 'benchmarks') {
return <BenchmarksPage onBack={() => setView('dashboard')} />
}
// Workout select page
if (view === 'select-workout') {
return (
+66 -65
View File
@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700;800&family=Plus+Jakarta+Sans:wght@300;400;500;600;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
* {
margin: 0;
padding: 0;
@@ -5,60 +7,61 @@
}
:root {
/* Dark fitness palette - refined */
--bg-primary: #0a0a0f;
--bg-secondary: #0d0d14;
--bg-tertiary: #12121a;
--bg-card: #16161f;
--bg-card-hover: #1c1c28;
--bg-elevated: #1a1a24;
--bg: #0a0a0f;
/* Kinetic Precision - Stitch Design System */
--bg-primary: #0e0e0e;
--bg-secondary: #131313;
--bg-tertiary: #1a1a1a;
--bg-card: #1a1a1a;
--bg-card-hover: #20201f;
--bg-elevated: #20201f;
--bg: #0e0e0e;
/* Text colors - better hierarchy */
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-muted: #71717a;
--text-tertiary: #52525b;
--text-secondary: #adaaaa;
--text-muted: #767575;
--text-tertiary: #484847;
--text: #ffffff;
/* Accent - refined energetic coral */
--accent: #ff6b4a;
--accent-hover: #ff8066;
--accent-subtle: rgba(255, 107, 74, 0.15);
--accent-glow: rgba(255, 107, 74, 0.25);
/* Primary: Electric Lime */
--accent: #cafd00;
--accent-hover: #beee00;
--accent-subtle: rgba(202, 253, 0, 0.12);
--accent-glow: rgba(202, 253, 0, 0.25);
--accent-on: #516700;
/* Status colors - refined */
--success: #22c55e;
--success-subtle: rgba(34, 197, 94, 0.15);
--warning: #f59e0b;
--warning-subtle: rgba(245, 158, 11, 0.15);
--error: #ef4444;
--error-subtle: rgba(239, 68, 68, 0.15);
/* Secondary: Orange */
--secondary: #ff7440;
--secondary-hover: #ff8c5a;
--secondary-subtle: rgba(255, 116, 64, 0.12);
--secondary-glow: rgba(255, 116, 64, 0.25);
/* Borders - refined */
--border: #1f1f2a;
--border-hover: #2a2a38;
--border-accent: var(--accent-subtle);
--success: #f3ffca;
--success-subtle: rgba(243, 255, 202, 0.12);
--warning: #ff7440;
--warning-subtle: rgba(255, 116, 64, 0.12);
--error: #ff7351;
--error-subtle: rgba(255, 115, 81, 0.15);
/* Shadows - key for enterprise feel */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.6), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.7), 0 8px 10px -6px rgba(0, 0, 0, 0.4);
--shadow-glow: 0 0 20px var(--accent-glow);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.3);
--border: #1f1f1f;
--border-hover: #262626;
--border-accent: rgba(202, 253, 0, 0.2);
/* Workout type colors - refined */
--workout-push: #ef4444;
--workout-pull: #3b82f6;
--workout-legs: #22c55e;
--workout-shoulders: #f59e0b;
--workout-upper: #8b5cf6;
--workout-lower: #06b6d4;
--workout-default: #ff6b4a;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.8);
--shadow-glow: 0 0 20px rgba(202, 253, 0, 0.3);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.5);
--shadow-elevated: 0 8px 16px rgba(0, 0, 0, 0.5), 0 2px 4px rgba(0, 0, 0, 0.4);
--workout-push: #ff7440;
--workout-pull: #f3ffca;
--workout-legs: #cafd00;
--workout-shoulders: #ff7440;
--workout-upper: #f3ffca;
--workout-lower: #beee00;
--workout-default: #cafd00;
/* Typography scale */
--font-xs: 0.75rem;
--font-sm: 0.875rem;
--font-base: 1rem;
@@ -67,7 +70,6 @@
--font-2xl: 1.5rem;
--font-3xl: 2rem;
/* Spacing scale */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
@@ -78,22 +80,20 @@
--space-10: 2.5rem;
--space-12: 3rem;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Border radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--radius-2xl: 24px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 10px;
--radius-2xl: 12px;
--radius-full: 9999px;
}
html, body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
@@ -103,6 +103,7 @@ html, body {
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Lexend', sans-serif;
font-weight: 700;
line-height: 1.2;
}
@@ -277,13 +278,13 @@ input {
.auth-card button[type="submit"] {
padding: var(--space-4);
background: var(--accent);
color: white;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
color: var(--accent-on);
border-radius: var(--radius-md);
font-size: var(--font-base);
font-weight: 600;
transition: all var(--transition-base);
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3);
position: relative;
overflow: hidden;
}
@@ -297,14 +298,14 @@ input {
}
.auth-card button[type="submit"]:hover:not(:disabled) {
background: var(--accent-hover);
background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%);
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4);
}
.auth-card button[type="submit"]:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(255, 107, 74, 0.3);
box-shadow: 0 2px 8px rgba(202, 253, 0, 0.3);
}
.auth-card button:disabled {
@@ -802,17 +803,17 @@ input {
}
.next-btn, .finish-btn {
background: var(--accent) !important;
color: white !important;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%) !important;
color: var(--accent-on) !important;
font-weight: 600;
border: none !important;
box-shadow: 0 4px 12px rgba(255, 107, 74, 0.3);
box-shadow: 0 4px 12px rgba(202, 253, 0, 0.3);
}
.next-btn:hover:not(:disabled), .finish-btn:hover:not(:disabled) {
background: var(--accent-hover) !important;
background: linear-gradient(135deg, var(--accent-hover) 0%, #b0de00 100%) !important;
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(255, 107, 74, 0.4);
box-shadow: 0 6px 20px rgba(202, 253, 0, 0.4);
}
button:disabled {
+429
View File
@@ -0,0 +1,429 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
import { Icon } from '../components/Icons'
import '../styles/kinetic-precision.css'
const API_URL = '/api'
// Placeholder data shown when API is unavailable
const PLACEHOLDER_DATA = {
strength: [
{
id: 'deadlift',
name: 'Marklyft',
current: 140,
goal: 180,
unit: 'kg',
intensity: 'lime',
category: 'Styrka',
},
{
id: 'squat',
name: 'Knäböj',
current: 110,
goal: 150,
unit: 'kg',
intensity: 'lime',
category: 'Styrka',
},
{
id: 'bench',
name: 'Bänkpress',
current: 90,
goal: 120,
unit: 'kg',
intensity: 'lime',
category: 'Styrka',
},
],
endurance: [
{
id: 'fivek',
name: '5K Löptid',
current: '24:30',
currentRaw: 24.5,
goal: 22,
unit: 'min',
intensity: 'orange',
lowerIsBetter: true,
category: 'Kondition',
},
{
id: 'vo2max',
name: 'VO2 Max',
current: 48,
goal: 55,
unit: 'ml/kg/min',
intensity: 'orange',
lowerIsBetter: false,
category: 'Kondition',
},
],
body: [
{
id: 'mass',
name: 'Kroppsvikt',
current: 82,
goal: 80,
unit: 'kg',
intensity: 'lime',
lowerIsBetter: true,
category: 'Kropp',
},
{
id: 'bodyfat',
name: 'Kroppsfett',
current: 16,
goal: 12,
unit: '%',
intensity: 'orange',
lowerIsBetter: true,
category: 'Kropp',
},
{
id: 'muscle',
name: 'Muskelmassa',
current: 68,
goal: 72,
unit: 'kg',
intensity: 'lime',
lowerIsBetter: false,
category: 'Kropp',
},
],
goals: [
{ id: 1, text: 'Marklyft 180 kg', progress: 78, type: 'lime' },
{ id: 2, text: 'Sänk kroppsfett till 12%', progress: 44, type: 'orange' },
{ id: 3, text: '5K under 22 min', progress: 60, type: 'orange' },
{ id: 4, text: 'VO2 Max 55', progress: 55, type: 'lime' },
],
}
function getProgress(metric) {
if (metric.lowerIsBetter) {
const rawCurrent = typeof metric.current === 'string' ? metric.currentRaw : metric.current
const range = rawCurrent - metric.goal
const total = rawCurrent // distance from 0 to current
if (total <= 0) return 100
return Math.min(100, Math.max(0, Math.round((1 - range / total) * 100)))
}
if (metric.goal <= 0) return 0
return Math.min(100, Math.round((metric.current / metric.goal) * 100))
}
function MetricCard({ metric }) {
const progress = getProgress(metric)
const isLime = metric.intensity === 'lime'
return (
<div
className={`benchmark-card intensity-bar-${metric.intensity}`}
style={{ paddingLeft: '1.5rem' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '0.5rem' }}>
<div>
<p className="data-label">{metric.category}</p>
<h3 style={{ fontFamily: "'Lexend', sans-serif", fontWeight: 700, fontSize: '1rem', color: '#ffffff', marginTop: '0.125rem' }}>
{metric.name}
</h3>
</div>
<div style={{ textAlign: 'right' }}>
<div className="stat-chip">
<span className="stat-number" style={{ color: isLime ? '#cafd00' : '#ff7440' }}>
{metric.current}
</span>
<span className="stat-unit">{metric.unit}</span>
</div>
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.7rem', color: '#767575', marginTop: '0.125rem' }}>
Mål: {metric.goal} {metric.unit}
</p>
</div>
</div>
<div className="progress-bar-track">
<div
className={`progress-bar-fill${isLime ? '' : ' secondary'}`}
style={{ width: `${progress}%` }}
/>
</div>
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.7rem', color: '#767575', marginTop: '0.375rem', textAlign: 'right' }}>
{progress}% av mål
</p>
</div>
)
}
function SectionHeader({ title }) {
return (
<div style={{ padding: '0.75rem 0 0.5rem' }}>
<h2 style={{
fontFamily: "'Lexend', sans-serif",
fontWeight: 700,
fontSize: '1.125rem',
color: '#ffffff',
letterSpacing: '-0.01em',
}}>
{title}
</h2>
</div>
)
}
function GoalCard({ goal }) {
const isLime = goal.type === 'lime'
return (
<div style={{
background: '#1a1a1a',
borderRadius: '8px',
padding: '0.875rem 1rem',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}}>
<div style={{
width: '40px',
height: '40px',
borderRadius: '50%',
background: isLime ? 'rgba(202, 253, 0, 0.1)' : 'rgba(255, 116, 64, 0.1)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke={isLime ? '#cafd00' : '#ff7440'} strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</svg>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontFamily: "'Plus Jakarta Sans', sans-serif", fontSize: '0.875rem', fontWeight: 600, color: '#ffffff', marginBottom: '0.375rem' }}>
{goal.text}
</p>
<div className="progress-bar-track" style={{ height: '4px' }}>
<div
className={`progress-bar-fill${isLime ? '' : ' secondary'}`}
style={{ width: `${goal.progress}%` }}
/>
</div>
</div>
<span style={{
fontFamily: "'Lexend', sans-serif",
fontWeight: 700,
fontSize: '0.875rem',
color: isLime ? '#cafd00' : '#ff7440',
flexShrink: 0,
}}>
{goal.progress}%
</span>
</div>
)
}
function BenchmarksPage({ onBack }) {
const { user } = useAuth()
const userId = user?.id || 1
const [data, setData] = useState(PLACEHOLDER_DATA)
const [loading, setLoading] = useState(true)
const [usingPlaceholder, setUsingPlaceholder] = useState(false)
useEffect(() => {
const fetchBenchmarks = async () => {
try {
const res = await fetch(`${API_URL}/benchmarks?user_id=${userId}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
// Merge API data with placeholder structure if keys exist
if (json && (json.strength || json.endurance || json.body || json.goals)) {
setData({
strength: json.strength || PLACEHOLDER_DATA.strength,
endurance: json.endurance || PLACEHOLDER_DATA.endurance,
body: json.body || PLACEHOLDER_DATA.body,
goals: json.goals || PLACEHOLDER_DATA.goals,
})
} else {
setUsingPlaceholder(true)
}
} catch {
setUsingPlaceholder(true)
} finally {
setLoading(false)
}
}
fetchBenchmarks()
}, [userId])
if (loading) {
return (
<div style={{ minHeight: '100vh', background: '#0e0e0e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center', color: '#767575' }}>
<div className="spinner" style={{ margin: '0 auto 0.75rem' }} />
<p style={{ fontFamily: "'Plus Jakarta Sans', sans-serif" }}>Laddar...</p>
</div>
</div>
)
}
return (
<div style={{ minHeight: '100vh', background: '#0e0e0e', color: '#ffffff', display: 'flex', flexDirection: 'column' }}>
{/* Header - glassmorphism */}
<header className="glass-nav" style={{
position: 'sticky',
top: 0,
zIndex: 10,
padding: '1rem 1.25rem',
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
borderBottom: '1px solid #1f1f1f',
}}>
<button
onClick={onBack}
style={{
background: 'transparent',
border: 'none',
color: '#adaaaa',
cursor: 'pointer',
padding: '0.25rem',
display: 'flex',
alignItems: 'center',
borderRadius: '4px',
transition: 'color 150ms ease',
}}
onMouseEnter={e => e.currentTarget.style.color = '#ffffff'}
onMouseLeave={e => e.currentTarget.style.color = '#adaaaa'}
aria-label="Tillbaka"
>
<Icon name="chevronLeft" size={22} />
</button>
<div style={{ flex: 1 }}>
<h1 style={{
fontFamily: "'Lexend', sans-serif",
fontWeight: 700,
fontSize: '1.25rem',
color: '#ffffff',
lineHeight: 1.2,
}}>
Benchmarks
</h1>
<p style={{
fontFamily: "'Space Grotesk', monospace",
fontSize: '0.75rem',
color: '#767575',
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginTop: '0.125rem',
}}>
Mätpunkter & Mål
</p>
</div>
{usingPlaceholder && (
<span className="goal-badge active" style={{ fontSize: '0.65rem' }}>Demo</span>
)}
</header>
{/* Content */}
<main style={{ flex: 1, padding: '1rem 1.25rem 6rem', maxWidth: '640px', width: '100%', margin: '0 auto' }}>
{/* Summary row */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '0.75rem',
marginBottom: '1.5rem',
paddingTop: '0.5rem',
}}>
{[
{ label: 'Styrka', value: `${data.strength.length}`, sub: 'övningar' },
{ label: 'Kondition', value: `${data.endurance.length}`, sub: 'mätvärden' },
{ label: 'Aktiva mål', value: `${data.goals.length}`, sub: 'pågående' },
].map(s => (
<div key={s.label} style={{ background: '#1a1a1a', borderRadius: '8px', padding: '0.875rem 0.75rem', textAlign: 'center' }}>
<p className="data-label" style={{ marginBottom: '0.25rem' }}>{s.label}</p>
<p style={{ fontFamily: "'Lexend', sans-serif", fontWeight: 700, fontSize: '1.5rem', color: '#cafd00', lineHeight: 1 }}>{s.value}</p>
<p style={{ fontFamily: "'Space Grotesk', monospace", fontSize: '0.65rem', color: '#767575', textTransform: 'uppercase', letterSpacing: '0.04em', marginTop: '0.125rem' }}>{s.sub}</p>
</div>
))}
</div>
{/* Strength */}
<section style={{ marginBottom: '1.25rem' }}>
<SectionHeader title="Styrka" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{data.strength.map(m => <MetricCard key={m.id} metric={m} />)}
</div>
</section>
{/* Divider via background shift */}
<div className="surface-low" style={{ margin: '0 -1.25rem', padding: '1.25rem 1.25rem' }}>
{/* Endurance */}
<section style={{ marginBottom: '1.25rem' }}>
<SectionHeader title="Kondition" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{data.endurance.map(m => <MetricCard key={m.id} metric={m} />)}
</div>
</section>
{/* Body composition */}
<section>
<SectionHeader title="Kroppskomposition" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{data.body.map(m => <MetricCard key={m.id} metric={m} />)}
</div>
</section>
</div>
{/* Active goals */}
<section style={{ marginTop: '1.5rem' }}>
<SectionHeader title="Aktiva mål" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{data.goals.map(g => <GoalCard key={g.id} goal={g} />)}
</div>
</section>
</main>
{/* Bottom nav */}
<nav style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
background: 'rgba(26, 26, 26, 0.7)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderTop: '1px solid #1f1f1f',
padding: '0.75rem 1.25rem',
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
}}>
<button
onClick={onBack}
style={{
background: 'linear-gradient(135deg, #cafd00 0%, #beee00 100%)',
color: '#516700',
border: 'none',
borderRadius: '6px',
padding: '0.625rem 1.5rem',
fontFamily: "'Plus Jakarta Sans', sans-serif",
fontWeight: 700,
fontSize: '0.875rem',
textTransform: 'uppercase',
letterSpacing: '0.05em',
cursor: 'pointer',
boxShadow: '0 4px 16px rgba(202, 253, 0, 0.3)',
transition: 'all 150ms ease',
}}
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 6px 24px rgba(202, 253, 0, 0.4)' }}
onMouseLeave={e => { e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = '0 4px 16px rgba(202, 253, 0, 0.3)' }}
>
Tillbaka till Dashboard
</button>
</nav>
</div>
)
}
export default BenchmarksPage
+509 -97
View File
@@ -2,6 +2,7 @@ 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'
@@ -11,7 +12,6 @@ const getCoachGreeting = (user, todayWorkout) => {
const name = user?.name?.split(' ')[0] || 'du'
if (todayWorkout) {
// There's a workout today
if (hour < 10) {
return `Godmorgon ${name}! Idag kör vi ${todayWorkout.name.toLowerCase()}. Redo?`
} else if (hour < 14) {
@@ -22,7 +22,6 @@ const getCoachGreeting = (user, todayWorkout) => {
return `Kvällspass ${name}? ${todayWorkout.name} perfekt för att avsluta dagen.`
}
} else {
// Rest day
if (hour < 10) {
return `Godmorgon ${name}! Vilodag idag perfekt för återhämtning.`
} else if (hour < 14) {
@@ -35,7 +34,6 @@ const getCoachGreeting = (user, todayWorkout) => {
}
}
// Rest day tips
const restDayTips = [
{ iconName: 'walking', text: 'Promenad' },
{ iconName: 'yoga', text: 'Stretching' },
@@ -43,15 +41,42 @@ const restDayTips = [
{ iconName: 'cycling', text: 'Cykling' },
]
// Get weekday names
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()
@@ -62,25 +87,42 @@ function Dashboard({ onStartWorkout, onNavigate }) {
const res = await fetch(`${API_URL}/programs/1`)
const data = await res.json()
setProgram(data)
// Determine today's workout based on day of week
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 className="dashboard loading">
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0e0e0e' }}>
<div className="spinner"></div>
<p>Laddar...</p>
</div>
)
}
@@ -88,131 +130,501 @@ function Dashboard({ onStartWorkout, onNavigate }) {
const workoutDays = program?.days?.map(d => d.day_number) || []
return (
<div className="dashboard">
<header className="dashboard-header">
<div className="header-top">
<h1 className="brand-title">
<Logo />
<span className="brand-name">Gravl</span>
</h1>
<nav className="nav-menu">
<button className="nav-btn active"><Icon name="home" size={18} /></button>
<button className="nav-btn" onClick={() => onNavigate('progress')}><Icon name="chart" size={18} /></button>
<button className="nav-btn" onClick={() => onNavigate('encyclopedia')} title="Exercise Encyclopedia"><Icon name="search" size={18} /></button>
<button className="nav-btn" onClick={() => onNavigate('profile')}><Icon name="user" size={18} /></button>
<button className="nav-btn logout" onClick={logout}><Icon name="logout" size={18} /></button>
</nav>
</div>
<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 className="dashboard-main">
{/* Week Calendar - TOP */}
<section className="week-calendar">
<div className="calendar-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
className="calendar-nav"
onClick={() => setCurrentWeekStart(addDays(currentWeekStart, -7))}
style={{ background: 'none', border: 'none', color: '#adaaaa', cursor: 'pointer', padding: '0.25rem' }}
>
<Icon name="chevronLeft" size={16} />
</button>
<span className="calendar-title">
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>
{formatWeekRange(currentWeekStart)}
</span>
<button
className="calendar-nav"
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 className="calendar-days">
<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}
className={`calendar-day ${isToday ? 'today' : ''} ${hasWorkout ? 'has-workout' : ''}`}
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 className="day-name">{name}</span>
<span className="day-date">{date.getDate()}</span>
{hasWorkout && <span className="day-dot" />}
<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 Section with Today's Action */}
<section className="coach-section">
<div className="coach-greeting">
<div className="coach-avatar">
<Icon name="coach" size={36} />
{/* 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 className="coach-message">
<p>{getCoachGreeting(user, todayWorkout)}</p>
<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>
{/* Today's Action */}
<div className="today-action">
{todayWorkout ? (
// Workout today - show workout card
<div className="today-workout-card" onClick={() => onStartWorkout(todayWorkout)}>
<div className="workout-info">
<h3>{todayWorkout.name}</h3>
<span className="workout-meta">
{todayWorkout.exercises?.filter(e => e.name).length} övningar ~45 min
</span>
</div>
<div className="workout-action">
<Icon name="arrowRight" size={24} />
</div>
</div>
) : (
// Rest day - show tips + add button
<div className="rest-day-section">
<div className="rest-tips">
{restDayTips.map((tip, i) => (
<span key={i} className="tip-badge">
<Icon name={tip.iconName} size={16} />
{tip.text}
</span>
))}
</div>
<button
className="add-workout-btn"
onClick={() => onNavigate('select-workout')}
>
<Icon name="plus" size={20} />
<span>Lägg till pass</span>
</button>
</div>
)}
</div>
</section>
{/* Quick Stats */}
<section className="quick-stats">
<div className="stat-card">
<span className="stat-value">{workoutDays.length}</span>
<span className="stat-label">Pass/vecka</span>
{/* 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 className="stat-card">
<span className="stat-value">2</span>
<span className="stat-label">Denna vecka</span>
</div>
<div className="stat-card">
<span className="stat-value stat-icon"><Icon name="fire" size={28} /></span>
<span className="stat-label">Streak: 5</span>
<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>
)
}
@@ -232,16 +644,16 @@ function addDays(date, days) {
}
function isSameDay(d1, d2) {
return d1.getDate() === d2.getDate() &&
d1.getMonth() === d2.getMonth() &&
d1.getFullYear() === d2.getFullYear()
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}`
}
+269
View File
@@ -0,0 +1,269 @@
.login-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
background: #0e0e0e;
padding: 1.5rem 1.1rem;
position: relative;
overflow: hidden;
}
/* Lime radial glow behind logo */
.login-glow {
position: absolute;
top: -10%;
left: 50%;
transform: translateX(-50%);
width: 500px;
height: 380px;
background: radial-gradient(ellipse at center, rgba(202, 253, 0, 0.07) 0%, transparent 65%);
pointer-events: none;
}
.login-container {
width: 100%;
max-width: 390px;
display: flex;
flex-direction: column;
gap: 0;
position: relative;
z-index: 1;
}
/* ---- Logo block ---- */
.login-logo-block {
text-align: center;
margin-bottom: 3rem;
}
.login-wordmark {
font-family: 'Lexend', sans-serif;
font-weight: 800;
font-size: 3rem;
letter-spacing: -0.02em;
color: #cafd00;
line-height: 1;
text-shadow: 0 0 40px rgba(202, 253, 0, 0.35);
}
.login-tagline {
font-family: 'Space Grotesk', monospace;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #767575;
margin-top: 0.5rem;
}
/* ---- Error ---- */
.login-error {
background: rgba(255, 115, 81, 0.1);
color: #ff7351;
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
font-family: 'Plus Jakarta Sans', sans-serif;
margin-bottom: 1.5rem;
border-left: 3px solid #ff7351;
}
/* ---- Form ---- */
.login-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-bottom: 1rem;
}
.login-field {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.login-field-label {
font-family: 'Space Grotesk', monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
color: #767575;
}
.login-input-wrap {
position: relative;
}
.login-input {
width: 100%;
padding: 0.9rem 1rem;
background: #1a1a1a;
border: none;
border-bottom: 2px solid #262626;
border-radius: 4px 4px 0 0;
color: #ffffff;
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 16px;
transition: border-color 150ms ease;
outline: none;
}
.login-input:focus {
border-bottom-color: #cafd00;
background: #20201f;
}
.login-input::placeholder {
color: #484847;
}
.login-input-wrap .login-input {
padding-right: 3rem;
}
.login-toggle-pw {
position: absolute;
right: 0.75rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #767575;
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
transition: color 150ms ease;
}
.login-toggle-pw:hover {
color: #adaaaa;
}
/* ---- Primary CTA ---- */
.login-btn-primary {
margin-top: 0.5rem;
width: 100%;
padding: 1rem;
background: linear-gradient(135deg, #cafd00 0%, #beee00 100%);
color: #516700;
font-family: 'Lexend', sans-serif;
font-weight: 700;
font-size: 0.875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 150ms ease;
box-shadow: 0 4px 20px rgba(202, 253, 0, 0.25);
display: flex;
align-items: center;
justify-content: center;
min-height: 52px;
}
.login-btn-primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 6px 28px rgba(202, 253, 0, 0.35);
}
.login-btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.login-btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(81, 103, 0, 0.3);
border-top-color: #516700;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---- Forgot / register link ---- */
.login-forgot {
display: block;
text-align: center;
color: #ff7440;
font-family: 'Plus Jakarta Sans', sans-serif;
font-size: 0.875rem;
text-decoration: none;
padding: 0.75rem 0;
transition: color 150ms ease;
}
.login-forgot:hover {
color: #ff8c5a;
}
/* ---- Divider ---- */
.login-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 0.5rem 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: #1f1f1f;
}
.login-divider span {
font-family: 'Space Grotesk', monospace;
font-size: 0.7rem;
letter-spacing: 0.1em;
color: #484847;
text-transform: uppercase;
}
/* ---- Ghost button ---- */
.login-btn-ghost {
display: block;
width: 100%;
padding: 0.9rem;
background: transparent;
border: 1px solid #262626;
border-radius: 6px;
color: #adaaaa;
font-family: 'Lexend', sans-serif;
font-weight: 600;
font-size: 0.875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
text-align: center;
text-decoration: none;
cursor: pointer;
transition: all 150ms ease;
margin-top: 0.5rem;
}
.login-btn-ghost:hover {
border-color: #484847;
color: #ffffff;
}
/* ---- Footer ---- */
.login-footer {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin-top: 2.5rem;
color: #484847;
font-family: 'Space Grotesk', monospace;
font-size: 0.7rem;
letter-spacing: 0.05em;
}
+88 -12
View File
@@ -1,11 +1,12 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import Logo from '../components/Logo';
import './LoginPage.css';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
@@ -25,18 +26,93 @@ export default function LoginPage() {
};
return (
<div className="auth-page">
<div className="auth-card">
<Logo />
<h1 className="auth-title">Logga in</h1>
<p className="auth-tagline">Din personliga träningspartner</p>
{error && <div className="error auth-error">{error}</div>}
<form onSubmit={handleSubmit}>
<input type="email" placeholder="E-post" value={email} onChange={e => setEmail(e.target.value)} required />
<input type="password" placeholder="Lösenord" value={password} onChange={e => setPassword(e.target.value)} required />
<button type="submit" disabled={loading}>{loading ? 'Loggar in...' : 'Logga in'}</button>
<div className="login-page">
<div className="login-glow" />
<div className="login-container">
{/* Logo */}
<div className="login-logo-block">
<div className="login-wordmark">GRAVL</div>
<p className="login-tagline">Track. Progress. Dominate.</p>
</div>
{/* Error */}
{error && <div className="login-error">{error}</div>}
{/* Form */}
<form onSubmit={handleSubmit} className="login-form">
<div className="login-field">
<label className="login-field-label">E-POST</label>
<input
type="email"
className="login-input"
placeholder="din@epost.se"
value={email}
onChange={e => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="login-field">
<label className="login-field-label">LÖSENORD</label>
<div className="login-input-wrap">
<input
type={showPassword ? 'text' : 'password'}
className="login-input"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
<button
type="button"
className="login-toggle-pw"
onClick={() => setShowPassword(v => !v)}
tabIndex={-1}
>
{showPassword ? (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</div>
<button type="submit" className="login-btn-primary" disabled={loading}>
{loading ? (
<span className="login-spinner" />
) : (
'LOGGA IN'
)}
</button>
</form>
<p className="auth-link">Inget konto? <Link to="/register">Skapa konto</Link></p>
<Link to="/register" className="login-forgot">Inget konto? Skapa ett </Link>
<div className="login-divider">
<span>eller</span>
</div>
<Link to="/register" className="login-btn-ghost">SKAPA KONTO</Link>
{/* Footer */}
<div className="login-footer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<span>Din data. Krypterad. Alltid.</span>
</div>
</div>
</div>
);
+434 -168
View File
@@ -1,186 +1,455 @@
import { useState, useEffect } from 'react'
import { useAuth } from '../context/AuthContext'
import '../styles/kinetic-precision.css'
const API_URL = '/api'
// Placeholder workout history
const PLACEHOLDER_HISTORY = [
{ id: 1, name: 'Bröst & Triceps', date: new Date(Date.now() - 1 * 86400000).toISOString(), duration: 52, exercise_count: 6, volume: 8750, is_pr: true, exercises: ['Bänkpress', 'Incline DB Press', 'Cable Flyes', 'Tricep Pushdowns', 'Overhead Ext', 'Dips'] },
{ id: 2, name: 'Rygg & Biceps', date: new Date(Date.now() - 3 * 86400000).toISOString(), duration: 48, exercise_count: 7, volume: 11200, is_pr: false, exercises: ['Lat Pulldown', 'Seated Row', 'Face Pulls', 'Barbell Curl', 'Hammer Curl', 'Reverse Curl', 'Shrugs'] },
{ id: 3, name: 'Ben & Axlar', date: new Date(Date.now() - 5 * 86400000).toISOString(), duration: 61, exercise_count: 8, volume: 14300, is_pr: false, exercises: ['Knäböj', 'Leg Press', 'Leg Curl', 'Leg Ext', 'Military Press', 'Lateral Raise', 'Front Raise', 'Rear Delt Fly'] },
{ id: 4, name: 'Push', date: new Date(Date.now() - 8 * 86400000).toISOString(), duration: 55, exercise_count: 6, volume: 9100, is_pr: false, exercises: ['Bänkpress', 'OHP', 'DB Press', 'Cable Flyes', 'Tricep Ext', 'Lateral Raise'] },
{ id: 5, name: 'Pull', date: new Date(Date.now() - 10 * 86400000).toISOString(), duration: 46, exercise_count: 5, volume: 10500, is_pr: false, exercises: ['Marklyft', 'Pull-ups', 'Seated Row', 'Face Pulls', 'Bicep Curl'] },
]
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.toLocaleDateString('sv-SE', { weekday: 'short', day: 'numeric', month: 'short' })
}
function formatVolume(kg) {
if (kg >= 1000) {
return `${Math.round(kg / 100) / 10} 000`
}
return `${kg}`
}
function ProgressPage({ onBack }) {
const { user } = useAuth()
const [measurements, setMeasurements] = useState([])
const [strength, setStrength] = useState([])
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState('weight')
const [workoutHistory, setWorkoutHistory] = useState(PLACEHOLDER_HISTORY)
const [expandedSession, setExpandedSession] = useState(null)
// Monthly summary computed from history
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
const monthSessions = workoutHistory.filter(s => new Date(s.date) >= monthStart)
const totalVolume = workoutHistory.reduce((sum, s) => sum + (s.volume || 0), 0)
const streak = 14 // placeholder
const sessionCount = workoutHistory.length
useEffect(() => {
fetchData()
}, [])
const fetchData = async () => {
// Try workout history first
try {
const histRes = await fetch(`${API_URL}/user/workout-history?user_id=${user?.id || 1}`)
if (histRes.ok) {
const histData = await histRes.json()
if (Array.isArray(histData) && histData.length > 0) {
setWorkoutHistory(histData)
}
}
} catch (_) {
// use placeholder
}
// Try measurements and strength
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)
} catch (_) {
// silent
}
setLoading(false)
}
if (loading) {
return (
<div className="progress-page loading">
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0e0e0e' }}>
<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>
<div style={{ minHeight: '100vh', background: '#0e0e0e', paddingBottom: '2rem' }}>
{/* HEADER */}
<header style={{
background: '#0e0e0e',
padding: '1rem 1.25rem',
display: 'flex',
alignItems: 'center',
gap: '1rem',
position: 'sticky',
top: 0,
zIndex: 50,
borderBottom: '1px solid #1a1a1a',
}}>
<button
onClick={onBack}
style={{
background: 'none',
border: 'none',
color: '#adaaaa',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
fontFamily: 'Plus Jakarta Sans, sans-serif',
fontSize: '0.875rem',
padding: 0,
}}
>
Tillbaka
</button>
<h1 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 800,
fontSize: '1rem',
color: '#ffffff',
letterSpacing: '0.08em',
textTransform: 'uppercase',
flex: 1,
textAlign: 'center',
}}>Framsteg &amp; Historik</h1>
<div style={{ width: 70 }} />
</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>
<main style={{ padding: '1.25rem' }}>
{/* MONTHLY SUMMARY BAR */}
<section style={{
background: '#131313',
borderRadius: '10px',
padding: '1rem',
marginBottom: '1.5rem',
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '0',
}}>
{[
{ label: 'Volym', value: formatVolume(totalVolume), unit: 'KG' },
{ label: 'Streak', value: String(streak), unit: 'DAGAR' },
{ label: 'Pass', value: String(sessionCount), unit: 'TOTALT' },
].map((stat, i) => (
<div
key={stat.label}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '0.5rem 0',
borderRight: i < 2 ? '1px solid #1f1f1f' : 'none',
}}
>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 800,
fontSize: '1.25rem',
color: '#cafd00',
lineHeight: 1.1,
}}>{stat.value}</span>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.6rem',
color: '#767575',
letterSpacing: '0.06em',
textTransform: 'uppercase',
marginTop: '0.125rem',
}}>{stat.unit}</span>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: '#adaaaa',
marginTop: '0.125rem',
}}>{stat.label}</span>
</div>
))}
</section>
{/* 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>
)}
{/* WORKOUT HISTORY */}
<section style={{ marginBottom: '1.75rem' }}>
<h2 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.8125rem',
color: '#ffffff',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '0.875rem',
}}>Träningshistorik</h2>
{/* 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>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.625rem' }}>
{workoutHistory.map((session) => (
<div key={session.id}>
<div
className={session.is_pr ? 'intensity-bar-orange' : 'intensity-bar-lime'}
onClick={() => setExpandedSession(expandedSession === session.id ? null : session.id)}
style={{
background: '#1a1a1a',
borderRadius: expandedSession === session.id ? '10px 10px 0 0' : '10px',
padding: '0.875rem 0.875rem 0.875rem 1.25rem',
cursor: 'pointer',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.7rem',
color: '#767575',
letterSpacing: '0.03em',
display: 'block',
marginBottom: '0.25rem',
textTransform: 'capitalize',
}}>{formatDate(session.date)}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '1rem',
color: '#ffffff',
}}>{session.name}</span>
{session.is_pr && (
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.6rem',
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.7rem',
color: '#767575',
letterSpacing: '0.02em',
}}>{session.duration} min · {session.exercise_count} övningar</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '1rem',
color: '#cafd00',
}}>{formatVolume(session.volume)}</span>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.6rem',
color: '#767575',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>kg</span>
</div>
</div>
</div>
{/* 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>
{/* Expanded exercise list */}
{expandedSession === session.id && session.exercises && (
<div style={{
background: '#131313',
borderRadius: '0 0 10px 10px',
padding: '0.75rem 1.25rem 0.875rem',
borderTop: '1px solid #1f1f1f',
}}>
{session.exercises.map((ex, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.3rem 0',
}}
>
<span style={{
width: 4, height: 4, borderRadius: '50%',
background: '#767575',
flexShrink: 0,
}} />
<span style={{
fontFamily: 'Plus Jakarta Sans, sans-serif',
fontSize: '0.8125rem',
color: '#adaaaa',
}}>{ex}</span>
</div>
))}
</div>
)}
</div>
) : (
<EmptyState message="Inga styrkerekord registrerade" />
)}
</section>
)}
))}
</div>
</section>
{/* ANALYTICS SECTION (existing tabs - secondary) */}
<section>
<h2 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.8125rem',
color: '#ffffff',
textTransform: 'uppercase',
letterSpacing: '0.08em',
marginBottom: '0.875rem',
}}>Mätningar &amp; Styrka</h2>
{/* Tab Navigation */}
<div style={{
display: 'flex',
background: '#131313',
borderRadius: '8px',
padding: '0.25rem',
marginBottom: '1rem',
gap: '0.25rem',
}}>
{[
{ key: 'weight', label: 'Vikt' },
{ key: 'bodyfat', label: 'Kroppsfett' },
{ key: 'strength', label: 'Styrka' },
].map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
flex: 1,
padding: '0.5rem',
background: activeTab === tab.key ? '#1a1a1a' : 'transparent',
border: activeTab === tab.key ? '1px solid #262626' : '1px solid transparent',
borderRadius: '6px',
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: activeTab === tab.key ? '#ffffff' : '#767575',
cursor: 'pointer',
letterSpacing: '0.03em',
transition: 'all 150ms ease',
}}
>{tab.label}</button>
))}
</div>
{/* Weight Chart */}
{activeTab === 'weight' && (
<section className="chart-section">
{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">
{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">
{strength.length > 0 ? (
<div className="strength-charts">
<div className="strength-chart-item">
<h3 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>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 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>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 style={{ fontFamily: 'Lexend', color: '#ffffff', marginBottom: '0.5rem' }}>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>
)}
</section>
</main>
</div>
)
@@ -189,21 +458,20 @@ function ProgressPage({ onBack }) {
// 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
@@ -211,14 +479,11 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
}).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}
@@ -230,8 +495,6 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
strokeDasharray="4"
/>
))}
{/* Y-axis labels */}
{yLabels.map((label, i) => (
<text
key={i}
@@ -244,8 +507,6 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
{label}
</text>
))}
{/* Line */}
<path
d={pathD}
fill="none"
@@ -254,30 +515,21 @@ function SimpleLineChart({ data, valueKey, unit, color }) {
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
fill={color}
/>
<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>
<span>{formatDateShort(data[0]?.created_at)}</span>
<span>{formatDateShort(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
@@ -310,15 +562,29 @@ function ProgressStats({ data, valueKey, unit, label }) {
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 style={{
textAlign: 'center',
padding: '2rem 1rem',
background: '#131313',
borderRadius: '10px',
}}>
<p style={{
fontFamily: 'Plus Jakarta Sans, sans-serif',
fontSize: '0.875rem',
color: '#767575',
marginBottom: '0.5rem',
}}>{message}</p>
<p style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#484847',
letterSpacing: '0.03em',
}}>Logga mätningar för att se din progress</p>
</div>
)
}
function formatDate(dateStr) {
function formatDateShort(dateStr) {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('sv-SE', { month: 'short', day: 'numeric' })
+200 -79
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'
import { Icon } from '../components/Icons'
import SwapWorkoutModal from '../components/SwapWorkoutModal'
import '../styles/kinetic-precision.css'
const API_URL = '/api'
@@ -453,6 +454,8 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
onSwap={() => openAlternatives(exercise)}
onUndo={() => undoSwap(exercise.id)}
canUndo={Boolean(recentSwaps[exercise.id])}
exerciseIndex={idx + 1}
totalExercises={exercises.length}
/>
)
})}
@@ -488,7 +491,7 @@ function WorkoutPage({ day, week, logs, onLogSet, onDeleteSet, onBack, fetchProg
)
}
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo }) {
function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSet, onDeleteSet, onSwap, isSwapped, onStartRest, originalExercise, onUndo, canUndo, exerciseIndex, totalExercises }) {
const [setList, setSetList] = useState([])
const [showAddModal, setShowAddModal] = useState(false)
const weightStep = 2.5
@@ -569,19 +572,62 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
const completedSets = setList.filter(s => s.completed).length
// Compute PR: current set weight exceeds progression last weight
const isPR = (input, idx) => {
const lastWeight = progression?.lastWeight
if (!lastWeight) return false
const w = parseFloat(input.weight)
return !isNaN(w) && w > lastWeight
}
return (
<div className={`exercise-card ${expanded ? 'expanded' : ''} ${completedSets === setList.length && setList.length > 0 ? 'all-done' : ''}`}>
<div className="exercise-header" onClick={onToggle}>
<div className="exercise-info">
<h3>{exercise.name}</h3>
<span className="muscle-group">{exercise.muscle_group}</span>
{isSwapped && originalExercise && (
<span className="swap-badge">Bytt från {originalExercise.name}</span>
{/* EXERCISE FOCUS HEADER */}
<div className="exercise-header" onClick={onToggle} style={{ paddingBottom: expanded ? '0.5rem' : undefined }}>
<div style={{ flex: 1 }}>
{/* Progress indicator */}
{exerciseIndex != null && (
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.65rem',
color: '#767575',
letterSpacing: '0.06em',
textTransform: 'uppercase',
display: 'block',
marginBottom: '0.25rem',
}}>Övning {exerciseIndex} av {totalExercises}</span>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<h3 style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '1.1rem',
color: '#ffffff',
margin: 0,
}}>{exercise.name}</h3>
{isSwapped && originalExercise && (
<span className="swap-badge" style={{ fontSize: '0.6rem' }}>Bytt</span>
)}
</div>
{exercise.muscle_group && (
<span style={{
display: 'inline-block',
marginTop: '0.25rem',
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.7rem',
color: '#767575',
letterSpacing: '0.03em',
}}>{exercise.muscle_group}</span>
)}
</div>
<div className="exercise-actions">
<div className="exercise-meta">
<span className="sets-info">{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: '0.25rem' }}>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#adaaaa',
letterSpacing: '0.03em',
}}>{exercise.sets}×{exercise.reps_min}-{exercise.reps_max}</span>
<span className={`progress-badge ${completedSets === setList.length ? 'complete' : ''}`}>
{completedSets}/{setList.length}
</span>
@@ -617,8 +663,9 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
{expanded && (
<div className="exercise-body">
{/* Progression hint */}
{progression && (
<div className="progression-hint">
<div className="progression-hint" style={{ marginBottom: '0.75rem' }}>
{progression.reason}
{progression.suggestedWeight && (
<strong> {progression.suggestedWeight} kg</strong>
@@ -626,80 +673,154 @@ function ExerciseCard({ exercise, logs, progression, expanded, onToggle, onLogSe
</div>
)}
{/* Target line */}
{(exercise.reps_min || exercise.reps_max) && (
<div style={{
background: '#131313',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '0.75rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.7rem',
color: '#767575',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>Mål</span>
<span style={{
fontFamily: 'Lexend, sans-serif',
fontWeight: 700,
fontSize: '0.875rem',
color: '#adaaaa',
}}>
{exercise.sets} set · {exercise.reps_min}{exercise.reps_max && exercise.reps_max !== exercise.reps_min ? `${exercise.reps_max}` : ''} reps
</span>
</div>
)}
<div className="sets-list">
{setList.map((input, idx) => (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<div className="set-row-top">
<span className="set-number">Set {idx + 1}</span>
{setList.map((input, idx) => {
const setIsPR = isPR(input, idx)
return (
<div key={idx} className={`set-row ${input.completed ? 'completed' : ''}`}>
<div className="set-row-top">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span className="set-number" style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.75rem',
color: '#767575',
letterSpacing: '0.04em',
textTransform: 'uppercase',
}}>Set {idx + 1}</span>
{setIsPR && (
<span style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.6rem',
fontWeight: 700,
color: '#516700',
background: '#cafd00',
padding: '0.1rem 0.35rem',
borderRadius: '4px',
letterSpacing: '0.04em',
}}>PR</span>
)}
</div>
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
>
<Icon name="trash" size={16} />
</button>
</div>
<div className="set-controls">
<div className="set-metric">
<span className="metric-label">Vikt</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
aria-label="Minska vikt"
>
</button>
<div className="metric-value">
<span className="metric-number" style={{
fontFamily: 'Lexend, sans-serif',
color: '#cafd00',
fontSize: '1.35rem',
fontWeight: 700,
}}>{input.weight === '' ? '0' : input.weight}</span>
<span className="metric-suffix">kg</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', weightStep)}
aria-label="Öka vikt"
>
+
</button>
</div>
</div>
<div className="set-metric">
<span className="metric-label">Reps</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
aria-label="Minska reps"
>
</button>
<div className="metric-value">
<span className="metric-number" style={{
fontFamily: 'Lexend, sans-serif',
fontSize: '1.35rem',
fontWeight: 700,
}}>{input.reps === '' ? '0' : input.reps}</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', repsStep)}
aria-label="Öka reps"
>
+
</button>
</div>
</div>
</div>
{/* Previous session reference */}
{progression?.lastWeight && progression?.lastReps && (
<div style={{
fontFamily: 'Space Grotesk, monospace',
fontSize: '0.7rem',
color: '#767575',
letterSpacing: '0.03em',
marginTop: '0.25rem',
marginBottom: '0.25rem',
}}>
Förra träningen: {progression.lastWeight}kg×{progression.lastReps}
</div>
)}
<button
className={`delete-set-btn ${setList.length <= 1 ? 'disabled' : ''}`}
onClick={() => handleDeleteSet(idx)}
disabled={setList.length <= 1}
aria-label={`Ta bort set ${idx + 1}`}
className={`klart-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
<Icon name="trash" size={16} />
{input.completed ? <Icon name="check" size={18} /> : null}
KLART
</button>
</div>
<div className="set-controls">
<div className="set-metric">
<span className="metric-label">Vikt</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', -weightStep)}
aria-label="Minska vikt"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.weight === '' ? '0' : input.weight}</span>
<span className="metric-suffix">kg</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'weight', weightStep)}
aria-label="Öka vikt"
>
+
</button>
</div>
</div>
<div className="set-metric">
<span className="metric-label">Reps</span>
<div className="metric-controls">
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', -repsStep)}
aria-label="Minska reps"
>
</button>
<div className="metric-value">
<span className="metric-number">{input.reps === '' ? '0' : input.reps}</span>
</div>
<button
type="button"
className="metric-btn"
onClick={() => handleAdjust(idx, 'reps', repsStep)}
aria-label="Öka reps"
>
+
</button>
</div>
</div>
</div>
<button
className={`klart-btn ${input.completed ? 'done' : ''}`}
onClick={() => handleComplete(idx)}
>
{input.completed ? <Icon name="check" size={18} /> : null}
KLART
</button>
</div>
))}
)
})}
</div>
<button
+170
View File
@@ -0,0 +1,170 @@
/* ============================================
Kinetic Precision - Stitch Design System
Shared component styles
============================================ */
/* Glassmorphism nav */
.glass-nav {
background: rgba(26, 26, 26, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
/* Kinetic button - lime gradient */
.btn-kinetic {
background: linear-gradient(135deg, #cafd00 0%, #beee00 100%);
color: #516700;
font-family: 'Plus Jakarta Sans', sans-serif;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border: none;
border-radius: 6px;
padding: 0.75rem 1.5rem;
cursor: pointer;
transition: all 150ms ease;
box-shadow: 0 4px 16px rgba(202, 253, 0, 0.3);
}
.btn-kinetic:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(202, 253, 0, 0.4);
}
.btn-kinetic:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(202, 253, 0, 0.3);
}
/* Intensity indicator bar */
.intensity-bar-lime {
position: relative;
}
.intensity-bar-lime::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #cafd00;
border-radius: 4px 0 0 4px;
}
.intensity-bar-orange {
position: relative;
}
.intensity-bar-orange::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background: #ff7440;
border-radius: 4px 0 0 4px;
}
/* Glow progress ring */
.progress-ring-glow {
filter: drop-shadow(0 0 8px rgba(202, 253, 0, 0.5));
}
/* Data label - Space Grotesk */
.data-label {
font-family: 'Space Grotesk', monospace;
font-size: 0.75rem;
letter-spacing: 0.05em;
color: #adaaaa;
text-transform: uppercase;
}
.data-value {
font-family: 'Lexend', sans-serif;
font-weight: 700;
color: #cafd00;
}
/* Section separator via background shift (no borders) */
.surface-low { background: #131313; }
.surface-mid { background: #1a1a1a; }
.surface-high { background: #20201f; }
/* Progress bar */
.progress-bar-track {
width: 100%;
height: 6px;
background: #262626;
border-radius: 3px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #cafd00 0%, #beee00 100%);
border-radius: 3px;
transition: width 400ms ease;
box-shadow: 0 0 8px rgba(202, 253, 0, 0.4);
}
.progress-bar-fill.secondary {
background: linear-gradient(90deg, #ff7440 0%, #ff8c5a 100%);
box-shadow: 0 0 8px rgba(255, 116, 64, 0.4);
}
/* Benchmark card */
.benchmark-card {
background: #1a1a1a;
border-radius: 8px;
padding: 1rem 1.25rem;
position: relative;
overflow: hidden;
}
/* Stat chip - Space Grotesk number display */
.stat-chip {
display: inline-flex;
align-items: baseline;
gap: 0.25rem;
}
.stat-chip .stat-number {
font-family: 'Lexend', sans-serif;
font-weight: 700;
font-size: 1.5rem;
color: #cafd00;
}
.stat-chip .stat-unit {
font-family: 'Space Grotesk', monospace;
font-size: 0.75rem;
color: #767575;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Goal badge */
.goal-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 4px;
font-family: 'Space Grotesk', monospace;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.goal-badge.active {
background: rgba(202, 253, 0, 0.12);
color: #cafd00;
}
.goal-badge.secondary {
background: rgba(255, 116, 64, 0.12);
color: #ff7440;
}