- Creates rest_days table for rest day tracking - Creates vitals_log table for resting HR + HRV - Creates weekly_goals table for training planning - Extends profiles with hf_max and sleep_goal_minutes columns - Extends activity_log with avg_hr and max_hr columns - Fixes sleep_goal_minutes missing column error in stats endpoint - Includes stats error handling in SleepWidget Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
112 lines
3.6 KiB
JavaScript
112 lines
3.6 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Moon, Plus } from 'lucide-react'
|
|
import { api } from '../utils/api'
|
|
|
|
/**
|
|
* SleepWidget - Dashboard widget for sleep tracking (v9d Phase 2b)
|
|
*
|
|
* Shows:
|
|
* - Last night's sleep (if exists)
|
|
* - 7-day average
|
|
* - Quick action button to add entry or view details
|
|
*/
|
|
export default function SleepWidget() {
|
|
const nav = useNavigate()
|
|
const [loading, setLoading] = useState(true)
|
|
const [lastNight, setLastNight] = useState(null)
|
|
const [stats, setStats] = useState(null)
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [])
|
|
|
|
const load = () => {
|
|
Promise.all([
|
|
api.listSleep(1), // Get last entry
|
|
api.getSleepStats(7).catch(() => null) // Stats optional
|
|
]).then(([sleepData, statsData]) => {
|
|
setLastNight(sleepData[0] || null)
|
|
setStats(statsData)
|
|
setLoading(false)
|
|
}).catch(err => {
|
|
console.error('Failed to load sleep widget:', err)
|
|
setLoading(false)
|
|
})
|
|
}
|
|
|
|
const formatDuration = (minutes) => {
|
|
const h = Math.floor(minutes / 60)
|
|
const m = minutes % 60
|
|
return `${h}h ${m}min`
|
|
}
|
|
|
|
const renderStars = (quality) => {
|
|
if (!quality) return '—'
|
|
return '★'.repeat(quality) + '☆'.repeat(5 - quality)
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
<Moon size={16} color="var(--accent)" />
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>Schlaf</div>
|
|
</div>
|
|
<div style={{ textAlign: 'center', padding: 20 }}>
|
|
<div className="spinner" style={{ width: 20, height: 20, margin: '0 auto' }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="card" style={{ padding: 16 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12 }}>
|
|
<Moon size={16} color="var(--accent)" />
|
|
<div style={{ fontWeight: 600, fontSize: 14 }}>Schlaf</div>
|
|
</div>
|
|
|
|
{lastNight ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
<div style={{ fontSize: 12, color: 'var(--text3)' }}>Letzte Nacht:</div>
|
|
<div style={{ fontSize: 16, fontWeight: 700 }}>
|
|
{lastNight.duration_formatted} {lastNight.quality && `· ${renderStars(lastNight.quality)}`}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>
|
|
{new Date(lastNight.date).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit' })}
|
|
</div>
|
|
|
|
{stats && stats.total_nights > 0 && (
|
|
<div style={{ marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--border)' }}>
|
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>Ø 7 Tage:</div>
|
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
|
{formatDuration(Math.round(stats.avg_duration_minutes))}
|
|
{stats.avg_quality && ` · ${stats.avg_quality.toFixed(1)}/5`}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div style={{ textAlign: 'center', padding: '12px 0', color: 'var(--text3)' }}>
|
|
<div style={{ fontSize: 12, marginBottom: 12 }}>Noch keine Einträge erfasst</div>
|
|
</div>
|
|
)}
|
|
|
|
<button
|
|
onClick={() => nav('/sleep')}
|
|
className="btn btn-secondary btn-full"
|
|
style={{ marginTop: 12, fontSize: 12 }}
|
|
>
|
|
{lastNight ? (
|
|
<>Zur Übersicht</>
|
|
) : (
|
|
<>
|
|
<Plus size={14} /> Schlaf erfassen
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|