All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+73 -132
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
LineChart, Line, AreaChart, Area, BarChart, Bar,
@@ -10,6 +10,7 @@ import StatCard from '../components/ui/StatCard'
import { formatSleep, formatWeight, formatHeartRate } from '../utils/format'
const RANGES = [
{ label: '1W', days: 7 },
{ label: '2W', days: 14 },
{ label: '1M', days: 30 },
{ label: '3M', days: 90 },
@@ -17,7 +18,13 @@ const RANGES = [
{ label: '1Y', days: 365 },
]
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }
function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
const vals = data.filter(d => d[dataKey] != null)
if (!vals.length) return (
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
)
return (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 0 }}>
@@ -28,36 +35,14 @@ function MetricChart({ data, dataKey, color, formatter, height = 140 }) {
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
width={32}
tickFormatter={formatter}
/>
<Tooltip
contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
strokeWidth={2}
fill={`url(#grad-${dataKey})`}
dot={false}
connectNulls={false}
isAnimationActive={false}
/>
<XAxis dataKey="date" tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false}
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={formatter} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
fill={`url(#grad-${dataKey})`} dot={false} connectNulls={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
)
@@ -71,7 +56,8 @@ function SleepChart({ data }) {
light: d.sleep_light_s ? +(d.sleep_light_s / 3600).toFixed(2) : null,
awake: d.sleep_awake_s ? +(d.sleep_awake_s / 3600).toFixed(2) : null,
}))
const hasData = chartData.some(d => d.deep || d.rem || d.light)
if (!hasData) return <div className="flex items-center justify-center h-36 text-gray-600 text-xs">No sleep data</div>
return (
<ResponsiveContainer width="100%" height={140}>
<BarChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 0 }} barSize={6}>
@@ -80,9 +66,8 @@ function SleepChart({ data }) {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={24}
tickFormatter={v => `${v}h`} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" radius={[0, 0, 0, 0]} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="deep" name="Deep" stackId="a" fill="#6366f1" />
<Bar dataKey="rem" name="REM" stackId="a" fill="#8b5cf6" />
<Bar dataKey="light" name="Light" stackId="a" fill="#a78bfa" />
<Bar dataKey="awake" name="Awake" stackId="a" fill="#374151" radius={[2, 2, 0, 0]} />
@@ -92,21 +77,22 @@ function SleepChart({ data }) {
}
export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(30)
const [rangeDays, setRangeDays] = useState(7) // default 1 week
const fromDate = subDays(new Date(), rangeDays).toISOString()
const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays])
const { data: summary } = useQuery({
queryKey: ['health-summary'],
queryFn: () => api.get('/health-metrics/summary').then(r => r.data),
})
const { data: metrics } = useQuery({
const { data: metrics, isLoading } = useQuery({
queryKey: ['health-metrics', rangeDays],
queryFn: () =>
api.get('/health-metrics/', {
params: { from_date: fromDate, limit: rangeDays },
}).then(r => r.data.reverse()),
params: { from_date: fromDate, limit: rangeDays + 1 },
}).then(r => r.data.slice().reverse()), // oldest first for charts
keepPreviousData: true,
})
const latest = summary?.latest
@@ -118,132 +104,75 @@ export default function HealthPage() {
{/* Summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<StatCard
label="Resting HR"
value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined}
accent="red"
/>
<StatCard
label="HRV"
value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined}
/>
<StatCard
label="Sleep"
value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined}
/>
<StatCard
label="Weight"
value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined}
/>
<StatCard
label="VO2 Max"
value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined}
accent="blue"
/>
<StatCard
label="Steps"
value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined}
/>
<StatCard
label="Avg Stress"
value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'}
/>
<StatCard
label="SpO2"
value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'}
/>
<StatCard label="Resting HR" value={formatHeartRate(latest?.resting_hr)}
sub={avg30?.resting_hr ? `30d avg: ${Math.round(avg30.resting_hr)} bpm` : undefined} accent="red" />
<StatCard label="HRV" value={latest?.hrv_nightly_avg ? `${Math.round(latest.hrv_nightly_avg)} ms` : '--'}
sub={latest?.hrv_status || undefined} />
<StatCard label="Sleep" value={formatSleep(latest?.sleep_duration_s)}
sub={latest?.sleep_score ? `Score: ${Math.round(latest.sleep_score)}` : undefined} />
<StatCard label="Weight" value={formatWeight(latest?.weight_kg)}
sub={latest?.body_fat_pct ? `${latest.body_fat_pct.toFixed(1)}% body fat` : undefined} />
<StatCard label="VO2 Max" value={latest?.vo2max ? latest.vo2max.toFixed(1) : '--'}
sub={latest?.fitness_age ? `Fitness age: ${latest.fitness_age}` : undefined} accent="blue" />
<StatCard label="Steps" value={latest?.steps ? latest.steps.toLocaleString() : '--'}
sub={avg30?.steps ? `30d avg: ${Math.round(avg30.steps).toLocaleString()}` : undefined} />
<StatCard label="Stress" value={latest?.avg_stress ? `${Math.round(latest.avg_stress)}` : '--'} />
<StatCard label="SpO2" value={latest?.spo2_avg ? `${latest.spo2_avg.toFixed(1)}%` : '--'} />
</div>
{/* Range selector */}
<div className="flex gap-2">
{RANGES.map(({ label, days }) => (
<button
key={label}
onClick={() => setRangeDays(days)}
<button key={label} onClick={() => setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}
>
rangeDays === days ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
))}
</div>
{metrics && metrics.length > 0 ? (
{isLoading ? (
<div className="text-gray-500 text-sm">Loading</div>
) : metrics && metrics.length > 0 ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Resting HR */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Resting Heart Rate</h3>
<MetricChart
data={metrics}
dataKey="resting_hr"
color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`}
/>
<MetricChart data={metrics} dataKey="resting_hr" color="#f43f5e"
formatter={v => `${Math.round(v)} bpm`} />
</div>
{/* HRV */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">HRV (nightly avg)</h3>
<MetricChart
data={metrics}
dataKey="hrv_nightly_avg"
color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`}
/>
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
formatter={v => `${Math.round(v)} ms`} />
</div>
{/* Sleep */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Sleep Stages</h3>
<SleepChart data={metrics} />
<div className="flex gap-4 mt-2">
{[
{ label: 'Deep', color: '#6366f1' },
{ label: 'REM', color: '#8b5cf6' },
{ label: 'Light', color: '#a78bfa' },
{ label: 'Awake', color: '#374151' },
].map(({ label, color }) => (
<div key={label} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: color }} />
<span className="text-xs text-gray-400">{label}</span>
{[['Deep','#6366f1'],['REM','#8b5cf6'],['Light','#a78bfa'],['Awake','#374151']].map(([l,c]) => (
<div key={l} className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
<span className="text-xs text-gray-400">{l}</span>
</div>
))}
</div>
</div>
{/* Weight */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Weight</h3>
<MetricChart
data={metrics}
dataKey="weight_kg"
color="#34d399"
formatter={v => `${v.toFixed(1)} kg`}
/>
<MetricChart data={metrics} dataKey="weight_kg" color="#34d399"
formatter={v => `${v.toFixed(1)} kg`} />
</div>
{/* VO2 Max */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">VO2 Max</h3>
<MetricChart
data={metrics}
dataKey="vo2max"
color="#3b82f6"
formatter={v => v.toFixed(1)}
/>
<MetricChart data={metrics} dataKey="vo2max" color="#3b82f6" formatter={v => v.toFixed(1)} />
</div>
{/* Steps */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Daily Steps</h3>
<ResponsiveContainer width="100%" height={140}>
@@ -253,18 +182,30 @@ export default function HealthPage() {
tickFormatter={d => format(new Date(d), 'MMM d')} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
tickFormatter={v => v >= 1000 ? `${(v/1000).toFixed(0)}k` : v} />
<Tooltip contentStyle={{ background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12 }}
labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')} />
<Bar dataKey="steps" name="Steps" fill="#fbbf24" radius={[2, 2, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Avg Heart Rate (day)</h3>
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
formatter={v => `${Math.round(v)} bpm`} />
</div>
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Stress Level</h3>
<MetricChart data={metrics} dataKey="avg_stress" color="#a78bfa"
formatter={v => Math.round(v)} />
</div>
</div>
) : (
<div className="text-center py-16 text-gray-600">
<p className="text-4xl mb-3">📊</p>
<p className="text-lg">No health data yet</p>
<p className="text-sm mt-1">Import a Garmin export to see your health trends</p>
<p className="text-lg">No health data for this period</p>
<p className="text-sm mt-1">Import a Garmin export or try a longer date range</p>
</div>
)}
</div>