mirror of
https://github.com/hanzoskill/garmin-health-analysis.git
synced 2026-10-30 21:01:12 +00:00
561 lines
19 KiB
Python
561 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Generate interactive HTML charts from Garmin health data.
|
|
Uses Chart.js for visualizations.
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import argparse
|
|
import webbrowser
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
|
|
# Import auth and data helpers
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|
from garmin_auth import get_client
|
|
from garmin_data import fetch_sleep, fetch_hrv, fetch_body_battery, fetch_heart_rate, fetch_activities, fetch_stress
|
|
|
|
|
|
def generate_html(charts_data, title="Garmin Health Dashboard"):
|
|
"""Generate HTML with Chart.js visualizations."""
|
|
|
|
html = f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>{title}</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
|
<style>
|
|
* {{
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}}
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
|
color: #fff;
|
|
padding: 20px;
|
|
min-height: 100vh;
|
|
}}
|
|
.container {{
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
}}
|
|
h1 {{
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
font-size: 2.5rem;
|
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
|
}}
|
|
.stats-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 40px;
|
|
}}
|
|
.stat-card {{
|
|
background: rgba(255, 255, 255, 0.1);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
text-align: center;
|
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
}}
|
|
.stat-value {{
|
|
font-size: 2.5rem;
|
|
font-weight: bold;
|
|
margin: 10px 0;
|
|
}}
|
|
.stat-label {{
|
|
font-size: 0.9rem;
|
|
opacity: 0.8;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
}}
|
|
.charts-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(500px, 1fr));
|
|
gap: 30px;
|
|
}}
|
|
.chart-container {{
|
|
background: rgba(255, 255, 255, 0.05);
|
|
backdrop-filter: blur(10px);
|
|
border-radius: 16px;
|
|
padding: 30px;
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}}
|
|
.chart-title {{
|
|
font-size: 1.3rem;
|
|
margin-bottom: 20px;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
}}
|
|
canvas {{
|
|
max-height: 400px;
|
|
}}
|
|
.footer {{
|
|
text-align: center;
|
|
margin-top: 40px;
|
|
opacity: 0.6;
|
|
font-size: 0.9rem;
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>{title}</h1>
|
|
|
|
<div class="stats-grid" id="stats"></div>
|
|
|
|
<div class="charts-grid" id="charts"></div>
|
|
|
|
<div class="footer">
|
|
Generated on {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
Chart.defaults.color = '#fff';
|
|
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
|
|
|
|
const chartsData = {json.dumps(charts_data)};
|
|
|
|
// Render stats cards
|
|
function renderStats(stats) {{
|
|
const container = document.getElementById('stats');
|
|
for (const [label, value] of Object.entries(stats)) {{
|
|
const card = document.createElement('div');
|
|
card.className = 'stat-card';
|
|
card.innerHTML = `
|
|
<div class="stat-label">${{label}}</div>
|
|
<div class="stat-value">${{value}}</div>
|
|
`;
|
|
container.appendChild(card);
|
|
}}
|
|
}}
|
|
|
|
// Render chart
|
|
function renderChart(config) {{
|
|
const container = document.createElement('div');
|
|
container.className = 'chart-container';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'chart-title';
|
|
title.textContent = config.title;
|
|
container.appendChild(title);
|
|
|
|
const canvas = document.createElement('canvas');
|
|
container.appendChild(canvas);
|
|
|
|
document.getElementById('charts').appendChild(container);
|
|
|
|
new Chart(canvas, config.chart);
|
|
}}
|
|
|
|
// Render all data
|
|
if (chartsData.stats) {{
|
|
renderStats(chartsData.stats);
|
|
}}
|
|
|
|
if (chartsData.charts) {{
|
|
chartsData.charts.forEach(renderChart);
|
|
}}
|
|
</script>
|
|
</body>
|
|
</html>"""
|
|
|
|
return html
|
|
|
|
|
|
def create_sleep_chart(sleep_data):
|
|
"""Create sleep analysis chart."""
|
|
dates = [s["date"] for s in sleep_data if s.get("sleep_time_seconds")]
|
|
hours = [s["sleep_time_seconds"] / 3600 for s in sleep_data if s.get("sleep_time_seconds")]
|
|
scores = [s.get("sleep_score", 0) for s in sleep_data if s.get("sleep_score")]
|
|
|
|
avg_hours = sum(hours) / len(hours) if hours else 0
|
|
avg_score = sum(scores) / len(scores) if scores else 0
|
|
|
|
return {
|
|
"stats": {
|
|
"Avg Sleep": f"{avg_hours:.1f}h",
|
|
"Avg Score": f"{avg_score:.0f}/100"
|
|
},
|
|
"chart": {
|
|
"title": "Sleep Analysis",
|
|
"chart": {
|
|
"type": "bar",
|
|
"data": {
|
|
"labels": dates,
|
|
"datasets": [
|
|
{
|
|
"label": "Sleep Hours",
|
|
"data": hours,
|
|
"backgroundColor": "rgba(54, 162, 235, 0.6)",
|
|
"borderColor": "rgba(54, 162, 235, 1)",
|
|
"borderWidth": 2,
|
|
"yAxisID": "y"
|
|
},
|
|
{
|
|
"label": "Sleep Score",
|
|
"data": scores,
|
|
"type": "line",
|
|
"borderColor": "rgba(255, 206, 86, 1)",
|
|
"backgroundColor": "rgba(255, 206, 86, 0.1)",
|
|
"borderWidth": 3,
|
|
"tension": 0.4,
|
|
"yAxisID": "y1"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"responsive": True,
|
|
"interaction": {
|
|
"mode": "index",
|
|
"intersect": False
|
|
},
|
|
"scales": {
|
|
"y": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "left",
|
|
"title": {
|
|
"display": True,
|
|
"text": "Hours"
|
|
}
|
|
},
|
|
"y1": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "right",
|
|
"title": {
|
|
"display": True,
|
|
"text": "Score"
|
|
},
|
|
"grid": {
|
|
"drawOnChartArea": False
|
|
},
|
|
"max": 100
|
|
}
|
|
},
|
|
"plugins": {
|
|
"legend": {
|
|
"display": True
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def create_body_battery_chart(bb_data):
|
|
"""Create Body Battery (recovery) chart."""
|
|
dates = [b["date"] for b in bb_data if b.get("charged") is not None]
|
|
charged = [b.get("charged", 0) for b in bb_data if b.get("charged") is not None]
|
|
highest = [b.get("highest", 0) for b in bb_data if b.get("highest") is not None]
|
|
|
|
avg_charged = sum(charged) / len(charged) if charged else 0
|
|
avg_highest = sum(highest) / len(highest) if highest else 0
|
|
|
|
# Color code based on levels
|
|
colors = []
|
|
for val in highest:
|
|
if val >= 75:
|
|
colors.append("rgba(75, 192, 192, 0.6)") # Green
|
|
elif val >= 50:
|
|
colors.append("rgba(255, 206, 86, 0.6)") # Yellow
|
|
elif val >= 25:
|
|
colors.append("rgba(255, 159, 64, 0.6)") # Orange
|
|
else:
|
|
colors.append("rgba(255, 99, 132, 0.6)") # Red
|
|
|
|
return {
|
|
"stats": {
|
|
"Avg Charged": f"+{avg_charged:.0f}",
|
|
"Avg Peak": f"{avg_highest:.0f}/100"
|
|
},
|
|
"chart": {
|
|
"title": "Body Battery (Recovery)",
|
|
"chart": {
|
|
"type": "bar",
|
|
"data": {
|
|
"labels": dates,
|
|
"datasets": [{
|
|
"label": "Highest Body Battery",
|
|
"data": highest,
|
|
"backgroundColor": colors,
|
|
"borderWidth": 0
|
|
}]
|
|
},
|
|
"options": {
|
|
"responsive": True,
|
|
"scales": {
|
|
"y": {
|
|
"beginAtZero": True,
|
|
"max": 100,
|
|
"title": {
|
|
"display": True,
|
|
"text": "Body Battery"
|
|
}
|
|
}
|
|
},
|
|
"plugins": {
|
|
"legend": {
|
|
"display": False
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def create_hrv_chart(hrv_data, hr_data):
|
|
"""Create HRV and resting heart rate trend chart."""
|
|
dates = [h["date"] for h in hrv_data if h.get("last_night_avg")]
|
|
hrv_values = [h.get("last_night_avg", 0) for h in hrv_data if h.get("last_night_avg")]
|
|
|
|
# Match dates with heart rate data
|
|
hr_map = {h["date"]: h.get("resting_hr") for h in hr_data if h.get("resting_hr")}
|
|
rhr_values = [hr_map.get(date, 0) for date in dates]
|
|
|
|
avg_hrv = sum(hrv_values) / len(hrv_values) if hrv_values else 0
|
|
avg_rhr = sum(rhr_values) / len(rhr_values) if rhr_values else 0
|
|
|
|
return {
|
|
"stats": {
|
|
"Avg HRV": f"{avg_hrv:.0f} ms",
|
|
"Avg RHR": f"{avg_rhr:.0f} bpm"
|
|
},
|
|
"chart": {
|
|
"title": "HRV & Resting Heart Rate",
|
|
"chart": {
|
|
"type": "line",
|
|
"data": {
|
|
"labels": dates,
|
|
"datasets": [
|
|
{
|
|
"label": "HRV (ms)",
|
|
"data": hrv_values,
|
|
"borderColor": "rgba(153, 102, 255, 1)",
|
|
"backgroundColor": "rgba(153, 102, 255, 0.1)",
|
|
"borderWidth": 3,
|
|
"tension": 0.4,
|
|
"yAxisID": "y"
|
|
},
|
|
{
|
|
"label": "Resting HR (bpm)",
|
|
"data": rhr_values,
|
|
"borderColor": "rgba(255, 99, 132, 1)",
|
|
"backgroundColor": "rgba(255, 99, 132, 0.1)",
|
|
"borderWidth": 3,
|
|
"tension": 0.4,
|
|
"yAxisID": "y1"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"responsive": True,
|
|
"interaction": {
|
|
"mode": "index",
|
|
"intersect": False
|
|
},
|
|
"scales": {
|
|
"y": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "left",
|
|
"title": {
|
|
"display": True,
|
|
"text": "HRV (ms)"
|
|
}
|
|
},
|
|
"y1": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "right",
|
|
"title": {
|
|
"display": True,
|
|
"text": "Heart Rate (bpm)"
|
|
},
|
|
"grid": {
|
|
"drawOnChartArea": False
|
|
}
|
|
}
|
|
},
|
|
"plugins": {
|
|
"legend": {
|
|
"display": True
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def create_activities_chart(activities_data):
|
|
"""Create activities/workouts summary chart."""
|
|
# Group by type
|
|
types = {}
|
|
for activity in activities_data:
|
|
activity_type = activity.get("activity_type", "Unknown")
|
|
if activity_type not in types:
|
|
types[activity_type] = {"count": 0, "calories": 0, "duration": 0}
|
|
types[activity_type]["count"] += 1
|
|
types[activity_type]["calories"] += activity.get("calories", 0)
|
|
types[activity_type]["duration"] += activity.get("duration_seconds", 0) / 3600
|
|
|
|
labels = list(types.keys())
|
|
counts = [types[t]["count"] for t in labels]
|
|
calories = [types[t]["calories"] for t in labels]
|
|
|
|
total_activities = sum(counts)
|
|
total_calories = sum(calories)
|
|
|
|
return {
|
|
"stats": {
|
|
"Activities": f"{total_activities}",
|
|
"Total Calories": f"{total_calories:.0f}"
|
|
},
|
|
"chart": {
|
|
"title": "Activities Summary",
|
|
"chart": {
|
|
"type": "bar",
|
|
"data": {
|
|
"labels": labels,
|
|
"datasets": [
|
|
{
|
|
"label": "Count",
|
|
"data": counts,
|
|
"backgroundColor": "rgba(75, 192, 192, 0.6)",
|
|
"borderColor": "rgba(75, 192, 192, 1)",
|
|
"borderWidth": 2,
|
|
"yAxisID": "y"
|
|
},
|
|
{
|
|
"label": "Calories",
|
|
"data": calories,
|
|
"backgroundColor": "rgba(255, 159, 64, 0.6)",
|
|
"borderColor": "rgba(255, 159, 64, 1)",
|
|
"borderWidth": 2,
|
|
"yAxisID": "y1"
|
|
}
|
|
]
|
|
},
|
|
"options": {
|
|
"responsive": True,
|
|
"scales": {
|
|
"y": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "left",
|
|
"title": {
|
|
"display": True,
|
|
"text": "Count"
|
|
}
|
|
},
|
|
"y1": {
|
|
"type": "linear",
|
|
"display": True,
|
|
"position": "right",
|
|
"title": {
|
|
"display": True,
|
|
"text": "Calories"
|
|
},
|
|
"grid": {
|
|
"drawOnChartArea": False
|
|
}
|
|
}
|
|
},
|
|
"plugins": {
|
|
"legend": {
|
|
"display": True
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Generate Garmin health charts")
|
|
parser.add_argument("chart", choices=["sleep", "body_battery", "hrv", "activities", "dashboard"],
|
|
help="Type of chart to generate")
|
|
parser.add_argument("--days", type=int, default=30, help="Number of days (default: 30)")
|
|
parser.add_argument("--output", help="Output HTML file (default: opens in browser)")
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Get authenticated client
|
|
client = get_client()
|
|
if not client:
|
|
print("❌ Not authenticated. Run: python3 scripts/garmin_auth.py login", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
print(f"📊 Fetching {args.days} days of data...", file=sys.stderr)
|
|
|
|
# Prepare chart data
|
|
charts_data = {"stats": {}, "charts": []}
|
|
|
|
if args.chart in ["sleep", "dashboard"]:
|
|
print(" - Sleep data", file=sys.stderr)
|
|
sleep_data = fetch_sleep(client, args.days).get("sleep", [])
|
|
if sleep_data:
|
|
result = create_sleep_chart(sleep_data)
|
|
charts_data["stats"].update(result.get("stats", {}))
|
|
charts_data["charts"].append(result["chart"])
|
|
|
|
if args.chart in ["body_battery", "dashboard"]:
|
|
print(" - Body Battery data", file=sys.stderr)
|
|
bb_data = fetch_body_battery(client, args.days).get("body_battery", [])
|
|
if bb_data:
|
|
result = create_body_battery_chart(bb_data)
|
|
charts_data["stats"].update(result.get("stats", {}))
|
|
charts_data["charts"].append(result["chart"])
|
|
|
|
if args.chart in ["hrv", "dashboard"]:
|
|
print(" - HRV & Heart Rate data", file=sys.stderr)
|
|
hrv_data = fetch_hrv(client, args.days).get("hrv", [])
|
|
hr_data = fetch_heart_rate(client, args.days).get("heart_rate", [])
|
|
if hrv_data:
|
|
result = create_hrv_chart(hrv_data, hr_data)
|
|
charts_data["stats"].update(result.get("stats", {}))
|
|
charts_data["charts"].append(result["chart"])
|
|
|
|
if args.chart in ["activities", "dashboard"]:
|
|
print(" - Activities data", file=sys.stderr)
|
|
activities_data = fetch_activities(client, args.days).get("activities", [])
|
|
if activities_data:
|
|
result = create_activities_chart(activities_data)
|
|
charts_data["stats"].update(result.get("stats", {}))
|
|
charts_data["charts"].append(result["chart"])
|
|
|
|
# Generate HTML
|
|
title = f"Garmin {args.chart.title()} - Last {args.days} Days"
|
|
html = generate_html(charts_data, title)
|
|
|
|
# Save or open
|
|
if args.output:
|
|
output_path = Path(args.output).expanduser()
|
|
output_path.write_text(html)
|
|
print(f"✅ Chart saved to {output_path}", file=sys.stderr)
|
|
else:
|
|
# Save to temp file and open
|
|
import tempfile
|
|
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False) as f:
|
|
f.write(html)
|
|
temp_path = f.name
|
|
|
|
print(f"✅ Opening chart in browser...", file=sys.stderr)
|
|
webbrowser.open(f"file://{temp_path}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|