HRV baseline band from Garmin + dashboard HRV colours + route name on recent activities + sync-now race fix
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, desc, delete
|
from sqlalchemy import select, func, desc, delete
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -28,6 +29,7 @@ class ActivitySummary(BaseModel):
|
|||||||
bounding_box: Optional[dict]
|
bounding_box: Optional[dict]
|
||||||
hr_zones: Optional[dict]
|
hr_zones: Optional[dict]
|
||||||
named_route_id: Optional[int]
|
named_route_id: Optional[int]
|
||||||
|
named_route_name: Optional[str] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -110,7 +112,7 @@ async def list_activities(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
q = select(Activity).where(Activity.user_id == current_user.id)
|
q = select(Activity).options(selectinload(Activity.named_route)).where(Activity.user_id == current_user.id)
|
||||||
|
|
||||||
if sport_type:
|
if sport_type:
|
||||||
q = q.where(Activity.sport_type == sport_type)
|
q = q.where(Activity.sport_type == sport_type)
|
||||||
@@ -133,7 +135,7 @@ async def get_activity(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(Activity).where(
|
select(Activity).options(selectinload(Activity.named_route)).where(
|
||||||
Activity.id == activity_id,
|
Activity.id == activity_id,
|
||||||
Activity.user_id == current_user.id,
|
Activity.user_id == current_user.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ class HealthMetricOut(BaseModel):
|
|||||||
hrv_status: Optional[str]
|
hrv_status: Optional[str]
|
||||||
hrv_5min_high: Optional[float]
|
hrv_5min_high: Optional[float]
|
||||||
hrv_5min_low: Optional[float]
|
hrv_5min_low: Optional[float]
|
||||||
|
hrv_baseline_low: Optional[float]
|
||||||
|
hrv_baseline_upper: Optional[float]
|
||||||
sleep_duration_s: Optional[float]
|
sleep_duration_s: Optional[float]
|
||||||
sleep_deep_s: Optional[float]
|
sleep_deep_s: Optional[float]
|
||||||
sleep_light_s: Optional[float]
|
sleep_light_s: Optional[float]
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ async def init_db():
|
|||||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS intraday_hr JSONB",
|
||||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS body_battery JSONB",
|
||||||
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS sleep_stages JSON",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_low FLOAT",
|
||||||
|
"ALTER TABLE health_metrics ADD COLUMN IF NOT EXISTS hrv_baseline_upper FLOAT",
|
||||||
]:
|
]:
|
||||||
await conn.execute(text(stmt))
|
await conn.execute(text(stmt))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ class Activity(Base):
|
|||||||
named_route = relationship("NamedRoute", back_populates="activities")
|
named_route = relationship("NamedRoute", back_populates="activities")
|
||||||
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
laps = relationship("ActivityLap", back_populates="activity", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def named_route_name(self):
|
||||||
|
"""Name of the associated NamedRoute, or None. Reads the relationship only
|
||||||
|
if it was eager-loaded (selectinload) so it never triggers a lazy load in
|
||||||
|
the async request context — returns None when unloaded."""
|
||||||
|
from sqlalchemy import inspect as sa_inspect
|
||||||
|
if "named_route" in sa_inspect(self).unloaded:
|
||||||
|
return None
|
||||||
|
return self.named_route.name if self.named_route else None
|
||||||
|
|
||||||
|
|
||||||
class ActivityDataPoint(Base):
|
class ActivityDataPoint(Base):
|
||||||
__tablename__ = "activity_data_points"
|
__tablename__ = "activity_data_points"
|
||||||
@@ -254,6 +264,8 @@ class HealthMetric(Base):
|
|||||||
hrv_nightly_avg = Column(Float, nullable=True)
|
hrv_nightly_avg = Column(Float, nullable=True)
|
||||||
hrv_5min_high = Column(Float, nullable=True)
|
hrv_5min_high = Column(Float, nullable=True)
|
||||||
hrv_5min_low = Column(Float, nullable=True)
|
hrv_5min_low = Column(Float, nullable=True)
|
||||||
|
hrv_baseline_low = Column(Float, nullable=True) # Garmin balanced range lower bound (balancedLow)
|
||||||
|
hrv_baseline_upper = Column(Float, nullable=True) # Garmin balanced range upper bound (balancedUpper)
|
||||||
sleep_duration_s = Column(Float, nullable=True)
|
sleep_duration_s = Column(Float, nullable=True)
|
||||||
sleep_deep_s = Column(Float, nullable=True)
|
sleep_deep_s = Column(Float, nullable=True)
|
||||||
sleep_light_s = Column(Float, nullable=True)
|
sleep_light_s = Column(Float, nullable=True)
|
||||||
|
|||||||
@@ -602,6 +602,10 @@ def _parse_day(stats, sleep_data, hrv_data) -> dict:
|
|||||||
status = summary.get("status")
|
status = summary.get("status")
|
||||||
if status:
|
if status:
|
||||||
row["hrv_status"] = str(status).lower()
|
row["hrv_status"] = str(status).lower()
|
||||||
|
# Garmin's per-day balanced baseline range (the grey band in the app).
|
||||||
|
baseline = summary.get("baseline") or {}
|
||||||
|
_set(row, "hrv_baseline_low", baseline.get("balancedLow"))
|
||||||
|
_set(row, "hrv_baseline_upper", baseline.get("balancedUpper"))
|
||||||
|
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -10,7 +10,7 @@ services:
|
|||||||
POSTGRES_USER: ${DB_USER:-milevault}
|
POSTGRES_USER: ${DB_USER:-milevault}
|
||||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-milevault}
|
||||||
volumes:
|
volumes:
|
||||||
- db_data:/var/lib/postgresql/data
|
- ./db_data:/var/lib/postgresql/data
|
||||||
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
- ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-milevault} -d milevault"]
|
||||||
@@ -25,7 +25,7 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
command: redis-server --requirepass ${REDIS_PASSWORD:-milevault}
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- ./redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-milevault}", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
ENVIRONMENT: ${ENVIRONMENT:-production}
|
ENVIRONMENT: ${ENVIRONMENT:-production}
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -76,7 +76,7 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -96,7 +96,7 @@ services:
|
|||||||
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
SECRET_KEY: ${SECRET_KEY:-changeme_please_set_in_env_file_32chars}
|
||||||
FILE_STORE_PATH: /data/files
|
FILE_STORE_PATH: /data/files
|
||||||
volumes:
|
volumes:
|
||||||
- file_data:/data/files
|
- ./file_data:/data/files
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -124,8 +124,3 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
- frontend
|
- frontend
|
||||||
|
|
||||||
volumes:
|
|
||||||
db_data:
|
|
||||||
redis_data:
|
|
||||||
file_data:
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Coloured pill for a Garmin HRV status (balanced / unbalanced / low / poor).
|
||||||
|
// Shared by the Health page and the Dashboard HRV widget so the palette stays
|
||||||
|
// consistent across the app.
|
||||||
|
const HRV_PALETTE = {
|
||||||
|
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
||||||
|
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
||||||
|
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
||||||
|
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HrvBadge({ status }) {
|
||||||
|
if (!status) return null
|
||||||
|
const cls = HRV_PALETTE[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
||||||
|
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
||||||
|
}
|
||||||
@@ -39,17 +39,42 @@ export const useSyncStore = create((set, get) => ({
|
|||||||
connected: false,
|
connected: false,
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
email: '',
|
email: '',
|
||||||
|
// Set when the user manually triggers a sync; cleared once the worker takes
|
||||||
|
// over or finishes (see poll). prevSyncAt snapshots last_sync_at at trigger
|
||||||
|
// time so we can detect completion without relying on clock-synced times.
|
||||||
|
triggeredAt: null,
|
||||||
|
prevSyncAt: null,
|
||||||
|
|
||||||
poll: async () => {
|
poll: async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get('/garmin-sync/config')
|
const { data } = await api.get('/garmin-sync/config')
|
||||||
const status = data?.last_sync_status ?? ''
|
const status = data?.last_sync_status ?? ''
|
||||||
const inProgress = !!status && !isTerminal(status)
|
const lastSyncAt = data?.last_sync_at ?? null
|
||||||
|
let inProgress = !!status && !isTerminal(status)
|
||||||
|
|
||||||
|
// Grace window after a manual trigger. The Celery worker may not have
|
||||||
|
// updated last_sync_status yet, so the config can still report the
|
||||||
|
// PREVIOUS (terminal) status. Without this, the first poll fired right
|
||||||
|
// after triggering would clear inProgress and the button would look dead
|
||||||
|
// until clicked a second time. Keep the sync "in progress" until the
|
||||||
|
// worker either starts (non-terminal status) or finishes (last_sync_at
|
||||||
|
// changed from its pre-trigger value), with a hard cap as a safety net.
|
||||||
|
const { triggeredAt, prevSyncAt } = get()
|
||||||
|
if (triggeredAt) {
|
||||||
|
const finished = lastSyncAt && lastSyncAt !== prevSyncAt
|
||||||
|
if (inProgress || finished) {
|
||||||
|
set({ triggeredAt: null })
|
||||||
|
} else if (Date.now() - triggeredAt < 90000) {
|
||||||
|
inProgress = true
|
||||||
|
} else {
|
||||||
|
set({ triggeredAt: null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
set({
|
set({
|
||||||
status, inProgress,
|
status, inProgress,
|
||||||
connected: !!data?.connected,
|
connected: !!data?.connected,
|
||||||
lastSyncAt: data?.last_sync_at ?? null,
|
lastSyncAt, email: data?.email ?? '',
|
||||||
email: data?.email ?? '',
|
|
||||||
})
|
})
|
||||||
return inProgress
|
return inProgress
|
||||||
} catch {
|
} catch {
|
||||||
@@ -74,11 +99,11 @@ export const useSyncStore = create((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
trigger: async () => {
|
trigger: async () => {
|
||||||
set({ inProgress: true, status: 'Starting sync…' })
|
set({ inProgress: true, status: 'Starting sync…', triggeredAt: Date.now(), prevSyncAt: get().lastSyncAt })
|
||||||
try {
|
try {
|
||||||
await api.post('/garmin-sync/trigger')
|
await api.post('/garmin-sync/trigger')
|
||||||
} catch {
|
} catch {
|
||||||
set({ inProgress: false })
|
set({ inProgress: false, triggeredAt: null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
get().stopPolling()
|
get().stopPolling()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { startOfWeek, format, subWeeks, eachWeekOfInterval, subDays, addDays } f
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { useIsMobile } from '../hooks/useMediaQuery'
|
import { useIsMobile } from '../hooks/useMediaQuery'
|
||||||
import StatCard from '../components/ui/StatCard'
|
import StatCard from '../components/ui/StatCard'
|
||||||
|
import HrvBadge from '../components/ui/HrvBadge'
|
||||||
import ActivityMap from '../components/activity/ActivityMap'
|
import ActivityMap from '../components/activity/ActivityMap'
|
||||||
import {
|
import {
|
||||||
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
formatDuration, formatDistance, formatHeartRate, formatElevation,
|
||||||
@@ -23,20 +24,13 @@ const Grid = WidthProvider(GridLayout)
|
|||||||
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
|
||||||
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
const tooltipStyle = { background: '#111827', border: '1px solid #374151', borderRadius: 8, fontSize: 12, color: '#fff' }
|
||||||
|
|
||||||
const HRV_PALETTE = {
|
|
||||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
|
||||||
unbalanced: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
|
||||||
low: 'text-red-400 bg-red-400/10 border-red-400/30',
|
|
||||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
// Compact single-stat widgets. val(health, ytdStats) → display string.
|
||||||
const STAT_DEFS = {
|
const STAT_DEFS = {
|
||||||
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
stat_steps: { label: 'Steps today', accent: 'green', sub: 'goal 10,000', val: h => h.steps != null ? h.steps.toLocaleString() : '--' },
|
||||||
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
stat_resting_hr: { label: 'Resting HR', accent: 'red', val: h => formatHeartRate(h.resting_hr) },
|
||||||
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
stat_sleep: { label: 'Sleep', accent: 'default', val: h => formatSleep(h.sleep_duration_s) },
|
||||||
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
stat_vo2max: { label: 'VO₂ max', accent: 'blue', val: h => h.vo2max != null ? h.vo2max.toFixed(1) : '--', sub: h => h.fitness_age != null ? `fitness age ${h.fitness_age}` : undefined },
|
||||||
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status || undefined },
|
stat_hrv: { label: 'HRV status', accent: 'purple', val: h => h.hrv_nightly_avg != null ? `${Math.round(h.hrv_nightly_avg)} ms` : '--', sub: h => h.hrv_status ? <HrvBadge status={h.hrv_status} /> : undefined },
|
||||||
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
stat_running: { label: 'Running this year', accent: 'blue', val: (h, y) => y ? `${y.running_km.toFixed(0)} km` : '--' },
|
||||||
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
stat_cycling: { label: 'Cycling this year', accent: 'orange', val: (h, y) => y ? `${y.cycling_km.toFixed(0)} km` : '--' },
|
||||||
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
stat_stress: { label: 'Stress', accent: 'purple', val: h => h.avg_stress != null ? Math.round(h.avg_stress) : '--' },
|
||||||
@@ -410,6 +404,9 @@ function RecentActivities({ activities }) {
|
|||||||
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
<span className="text-lg">{sportIcon(activity.sport_type)}</span>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
<p className="text-sm font-medium text-white truncate">{activity.name}</p>
|
||||||
|
{activity.named_route_name && (
|
||||||
|
<p className="text-xs text-blue-400 truncate">📍 {activity.named_route_name}</p>
|
||||||
|
)}
|
||||||
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
<p className="text-xs text-gray-500">{formatDate(activity.start_time)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-sm">
|
<div className="text-right text-sm">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
|
|||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
import { formatSleep, sportIcon } from '../utils/format'
|
import { formatSleep, sportIcon } from '../utils/format'
|
||||||
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
|
||||||
|
import HrvBadge from '../components/ui/HrvBadge'
|
||||||
|
|
||||||
const RANGES = [
|
const RANGES = [
|
||||||
{ label: '1W', days: 7 },
|
{ label: '1W', days: 7 },
|
||||||
@@ -414,18 +415,6 @@ function SleepStageFallbackBar({ deepS, remS, lightS, awakeS }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function HrvBadge({ status }) {
|
|
||||||
if (!status) return null
|
|
||||||
const palette = {
|
|
||||||
balanced: 'text-green-400 bg-green-400/10 border-green-400/30',
|
|
||||||
unbalanced: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/30',
|
|
||||||
low: 'text-orange-400 bg-orange-400/10 border-orange-400/30',
|
|
||||||
poor: 'text-red-400 bg-red-400/10 border-red-400/30',
|
|
||||||
}
|
|
||||||
const cls = palette[status.toLowerCase()] || 'text-gray-400 bg-gray-400/10 border-gray-400/30'
|
|
||||||
return <span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{status}</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavArrow({ onClick, disabled, children }) {
|
function NavArrow({ onClick, disabled, children }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -688,15 +677,24 @@ const statusDot = (statusKey) => (props) => {
|
|||||||
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
return <circle cx={cx} cy={cy} r={3.5} fill={color} stroke="#111827" strokeWidth={1} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey }) {
|
function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDate, onDayClick, connectNulls = false, showDots = false, domain, referenceLines, statusDotKey, bandLowKey, bandHighKey, bandColor = '#9ca3af' }) {
|
||||||
const vals = data.filter(d => d[dataKey] != null)
|
const vals = data.filter(d => d[dataKey] != null)
|
||||||
if (!vals.length) return (
|
if (!vals.length) return (
|
||||||
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
<div className="flex items-center justify-center text-gray-600 text-xs" style={{ height }}>No data</div>
|
||||||
)
|
)
|
||||||
|
// Range band (e.g. Garmin's HRV baseline): Recharts renders an Area as a band
|
||||||
|
// when its dataKey resolves to a [low, high] pair.
|
||||||
|
const hasBand = bandLowKey && bandHighKey
|
||||||
|
const chartData = hasBand
|
||||||
|
? data.map(d => ({
|
||||||
|
...d,
|
||||||
|
__band: (d[bandLowKey] != null && d[bandHighKey] != null) ? [d[bandLowKey], d[bandHighKey]] : null,
|
||||||
|
}))
|
||||||
|
: data
|
||||||
return (
|
return (
|
||||||
<ResponsiveContainer width="100%" height={height}>
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
<ComposedChart
|
<ComposedChart
|
||||||
data={data}
|
data={chartData}
|
||||||
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
margin={{ top: 4, right: 4, bottom: 4, left: 0 }}
|
||||||
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
style={{ cursor: onDayClick ? 'pointer' : 'default' }}
|
||||||
onClick={evt => {
|
onClick={evt => {
|
||||||
@@ -716,7 +714,11 @@ function MetricChart({ data, dataKey, color, formatter, height = 140, selectedDa
|
|||||||
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
<YAxis tick={{ fontSize: 10, fill: '#6b7280' }} axisLine={false} tickLine={false} width={36}
|
||||||
tickFormatter={formatter} domain={domain} />
|
tickFormatter={formatter} domain={domain} />
|
||||||
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
<Tooltip contentStyle={tooltipStyle} labelFormatter={d => format(new Date(d), 'MMM d, yyyy')}
|
||||||
formatter={v => [formatter ? formatter(v) : v?.toFixed(1)]} />
|
formatter={(v, name) => name === '__band' ? null : [formatter ? formatter(v) : v?.toFixed(1)]} />
|
||||||
|
{hasBand && (
|
||||||
|
<Area type="monotone" dataKey="__band" stroke="none" fill={bandColor} fillOpacity={0.18}
|
||||||
|
connectNulls isAnimationActive={false} legendType="none" activeDot={false} />
|
||||||
|
)}
|
||||||
{selectedDate && (
|
{selectedDate && (
|
||||||
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
<ReferenceLine x={selectedDate} stroke="#60a5fa" strokeWidth={1.5} strokeDasharray="4 2" />
|
||||||
)}
|
)}
|
||||||
@@ -1065,17 +1067,14 @@ export default function HealthPage() {
|
|||||||
<div className="flex items-center gap-3 text-xs text-gray-500">
|
<div className="flex items-center gap-3 text-xs text-gray-500">
|
||||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} /> Balanced</span>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#22c55e' }} /> Balanced</span>
|
||||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
|
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#f97316' }} /> Unbalanced</span>
|
||||||
<span className="flex items-center gap-1"><span className="w-2 h-2 rounded-full" style={{ background: '#ef4444' }} /> Low</span>
|
<span className="flex items-center gap-1"><span className="w-3 h-2 rounded-sm" style={{ background: '#9ca3af', opacity: 0.5 }} /> Baseline</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
<MetricChart data={metrics} dataKey="hrv_nightly_avg" color="#8b5cf6"
|
||||||
formatter={v => `${Math.round(v)} ms`}
|
formatter={v => `${Math.round(v)} ms`}
|
||||||
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
selectedDate={selDateForCharts} onDayClick={handleDayClick}
|
||||||
statusDotKey="hrv_status"
|
statusDotKey="hrv_status"
|
||||||
referenceLines={[
|
bandLowKey="hrv_baseline_low" bandHighKey="hrv_baseline_upper" bandColor="#9ca3af"
|
||||||
{ 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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user