Implemented all 9 UI fixes across health charts and activity detail pages. Changes are ready to push to git for the Docker build to pick them up.
This commit is contained in:
@@ -6,7 +6,7 @@ import {
|
||||
} from 'recharts'
|
||||
import { format, subDays } from 'date-fns'
|
||||
import api from '../utils/api'
|
||||
import { formatSleep } from '../utils/format'
|
||||
import { formatSleep, sportIcon } from '../utils/format'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '1W', days: 7 },
|
||||
@@ -91,7 +91,17 @@ function inferBBType(tsMs, level, prevLevel, sleepStartMs, sleepEndMs) {
|
||||
return 'stable'
|
||||
}
|
||||
|
||||
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
||||
function ActivityRefLabel({ viewBox, icon }) {
|
||||
if (!viewBox) return null
|
||||
const { x, y } = viewBox
|
||||
return (
|
||||
<text x={x} y={y + 12} textAnchor="middle" fontSize={14} fill="white" style={{ pointerEvents: 'none' }}>
|
||||
{icon}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd, activities }) {
|
||||
if (!bb) return null
|
||||
const { charged, drained, start_level, end_level } = bb
|
||||
if (!hiresValues?.length && !bb.values?.length && end_level == null) return null
|
||||
@@ -112,14 +122,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
||||
|
||||
const presentTypes = [...new Set(chartData.map(d => d.type))]
|
||||
const levelColor = bbLevelColor(end_level)
|
||||
const maxLevel = chartData.length ? Math.max(...chartData.map(d => d.level)) : null
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4 flex flex-col h-full">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-2">Body Battery</h3>
|
||||
|
||||
<div className="flex items-baseline gap-3 flex-wrap mb-3">
|
||||
{end_level != null && (
|
||||
<span className="text-3xl font-bold" style={{ color: levelColor }}>{Math.round(end_level)}</span>
|
||||
{maxLevel != null && (
|
||||
<span className="text-3xl font-bold" style={{ color: bbLevelColor(maxLevel) }}>{Math.round(maxLevel)}</span>
|
||||
)}
|
||||
{charged != null && (
|
||||
<span className="text-sm font-semibold text-green-400">+{charged}</span>
|
||||
@@ -127,18 +138,19 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
||||
{drained != null && (
|
||||
<span className="text-sm font-semibold text-orange-400">-{drained}</span>
|
||||
)}
|
||||
{start_level != null && end_level != null && (
|
||||
<span className="text-xs text-gray-500">{start_level} → {end_level}</span>
|
||||
{end_level != null && (
|
||||
<span className="text-xs text-gray-500">now {Math.round(end_level)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<ResponsiveContainer width="100%" height={100}>
|
||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 0 }} barCategoryGap={0}>
|
||||
<BarChart data={chartData} margin={{ top: 2, right: 4, bottom: 0, left: 28 }} barCategoryGap={0}>
|
||||
<XAxis dataKey="t" tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false}
|
||||
tickFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
interval={Math.max(1, Math.floor(chartData.length / 6))} />
|
||||
<YAxis domain={[0, 100]} hide />
|
||||
<YAxis domain={[0, 100]} tick={{ fontSize: 9, fill: '#6b7280' }} axisLine={false} tickLine={false} width={28}
|
||||
tickFormatter={v => v} ticks={[0, 25, 50, 75, 100]} />
|
||||
<Tooltip contentStyle={tooltipStyle}
|
||||
labelFormatter={ts => format(new Date(ts), 'HH:mm')}
|
||||
formatter={v => [`${Math.round(v)}`, 'Battery']} />
|
||||
@@ -147,6 +159,15 @@ function BodyBatteryChart({ bb, hiresValues, sleepStart, sleepEnd }) {
|
||||
<Cell key={i} fill={BB_INFERRED_COLOR[d.type]} />
|
||||
))}
|
||||
</Bar>
|
||||
{(activities || []).map(a => (
|
||||
<ReferenceLine
|
||||
key={a.id}
|
||||
x={new Date(a.start_time).getTime()}
|
||||
stroke="rgba(255,255,255,0.3)"
|
||||
strokeWidth={1.5}
|
||||
label={<ActivityRefLabel icon={sportIcon(a.sport_type)} />}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -237,6 +258,26 @@ function SleepHypnogram({ sleepStart, sleepEnd, stages }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
||||
const total = (deepS || 0) + (remS || 0) + (lightS || 0) + (awakeS || 0)
|
||||
if (!total) return null
|
||||
const segments = [
|
||||
{ label: 'Deep', s: deepS || 0, color: '#6366f1' },
|
||||
{ label: 'REM', s: remS || 0, color: '#8b5cf6' },
|
||||
{ label: 'Light', s: lightS || 0, color: '#a78bfa' },
|
||||
{ label: 'Awake', s: awakeS || 0, color: '#eab308' },
|
||||
].filter(seg => seg.s > 0)
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="flex h-5 rounded-sm overflow-hidden">
|
||||
{segments.map(seg => (
|
||||
<div key={seg.label} style={{ width: `${(seg.s / total) * 100}%`, backgroundColor: seg.color }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HrvBadge({ status }) {
|
||||
if (!status) return null
|
||||
const palette = {
|
||||
@@ -263,7 +304,7 @@ function NavArrow({ onClick, disabled, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStages, activities, onOlder, onNewer, hasOlder, hasNewer }) {
|
||||
if (!day) return (
|
||||
<div className="text-center py-10 text-gray-600">
|
||||
<p className="text-3xl mb-2">📊</p>
|
||||
@@ -323,10 +364,17 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
</div>
|
||||
{hasSleepStages ? (
|
||||
<>
|
||||
<SleepHypnogram
|
||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||
stages={sleepStages}
|
||||
/>
|
||||
{sleepStages?.length ? (
|
||||
<SleepHypnogram
|
||||
sleepStart={day.sleep_start} sleepEnd={day.sleep_end}
|
||||
stages={sleepStages}
|
||||
/>
|
||||
) : (
|
||||
<SleepStageFallbackBar
|
||||
deepS={day.sleep_deep_s} remS={day.sleep_rem_s}
|
||||
lightS={day.sleep_light_s} awakeS={day.sleep_awake_s}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-1.5 mt-2">
|
||||
{[
|
||||
['Deep', day.sleep_deep_s, '#6366f1'],
|
||||
@@ -417,7 +465,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} />
|
||||
<BodyBatteryChart bb={bodyBattery} hiresValues={bbHires} sleepStart={day?.sleep_start} sleepEnd={day?.sleep_end} activities={activities} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -472,28 +520,13 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
{day.spo2_avg ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-sky-400">{day.spo2_avg.toFixed(1)}</span>
|
||||
<span className="text-xs text-gray-500">%</span>
|
||||
</div>
|
||||
</>
|
||||
) : day.vo2max ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-blue-400">{day.vo2max.toFixed(1)}</span>
|
||||
</div>
|
||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 mb-1">SpO2</p>
|
||||
<span className="text-2xl font-bold text-white">--</span>
|
||||
</>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mb-1">VO2 Max</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-blue-400">
|
||||
{day.vo2max ? day.vo2max.toFixed(1) : '--'}
|
||||
</span>
|
||||
</div>
|
||||
{day.fitness_age && <p className="text-xs text-gray-500 mt-1">Fitness age {day.fitness_age}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -502,7 +535,7 @@ function DailySnapshot({ day, avg30, intradayHr, bodyBattery, bbHires, sleepStag
|
||||
|
||||
// ── Trend Charts ────────────────────────────────────────────────────────────
|
||||
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false }) {
|
||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines }) {
|
||||
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>
|
||||
@@ -528,12 +561,15 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
||||
<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} />
|
||||
tickFormatter={formatter} domain={domain} />
|
||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||
{selectedDate && (
|
||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||
)}
|
||||
{(referenceLines || []).map((rl, i) => (
|
||||
<ReferenceLine key={i} {...rl} />
|
||||
))}
|
||||
<Area type="monotone" dataKey={dataKey} stroke={color} strokeWidth={2}
|
||||
fill={`url(#grad-${dataKey})`}
|
||||
dot={showDots ? { fill: color, r: 3, strokeWidth: 0 } : false}
|
||||
@@ -643,6 +679,16 @@ export default function HealthPage() {
|
||||
enabled: !!selectedDay?.date,
|
||||
})
|
||||
|
||||
const { data: dayActivities } = useQuery({
|
||||
queryKey: ['activities-day', selectedDay?.date],
|
||||
queryFn: () => api.get('/activities/', { params: {
|
||||
from_date: selectedDay.date + 'T00:00:00',
|
||||
to_date: selectedDay.date + 'T23:59:59',
|
||||
per_page: 20,
|
||||
}}).then(r => r.data),
|
||||
enabled: !!selectedDay?.date,
|
||||
})
|
||||
|
||||
const handleDayClick = (dateStr) => setSelectedDateStr(d10(dateStr))
|
||||
const goOlder = () => {
|
||||
if (selectedIdx < allDaysSorted.length - 1)
|
||||
@@ -667,6 +713,7 @@ export default function HealthPage() {
|
||||
bodyBattery={intradayData?.body_battery}
|
||||
bbHires={intradayData?.body_battery_hires}
|
||||
sleepStages={intradayData?.sleep_stages}
|
||||
activities={dayActivities}
|
||||
onOlder={goOlder}
|
||||
onNewer={goNewer}
|
||||
hasOlder={selectedIdx >= 0 && selectedIdx < allDaysSorted.length - 1}
|
||||
@@ -703,7 +750,8 @@ export default function HealthPage() {
|
||||
<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`}
|
||||
formatter={v => Math.round(v)}
|
||||
domain={[0, 200]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
@@ -711,7 +759,12 @@ export default function HealthPage() {
|
||||
<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`}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||
referenceLines={[
|
||||
{ y: 20, stroke: '#f59e0b', strokeDasharray: '3 3', label: { value: 'Low', position: 'insideTopRight', fill: '#f59e0b', fontSize: 9 } },
|
||||
{ y: 60, stroke: '#22c55e', strokeDasharray: '3 3', label: { value: 'Good', position: 'insideTopRight', fill: '#22c55e', fontSize: 9 } },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
|
||||
@@ -769,13 +822,15 @@ export default function HealthPage() {
|
||||
<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)}
|
||||
domain={[0, 100]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</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">Heart Rate</h3>
|
||||
<MetricChart data={metrics} dataKey="avg_hr_day" color="#f97316"
|
||||
formatter={v => `${Math.round(v)} bpm`}
|
||||
formatter={v => Math.round(v)}
|
||||
domain={[0, 200]}
|
||||
selectedDate={selDateForCharts} onDayClick={handleDayClick} />
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user