AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT)
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ 'Set a password' if mode == 'setup' else 'Log in' }} — AHDX</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('ahdx-theme');
|
||||
if (t) document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main style="max-width: 360px; margin: 12vh auto;">
|
||||
<div class="brand" style="margin-bottom: 16px;">AHDX<span>Apple Health Data eXporter</span></div>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, msg in messages %}
|
||||
<div class="flash {{ category }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
<div class="card">
|
||||
{% if mode == 'setup' %}
|
||||
<h2>Set a password</h2>
|
||||
<p class="muted">The web UI is protected. Pick a password to use from now on.</p>
|
||||
<form method="post" action="{{ url_for('setup') }}">
|
||||
<p><input type="password" name="password" placeholder="Password" required style="width: 100%"></p>
|
||||
<p><input type="password" name="confirm" placeholder="Confirm password" required style="width: 100%"></p>
|
||||
<button type="submit">Set password</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<h2>Log in</h2>
|
||||
<form method="post" action="{{ url_for('login') }}">
|
||||
<p><input type="password" name="password" placeholder="Password" required autofocus style="width: 100%"></p>
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}AHDX{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<script>
|
||||
// Apply the saved theme before the page paints, so there's no flash.
|
||||
(function () {
|
||||
var t = localStorage.getItem('ahdx-theme');
|
||||
if (t) document.documentElement.setAttribute('data-theme', t);
|
||||
})();
|
||||
function toggleTheme() {
|
||||
var r = document.documentElement;
|
||||
var cur = r.getAttribute('data-theme') ||
|
||||
(matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
var next = cur === 'dark' ? 'light' : 'dark';
|
||||
r.setAttribute('data-theme', next);
|
||||
localStorage.setItem('ahdx-theme', next);
|
||||
}
|
||||
</script>
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">AHDX<span>Apple Health Data eXporter</span></div>
|
||||
<nav>
|
||||
<a href="{{ url_for('index') }}">Import</a>
|
||||
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
||||
<a href="{{ url_for('trends') }}">Trends</a>
|
||||
<a href="{{ url_for('browse') }}">Browse</a>
|
||||
<a href="{{ url_for('routes') }}">Routes</a>
|
||||
<a href="{{ url_for('ecg') }}">ECG</a>
|
||||
<a href="{{ url_for('databases') }}">Databases</a>
|
||||
</nav>
|
||||
<div class="db-picker">
|
||||
Database: <strong>{{ active_db['name'] if active_db else '—' }}</strong>
|
||||
</div>
|
||||
<button class="theme-toggle" onclick="toggleTheme()" title="Light / dark">◐</button>
|
||||
{% if auth_enabled %}
|
||||
<a class="btn" href="{{ url_for('logout') }}">Log out</a>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, msg in messages %}
|
||||
<div class="flash {{ category }}">{{ msg }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
{% block body %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Browse — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Browse records</h1>
|
||||
|
||||
<form method="get" class="filters">
|
||||
<select name="type">
|
||||
<option value="">All types</option>
|
||||
{% for t in types %}
|
||||
<option value="{{ t }}" {% if t == sel_type %}selected{% endif %}>
|
||||
{{ t.replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<label>From <input type="date" name="start" value="{{ start }}"></label>
|
||||
<label>To <input type="date" name="end" value="{{ end }}"></label>
|
||||
<button type="submit">Filter</button>
|
||||
<a class="btn" href="{{ url_for('export_csv', type=sel_type, start=start, end=end) }}">Download CSV</a>
|
||||
</form>
|
||||
|
||||
<p class="muted">Showing up to 500 rows, newest first. The CSV download has no limit.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Value</th><th>Unit</th><th>Source</th><th>Start</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr>
|
||||
<td>{{ r['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}</td>
|
||||
<td class="num">{{ r['value'] }}</td>
|
||||
<td>{{ r['unit'] or '' }}</td>
|
||||
<td>{{ r['source_name'] or '' }}</td>
|
||||
<td>{{ r['start_date'] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No records match. Try a different type or date range.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
{% if cards %}
|
||||
<div class="tiles">
|
||||
{% for c in cards %}
|
||||
<div class="tile">
|
||||
<span class="n">{{ c['value'] }}{% if c['unit'] %} <small>{{ c['unit'] }}</small>{% endif %}</span>
|
||||
<span class="l">{{ c['label'] }}{% if c['when'] %} · {{ c['when'] }}{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 style="margin-top:24px;">What's in here</h2>
|
||||
<div class="tiles">
|
||||
<div class="tile"><span class="n">{{ totals['n'] or 0 }}</span><span class="l">records</span></div>
|
||||
<div class="tile"><span class="n">{{ types|length }}</span><span class="l">types</span></div>
|
||||
<div class="tile"><span class="n">{{ workouts }}</span><span class="l">workouts</span></div>
|
||||
<div class="tile"><span class="n">{{ summaries }}</span><span class="l">day summaries</span></div>
|
||||
</div>
|
||||
|
||||
<p class="muted">
|
||||
{% if totals['first'] %}
|
||||
Data spans {{ totals['first'][:10] }} to {{ totals['last'][:10] }}.
|
||||
{% else %}
|
||||
This database is empty. Import an export on the <a href="{{ url_for('index') }}">Import</a> page.
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if types %}
|
||||
<h2>Record types</h2>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th class="num">Count</th><th>From</th><th>To</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in types %}
|
||||
<tr>
|
||||
<td>{{ t['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}</td>
|
||||
<td class="num">{{ t['n'] }}</td>
|
||||
<td>{{ t['first'][:10] if t['first'] else '' }}</td>
|
||||
<td>{{ t['last'][:10] if t['last'] else '' }}</td>
|
||||
<td><a href="{{ url_for('browse', type=t['type']) }}">browse</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Databases — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Databases</h1>
|
||||
<p class="muted">
|
||||
Each database is its own <code>.db</code> file under the data volume. Keep one
|
||||
per person, one per year, or a scratch one to test an import. Imports always go
|
||||
into the active database.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="num">Size</th>
|
||||
<th class="num">Records</th>
|
||||
<th class="num">Workouts</th>
|
||||
<th>Last import</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in overview %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ d['name'] }}</strong>
|
||||
{% if d['is_active'] %}<span class="badge">active</span>{% endif %}
|
||||
<div class="muted" style="font-size:0.8rem;">{{ d['filename'] }}</div>
|
||||
</td>
|
||||
<td class="num">{{ d['size_h'] }}</td>
|
||||
<td class="num">{{ '{:,}'.format(d['records']) }}</td>
|
||||
<td class="num">{{ d['workouts'] }}</td>
|
||||
<td class="muted">{{ d['last_import'][:16] if d['last_import'] else '—' }}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
{% if not d['is_active'] %}
|
||||
<form method="post" action="{{ url_for('activate_database', db_id=d['id']) }}">
|
||||
<button type="submit" class="btn-sm">Make active</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="{{ url_for('rename_database', db_id=d['id']) }}">
|
||||
<input type="text" name="name" placeholder="Rename to…" required>
|
||||
<button type="submit" class="btn-sm secondary">Rename</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('delete_database', db_id=d['id']) }}"
|
||||
onsubmit="return confirm('Delete {{ d['name'] }} and its data file? This cannot be undone.');">
|
||||
<button type="submit" class="btn-sm danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<form method="post" action="{{ url_for('new_database') }}" class="card" style="max-width:360px;">
|
||||
<h2>New database</h2>
|
||||
<input type="text" name="name" placeholder="e.g. Steffen, or Anette" required>
|
||||
<button type="submit">Create</button>
|
||||
</form>
|
||||
|
||||
<div class="card muted">
|
||||
<h2>Read API</h2>
|
||||
<p>Pull data out for Grafana or scripts. Open
|
||||
<a href="{{ url_for('api_index') }}"><code>/api</code></a> for the menu.
|
||||
Pick a database with <code>?db=Name</code>. Example:
|
||||
<code>/api/daily?db=Steffen&type=HKQuantityTypeIdentifierStepCount&agg=sum</code></p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ECG — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>ECG readings</h1>
|
||||
<p class="muted">From the electrocardiograms in your export. Click one to see the trace.</p>
|
||||
<table>
|
||||
<thead><tr><th>Recorded</th><th>Classification</th><th class="num">Rate</th><th class="num">Length</th><th>File</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in items %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('ecg_detail', ecg_id=e['id']) }}">{{ (e['recorded_date'] or '')[:19] or 'unknown' }}</a></td>
|
||||
<td>{{ e['classification'] or '' }}</td>
|
||||
<td class="num">{{ (e['sample_rate']|int) ~ ' Hz' if e['sample_rate'] else '' }}</td>
|
||||
<td class="num">{{ ('%.0f s'|format(e['duration_s'])) if e['duration_s'] else '' }}</td>
|
||||
<td class="muted">{{ e['filename'] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="muted">No ECG readings. They only exist if you've taken ECGs and you import the full <code>export.zip</code>.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}ECG — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<a href="{{ url_for('ecg') }}">← ECG</a>
|
||||
<h1>ECG {{ (ecg['recorded_date'] or '')[:19] }}</h1>
|
||||
<p class="muted">
|
||||
{{ ecg['classification'] or 'no classification' }}
|
||||
{% if ecg['sample_rate'] %} · {{ ecg['sample_rate']|int }} Hz{% endif %}
|
||||
{% if ecg['duration_s'] %} · {{ '%.0f'|format(ecg['duration_s']) }} s{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<svg id="ecg" viewBox="0 0 1000 260" style="width: 100%; height: auto;"></svg>
|
||||
<p class="muted">Thinned for display. The full waveform is in the database.</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const S = {{ samples|tojson }};
|
||||
(function () {
|
||||
const svg = document.getElementById("ecg");
|
||||
if (!S.length) { svg.outerHTML = "<p class='muted'>No samples.</p>"; return; }
|
||||
const W = 1000, H = 260, pad = 10;
|
||||
const lo = Math.min(...S), hi = Math.max(...S), r = (hi - lo) || 1;
|
||||
const px = i => pad + (i / (S.length - 1 || 1)) * (W - 2 * pad);
|
||||
const py = v => pad + (1 - (v - lo) / r) * (H - 2 * pad);
|
||||
let d = "";
|
||||
S.forEach((v, i) => { d += (i ? "L" : "M") + px(i).toFixed(1) + " " + py(v).toFixed(1) + " "; });
|
||||
svg.innerHTML = `<path d="${d}" style="fill:none;stroke:var(--accent);stroke-width:1"/>`;
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,54 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Import — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Import health data</h1>
|
||||
<p class="muted">
|
||||
On your iPhone: open Health, tap your profile picture, choose "Export All
|
||||
Health Data". You get a file called <code>export.zip</code>. Drop it here.
|
||||
You can also drop the <code>export.xml</code> from inside it.
|
||||
</p>
|
||||
|
||||
<form method="post" action="{{ url_for('do_import') }}" enctype="multipart/form-data" class="card">
|
||||
<input type="file" name="file" accept=".zip,.xml">
|
||||
<button type="submit">Import into "{{ active_db['name'] }}"</button>
|
||||
</form>
|
||||
|
||||
<div class="card" id="status-card">
|
||||
<h2>Last import</h2>
|
||||
<div id="status-body">
|
||||
{% include "includes/status.html" %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card muted">
|
||||
<h2>Hands-free</h2>
|
||||
<p>
|
||||
Drop exports into the <code>data/inbox/</code> folder and AHDX picks them up
|
||||
on a timer (every 30 minutes by default). Or have a free iOS Shortcut POST
|
||||
recent metrics to <code>/ingest</code>. Both feed the same database, and
|
||||
re-sending data you already have does nothing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Poll while an import is running so the numbers move without a refresh.
|
||||
function refresh() {
|
||||
fetch("{{ url_for('import_status') }}")
|
||||
.then(r => r.json())
|
||||
.then(s => {
|
||||
const el = document.getElementById("status-body");
|
||||
let html = "<p><strong>State:</strong> " + (s.state || "idle") + "</p>";
|
||||
if (s.source) html += "<p><strong>Source:</strong> " + s.source + "</p>";
|
||||
html += "<p><strong>Rows read:</strong> " + (s.records_seen || 0) +
|
||||
" <strong>new rows added:</strong> " + (s.records_new || 0) + "</p>";
|
||||
if (s.current_type) html += "<p><strong>Now reading:</strong> " + s.current_type + "</p>";
|
||||
if (s.message) html += "<p class='err'>" + s.message + "</p>";
|
||||
if (s.finished_at) html += "<p class='muted'>Finished " + s.finished_at + " UTC</p>";
|
||||
el.innerHTML = html;
|
||||
if (s.state === "running") setTimeout(refresh, 1000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
refresh();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% if status %}
|
||||
<p><strong>State:</strong> {{ status['state'] or 'idle' }}</p>
|
||||
{% if status['source'] %}<p><strong>Source:</strong> {{ status['source'] }}</p>{% endif %}
|
||||
<p><strong>Rows read:</strong> {{ status['records_seen'] or 0 }}
|
||||
<strong>new rows added:</strong> {{ status['records_new'] or 0 }}</p>
|
||||
{% if status['current_type'] %}<p><strong>Now reading:</strong> {{ status['current_type'] }}</p>{% endif %}
|
||||
{% if status['message'] %}<p class="err">{{ status['message'] }}</p>{% endif %}
|
||||
{% if status['finished_at'] %}<p class="muted">Finished {{ status['finished_at'] }} UTC</p>{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">Nothing imported yet.</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Route — AHDX{% endblock %}
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<a href="{{ url_for('routes') }}">← Routes</a>
|
||||
<h1>{{ route['filename'] }}</h1>
|
||||
<p class="muted">{{ (route['start_date'] or '')[:19] }} · {{ route['point_count'] }} points</p>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div id="map" style="height: 460px;"></div>
|
||||
</div>
|
||||
<p class="muted">Track is from your data. Map tiles come from OpenStreetMap over the network.</p>
|
||||
|
||||
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
||||
<script>
|
||||
// [lat, lon] pairs, which is the order Leaflet wants.
|
||||
const PTS = {{ points|tojson }};
|
||||
(function () {
|
||||
const box = document.getElementById("map");
|
||||
if (!PTS.length) { box.innerHTML = "<p class='muted' style='padding:16px;'>No GPS points in this route.</p>"; return; }
|
||||
const map = L.map(box);
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 19,
|
||||
attribution: "© OpenStreetMap contributors",
|
||||
}).addTo(map);
|
||||
const line = L.polyline(PTS, { color: "#0a84ff", weight: 4 }).addTo(map);
|
||||
map.fitBounds(line.getBounds(), { padding: [20, 20] });
|
||||
L.circleMarker(PTS[0], { radius: 6, color: "#2e7d32", fillOpacity: 1 }).addTo(map).bindTooltip("Start");
|
||||
L.circleMarker(PTS[PTS.length - 1], { radius: 6, color: "#c0392b", fillOpacity: 1 }).addTo(map).bindTooltip("End");
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Routes — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>GPS routes</h1>
|
||||
<p class="muted">From the workout-routes files in your export. Click one to see its shape.</p>
|
||||
<table>
|
||||
<thead><tr><th>Date</th><th>File</th><th class="num">Points</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in routes %}
|
||||
<tr>
|
||||
<td>{{ (r['start_date'] or '')[:10] }}</td>
|
||||
<td><a href="{{ url_for('route_detail', route_id=r['id']) }}">{{ r['filename'] }}</a></td>
|
||||
<td class="num">{{ r['point_count'] }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="3" class="muted">No routes yet. Import an <code>export.zip</code> (not just the xml) and they'll show up.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Trends — AHDX{% endblock %}
|
||||
{% block body %}
|
||||
<h1>Trends</h1>
|
||||
|
||||
<form method="get" class="filters">
|
||||
<select name="type">
|
||||
{% for t in types %}
|
||||
<option value="{{ t }}" {% if t == sel_type %}selected{% endif %}>
|
||||
{{ t.replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="agg">
|
||||
{% for a in ['avg', 'sum', 'min', 'max', 'count'] %}
|
||||
<option value="{{ a }}" {% if a == agg %}selected{% endif %}>{{ a }} per day</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit">Show</button>
|
||||
</form>
|
||||
|
||||
{% if stats and stats['n'] %}
|
||||
<div class="tiles" style="margin: 12px 0;">
|
||||
<div class="tile"><span class="n">{{ stats['n'] }}</span><span class="l">samples</span></div>
|
||||
<div class="tile"><span class="n">{{ '%.1f'|format(stats['avg']) if stats['avg'] is not none else '–' }}</span><span class="l">avg {{ stats['unit'] or '' }}</span></div>
|
||||
<div class="tile"><span class="n">{{ '%.1f'|format(stats['min']) if stats['min'] is not none else '–' }}</span><span class="l">min</span></div>
|
||||
<div class="tile"><span class="n">{{ '%.1f'|format(stats['max']) if stats['max'] is not none else '–' }}</span><span class="l">max</span></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<svg id="chart" viewBox="0 0 800 300" style="width: 100%; height: auto;"></svg>
|
||||
<p class="muted" id="chart-note"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Points come straight from the daily rollup query: [["2026-07-20", 812], ...].
|
||||
const POINTS = {{ points|tojson }};
|
||||
(function () {
|
||||
const svg = document.getElementById("chart");
|
||||
const note = document.getElementById("chart-note");
|
||||
if (!POINTS.length) { note.textContent = "No data for this metric yet."; return; }
|
||||
|
||||
const W = 800, H = 300, padL = 50, padR = 10, padT = 12, padB = 22;
|
||||
const ys = POINTS.map(p => p[1]);
|
||||
const ymin = Math.min(...ys), ymax = Math.max(...ys), yr = (ymax - ymin) || 1;
|
||||
const px = i => padL + (i / (POINTS.length - 1 || 1)) * (W - padL - padR);
|
||||
const py = v => padT + (1 - (v - ymin) / yr) * (H - padT - padB);
|
||||
|
||||
let d = "";
|
||||
POINTS.forEach((p, i) => { d += (i ? "L" : "M") + px(i).toFixed(1) + " " + py(p[1]).toFixed(1) + " "; });
|
||||
|
||||
const fmt = v => Math.abs(v - Math.round(v)) < 1e-9 ? String(Math.round(v)) : v.toFixed(1);
|
||||
svg.innerHTML =
|
||||
`<line x1="${padL}" y1="${py(ymin)}" x2="${W - padR}" y2="${py(ymin)}" style="stroke:var(--line)"/>` +
|
||||
`<text x="6" y="${py(ymax) + 4}" font-size="11" style="fill:var(--muted)">${fmt(ymax)}</text>` +
|
||||
`<text x="6" y="${py(ymin) + 4}" font-size="11" style="fill:var(--muted)">${fmt(ymin)}</text>` +
|
||||
`<text x="${padL}" y="${H - 6}" font-size="11" style="fill:var(--muted)">${POINTS[0][0]}</text>` +
|
||||
`<text x="${W - padR}" y="${H - 6}" font-size="11" text-anchor="end" style="fill:var(--muted)">${POINTS[POINTS.length - 1][0]}</text>` +
|
||||
`<path d="${d}" style="fill:none;stroke:var(--accent);stroke-width:2"/>`;
|
||||
note.textContent = POINTS.length + " days, " + POINTS[0][0] + " to " + POINTS[POINTS.length - 1][0];
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user