AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
data/
|
||||
.git/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
@@ -0,0 +1,7 @@
|
||||
# AHDX has no password in config on purpose. On first run it takes you to a
|
||||
# setup screen to create one; that password is stored salted in AHDX and also
|
||||
# becomes the Grafana admin password. Nothing to set here for auth.
|
||||
|
||||
# Optional port overrides
|
||||
# AHDX_PORT=8088
|
||||
# GRAFANA_PORT=3000
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Your health data. Never commit this.
|
||||
data/
|
||||
|
||||
# Grafana's own state (dashboards you make live here). Provisioning config under
|
||||
# grafana/ IS committed; this runtime dir is not.
|
||||
grafana-data/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Local env
|
||||
.env
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install deps first so this layer caches when only the code changes.
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
# All state lives here, mounted as a volume from the host.
|
||||
ENV AHDX_DATA=/data
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Steffen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,142 @@
|
||||
# AHDX — Apple Health Data eXporter
|
||||
|
||||
Your Apple Health history, out of Apple's black box and into a plain SQLite
|
||||
database you own, with a Grafana dashboard on top. It runs in Docker and works
|
||||
on Windows, Mac, or Linux.
|
||||
|
||||
You're in the EU (or you just think your health data is yours), so here's a way
|
||||
to actually keep it and look at it. Nothing is sent anywhere. The only time AHDX
|
||||
touches the network is to load map tiles when you open a workout route.
|
||||
|
||||
## What it looks like
|
||||
|
||||
Steps, resting heart rate, weight, VO₂ max, sleep with a score, and your workout
|
||||
routes on a real map, per person:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## Get it running
|
||||
|
||||
You need Docker (Docker Desktop on Windows/Mac, or Docker Engine on Linux).
|
||||
|
||||
```
|
||||
git clone <your-repo-url> ahdx
|
||||
cd ahdx
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
That starts two things: **AHDX** on http://localhost:8088 and **Grafana** on
|
||||
http://localhost:3000. Grafana waits for AHDX to be healthy before it starts, so
|
||||
the first boot takes a minute.
|
||||
|
||||
Open http://localhost:8088. On the first visit it asks you to **set a password**.
|
||||
That password is stored salted in AHDX, and it also becomes the Grafana admin
|
||||
password (user `admin`), so you set it once and it works for both.
|
||||
|
||||
Your data lives in `./data` next to the compose file. Blow away the containers
|
||||
whenever you want; the databases stay.
|
||||
|
||||
## Get your data out of the iPhone
|
||||
|
||||
1. Open **Health**, tap your profile picture, choose **Export All Health Data**.
|
||||
2. You get an `export.zip`. Send it to your computer.
|
||||
3. In AHDX, go to **Import** and drop the zip in. Big exports take a few minutes;
|
||||
the page shows a running count.
|
||||
|
||||
Loading a newer export later only adds what's new, so re-exporting every month
|
||||
doesn't make duplicates.
|
||||
|
||||
Two ways to keep it fresh without clicking, both free:
|
||||
|
||||
- **Watched folder** — drop an export into `data/inbox/` and AHDX imports it on a
|
||||
timer (every 30 min by default), then moves it to `data/inbox/done/`.
|
||||
- **Push from the phone** — a free iOS **Shortcut** can POST recent metrics to
|
||||
`/ingest` on a schedule. See the steps further down.
|
||||
|
||||
Apple has no free server-side API, so a fully automatic *full-history* export
|
||||
isn't possible — that part stays a manual tap. Automatic *recent* data works fine
|
||||
through the Shortcut.
|
||||
|
||||
## More than one person
|
||||
|
||||
Each person is their own database. On the **Databases** page you can create,
|
||||
rename, switch, and delete them, and see each one's size and record counts.
|
||||
Imports go into whichever database is active.
|
||||
|
||||
In Grafana, the **Person** dropdown up top switches the whole dashboard between
|
||||
them.
|
||||
|
||||
## The Grafana dashboard
|
||||
|
||||
It ships already set up — the AHDX datasource and the "Apple Health" dashboard
|
||||
are provisioned from files, so there's nothing to import. Panels:
|
||||
|
||||
- Latest weight, resting HR, VO₂ max, steps, active energy, exercise minutes.
|
||||
- Steps, heart rate, weight, and energy over time.
|
||||
- **Sleep**: a nightly score (0–100), stage breakdown (deep / core / REM / awake),
|
||||
and the score trend. The score is computed from sleep duration, efficiency, and
|
||||
how much deep/REM you got — Apple doesn't provide one.
|
||||
- **Workout route** on an OpenStreetMap map, with a box under it showing the
|
||||
activity, distance, duration, calories, elevation gain, and average speed.
|
||||
|
||||
## The read API
|
||||
|
||||
AHDX exposes a small read API at `/api` (open it in a browser for the menu). It's
|
||||
what Grafana reads, and you can point your own scripts at it. Pick a person with
|
||||
`?db=Name`:
|
||||
|
||||
```
|
||||
/api/daily?db=Ola&type=HKQuantityTypeIdentifierStepCount&agg=sum
|
||||
/api/sleep?db=Ola
|
||||
/api/routes?db=Ola
|
||||
```
|
||||
|
||||
Set `AHDX_API_KEY` in the compose file if you want to require an `X-Api-Key`
|
||||
header on it.
|
||||
|
||||
## Building the iOS Shortcut (the auto-trickle)
|
||||
|
||||
This sends today's step samples; copy the pattern for other metrics.
|
||||
|
||||
1. New Shortcut. Add **Find Health Samples** — Type *Steps*, sorted by End Date,
|
||||
filtered to "within the last 1 day".
|
||||
2. Add **Repeat with Each**. Inside it, build a **Dictionary** with keys `type`
|
||||
(`HKQuantityTypeIdentifierStepCount`), `value` (the item's Value), `unit`
|
||||
(`count`), `start_date`, `end_date`, `source_name`, then **Add to Variable**
|
||||
`rows`.
|
||||
3. After the loop, a **Dictionary** with one key `records` set to `rows`.
|
||||
4. **Get Contents of URL** — POST to `http://<your-pc-ip>:8088/ingest`, body JSON,
|
||||
pass that dictionary.
|
||||
5. In the **Automation** tab, run it on a schedule and turn off "Ask Before
|
||||
Running".
|
||||
|
||||
## Configuration
|
||||
|
||||
Everything has a sane default; override in a `.env` file or the compose file.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
|---|---|---|
|
||||
| `AHDX_PORT` | `8088` | Host port for the app |
|
||||
| `GRAFANA_PORT` | `3000` | Host port for Grafana |
|
||||
| `AHDX_AUTH` | `true` | Password-protect the UI |
|
||||
| `AHDX_SCAN_INTERVAL` | `1800` | Inbox check interval, seconds (0 = off) |
|
||||
| `AHDX_API_KEY` | *(empty)* | If set, the read API requires this header |
|
||||
|
||||
## What's in the database
|
||||
|
||||
Three tables that mirror the export: `records` (one row per measurement),
|
||||
`workouts`, and `activity_summary`. Plus `routes`/`route_points` and `ecg` when
|
||||
you import the full zip. Open any `data/databases/*.db` in a SQLite browser and
|
||||
it's all right there, no proprietary format.
|
||||
|
||||
## Privacy
|
||||
|
||||
No accounts, no analytics, no outbound calls — except loading map tiles from
|
||||
OpenStreetMap when you open a route, and only then. Password hashes are salted.
|
||||
Your health data never leaves the machine.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE).
|
||||
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
AHDX web app.
|
||||
|
||||
Small Flask front end over the storage layer: a page to import an Apple Health
|
||||
export, a dashboard, a browser for the raw records, database management, and a
|
||||
JSON endpoint the phone can push to. All local, no accounts, no outbound calls.
|
||||
"""
|
||||
import base64
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import urllib.request
|
||||
|
||||
from flask import (
|
||||
Flask, Response, abort, flash, jsonify, redirect, render_template, request,
|
||||
session, url_for,
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import db
|
||||
import jobs
|
||||
import scanner
|
||||
|
||||
|
||||
def _truthy(v):
|
||||
return str(v).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# Auth ships ON. AHDX keeps its own salted password in the registry DB, set once
|
||||
# via the GUI on first run. When you set it, AHDX also pushes it to Grafana (see
|
||||
# _sync_grafana_password) so one password covers both. The /ingest push endpoint
|
||||
# stays open; it's for the LAN.
|
||||
AUTH_ENABLED = _truthy(os.environ.get("AHDX_AUTH", "true"))
|
||||
GRAFANA_URL = os.environ.get("AHDX_GRAFANA_URL", "http://ahdx-grafana:3000").rstrip("/")
|
||||
|
||||
|
||||
def _has_password():
|
||||
return db.has_password()
|
||||
|
||||
|
||||
def _check_password(pw):
|
||||
return db.check_password(pw)
|
||||
|
||||
|
||||
def _sync_grafana_password(new_pw):
|
||||
"""Best-effort: set Grafana's admin password to match. A fresh Grafana ships
|
||||
as admin/admin, so we change it from there on first setup; on later changes
|
||||
we try the new password as the "current" one too. Failure is ignored — AHDX
|
||||
still works, Grafana just keeps whatever it had."""
|
||||
import json as _json
|
||||
for current in ("admin", new_pw):
|
||||
try:
|
||||
auth = base64.b64encode(f"admin:{current}".encode()).decode()
|
||||
body = _json.dumps({"oldPassword": current, "newPassword": new_pw}).encode()
|
||||
req = urllib.request.Request(
|
||||
GRAFANA_URL + "/api/user/password", data=body, method="PUT",
|
||||
headers={"Authorization": "Basic " + auth, "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=5) as r:
|
||||
if r.status == 200:
|
||||
return True
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
|
||||
# Optional shared key for the read API. If set, callers (Grafana, scripts) must
|
||||
# send it as the X-Api-Key header or a ?key= query param. If unset, the API is
|
||||
# open on the LAN, same as /ingest.
|
||||
API_KEY = os.environ.get("AHDX_API_KEY") or None
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
with app.app_context():
|
||||
db.init()
|
||||
|
||||
# A stable session secret. Prefer the env var; otherwise keep a random one in the
|
||||
# settings table so logins survive a restart without hard-coding anything.
|
||||
_secret = os.environ.get("SECRET_KEY") or db.get_setting("secret_key")
|
||||
if not _secret:
|
||||
_secret = secrets.token_hex(32)
|
||||
db.set_setting("secret_key", _secret)
|
||||
app.config["SECRET_KEY"] = _secret
|
||||
|
||||
scanner.start()
|
||||
|
||||
# Endpoints reachable without a login: the login/setup pages themselves, the
|
||||
# health check, static files, and the machine-to-machine push endpoint.
|
||||
OPEN_ENDPOINTS = {"login", "setup", "health", "static", "ingest"}
|
||||
|
||||
|
||||
@app.before_request
|
||||
def require_login():
|
||||
# The read API is machine-to-machine: it has its own key, so the browser
|
||||
# login never applies to it.
|
||||
if request.path.startswith("/api"):
|
||||
return
|
||||
if not AUTH_ENABLED or session.get("authed") or request.endpoint in OPEN_ENDPOINTS:
|
||||
return
|
||||
# First run with auth on and no password yet: force setting one.
|
||||
return redirect(url_for("setup" if not _has_password() else "login"))
|
||||
|
||||
|
||||
def _api_guard():
|
||||
"""Return an error response if the API key is required and missing/wrong,
|
||||
else None."""
|
||||
if not API_KEY:
|
||||
return None
|
||||
given = request.headers.get("X-Api-Key") or request.args.get("key")
|
||||
if given != API_KEY:
|
||||
return jsonify({"error": "unauthorized: send the X-Api-Key header"}), 401
|
||||
return None
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
# Every page shows the database picker, so hand it the list each render.
|
||||
return {
|
||||
"databases": db.list_databases(),
|
||||
"active_db": db.get_active(),
|
||||
"auth_enabled": AUTH_ENABLED,
|
||||
}
|
||||
|
||||
|
||||
# ---------- auth (only active when AHDX_AUTH is on) ----------
|
||||
|
||||
@app.route("/setup", methods=["GET", "POST"])
|
||||
def setup():
|
||||
if not AUTH_ENABLED or _has_password():
|
||||
return redirect(url_for("index"))
|
||||
if request.method == "POST":
|
||||
pw = request.form.get("password", "")
|
||||
confirm = request.form.get("confirm", "")
|
||||
if len(pw) < 6:
|
||||
flash("Use at least 6 characters.", "error")
|
||||
elif pw != confirm:
|
||||
flash("The two passwords don't match.", "error")
|
||||
else:
|
||||
db.set_password(pw)
|
||||
_sync_grafana_password(pw) # make it the Grafana admin password too
|
||||
session["authed"] = True
|
||||
flash("Password set — it's now the login for both AHDX and Grafana.", "success")
|
||||
return redirect(url_for("index"))
|
||||
return render_template("auth.html", mode="setup")
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if not AUTH_ENABLED:
|
||||
return redirect(url_for("index"))
|
||||
if not _has_password():
|
||||
return redirect(url_for("setup"))
|
||||
if request.method == "POST":
|
||||
if _check_password(request.form.get("password", "")):
|
||||
session["authed"] = True
|
||||
return redirect(url_for("index"))
|
||||
flash("Wrong password.", "error")
|
||||
return render_template("auth.html", mode="login")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.clear()
|
||||
return redirect(url_for("login") if AUTH_ENABLED else url_for("index"))
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ---------- import ----------
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("import.html", status=db.import_status())
|
||||
|
||||
|
||||
@app.route("/import", methods=["POST"])
|
||||
def do_import():
|
||||
f = request.files.get("file")
|
||||
if not f or not f.filename:
|
||||
flash("Pick your Apple Health export first.", "error")
|
||||
return redirect(url_for("index"))
|
||||
if jobs.import_running():
|
||||
flash("An import is already running. Give it a moment.", "error")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
saved = os.path.join(db.UPLOAD_DIR, "upload_" + secure_filename(f.filename))
|
||||
f.save(saved)
|
||||
# Parse off the request thread; the page polls /import/status for progress.
|
||||
threading.Thread(
|
||||
target=jobs.run_import, args=(saved, "upload:" + f.filename), daemon=True
|
||||
).start()
|
||||
flash("Import started.", "success")
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/import/status")
|
||||
def import_status():
|
||||
s = db.import_status()
|
||||
return jsonify({k: s[k] for k in s.keys()})
|
||||
|
||||
|
||||
# ---------- dashboard + browse ----------
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard():
|
||||
return render_template("dashboard.html", cards=db.dashboard_cards(), **db.dashboard_stats())
|
||||
|
||||
|
||||
@app.route("/trends")
|
||||
def trends():
|
||||
types = db.record_types()
|
||||
sel = request.args.get("type") or (types[0] if types else None)
|
||||
agg = request.args.get("agg", "avg")
|
||||
if agg not in ("avg", "sum", "min", "max", "count"):
|
||||
agg = "avg"
|
||||
points = db.daily_rollup(sel, agg) if sel else []
|
||||
stats = db.type_stats(sel) if sel else None
|
||||
return render_template(
|
||||
"trends.html", types=types, sel_type=sel or "", agg=agg,
|
||||
points=points, stats=stats,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/browse")
|
||||
def browse():
|
||||
type_ = request.args.get("type") or None
|
||||
start = request.args.get("start") or None
|
||||
end = request.args.get("end") or None
|
||||
rows = db.browse(type_, start, end, limit=500)
|
||||
return render_template(
|
||||
"browse.html", rows=rows, types=db.record_types(),
|
||||
sel_type=type_ or "", start=start or "", end=end or "",
|
||||
)
|
||||
|
||||
|
||||
@app.route("/export.csv")
|
||||
def export_csv():
|
||||
type_ = request.args.get("type") or None
|
||||
start = request.args.get("start") or None
|
||||
end = request.args.get("end") or None
|
||||
rows = db.browse(type_, start, end, limit=5_000_000)
|
||||
|
||||
def generate():
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["type", "source_name", "unit", "value", "start_date", "end_date"])
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate(0)
|
||||
for r in rows:
|
||||
writer.writerow([r["type"], r["source_name"], r["unit"], r["value"],
|
||||
r["start_date"], r["end_date"]])
|
||||
yield buf.getvalue()
|
||||
buf.seek(0); buf.truncate(0)
|
||||
|
||||
return Response(
|
||||
generate(), mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=ahdx-records.csv"},
|
||||
)
|
||||
|
||||
|
||||
# ---------- routes + ECG ----------
|
||||
|
||||
@app.route("/routes")
|
||||
def routes():
|
||||
return render_template("routes.html", routes=db.list_routes())
|
||||
|
||||
|
||||
@app.route("/routes/<int:route_id>")
|
||||
def route_detail(route_id):
|
||||
route, points = db.get_route(route_id)
|
||||
if not route:
|
||||
abort(404)
|
||||
return render_template("route_detail.html", route=route, points=points)
|
||||
|
||||
|
||||
@app.route("/ecg")
|
||||
def ecg():
|
||||
return render_template("ecg.html", items=db.list_ecg())
|
||||
|
||||
|
||||
@app.route("/ecg/<int:ecg_id>")
|
||||
def ecg_detail(ecg_id):
|
||||
row, samples = db.get_ecg(ecg_id)
|
||||
if not row:
|
||||
abort(404)
|
||||
return render_template("ecg_detail.html", ecg=row, samples=samples)
|
||||
|
||||
|
||||
# ---------- databases ----------
|
||||
|
||||
@app.route("/databases")
|
||||
def databases():
|
||||
return render_template("databases.html", overview=db.database_overview())
|
||||
|
||||
|
||||
@app.route("/databases/new", methods=["POST"])
|
||||
def new_database():
|
||||
try:
|
||||
db.create_database(request.form.get("name", ""))
|
||||
flash("Database created and set active.", "success")
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("databases"))
|
||||
|
||||
|
||||
@app.route("/databases/<int:db_id>/rename", methods=["POST"])
|
||||
def rename_database(db_id):
|
||||
try:
|
||||
db.rename_database(db_id, request.form.get("name", ""))
|
||||
flash("Database renamed.", "success")
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("databases"))
|
||||
|
||||
|
||||
@app.route("/databases/<int:db_id>/activate", methods=["POST"])
|
||||
def activate_database(db_id):
|
||||
db.set_active(db_id)
|
||||
flash("Switched active database.", "success")
|
||||
return redirect(request.referrer or url_for("databases"))
|
||||
|
||||
|
||||
@app.route("/databases/<int:db_id>/delete", methods=["POST"])
|
||||
def delete_database(db_id):
|
||||
try:
|
||||
db.delete_database(db_id)
|
||||
flash("Database deleted.", "success")
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "error")
|
||||
return redirect(url_for("databases"))
|
||||
|
||||
|
||||
# ---------- push endpoint for iOS Shortcuts ----------
|
||||
|
||||
@app.route("/ingest", methods=["POST"])
|
||||
def ingest():
|
||||
"""Accept a batch of records as JSON and merge them into the active database.
|
||||
Body is either a list of record objects or {"records": [...]}. A record is
|
||||
{type, value, unit, start_date, end_date, source_name}. Same dedup as the
|
||||
XML import, so re-sending an overlapping window is harmless."""
|
||||
data = request.get_json(silent=True)
|
||||
rows = data.get("records") if isinstance(data, dict) else data
|
||||
if not isinstance(rows, list):
|
||||
return jsonify({"error": 'send a JSON list, or {"records": [...]}'}), 400
|
||||
added = db.ingest_records(rows)
|
||||
return jsonify({"received": len(rows), "added": added})
|
||||
|
||||
|
||||
# ---------- read API (for Grafana, scripts, etc.) ----------
|
||||
|
||||
@app.route("/api")
|
||||
def api_index():
|
||||
"""The menu. Lists the databases and every endpoint so you can discover the
|
||||
API by opening this one URL."""
|
||||
guard = _api_guard()
|
||||
if guard:
|
||||
return guard
|
||||
base = request.host_url.rstrip("/")
|
||||
names = [d["name"] for d in db.list_databases()]
|
||||
example_db = names[0] if names else "Steffen"
|
||||
return jsonify({
|
||||
"app": "AHDX read API",
|
||||
"databases": names,
|
||||
"how": "Add ?db=<name> to target a database. Leave it off to use the active one.",
|
||||
"auth": "open" if not API_KEY else "send X-Api-Key header (or ?key=)",
|
||||
"endpoints": [
|
||||
{"path": "/api/databases", "returns": "the list of databases"},
|
||||
{"path": "/api/types", "params": ["db"], "returns": "record types with counts"},
|
||||
{"path": "/api/records", "params": ["db", "type", "from", "to", "limit"],
|
||||
"returns": "raw records"},
|
||||
{"path": "/api/daily", "params": ["db", "type", "agg", "from", "to"],
|
||||
"returns": "one value per day; agg is avg|sum|min|max|count. Best for Grafana."},
|
||||
{"path": "/api/workouts", "params": ["db"], "returns": "workouts"},
|
||||
{"path": "/api/activity", "params": ["db"], "returns": "daily activity summaries"},
|
||||
{"path": "/api/sleep", "params": ["db", "from", "to"],
|
||||
"returns": "one row per night: hours per stage + a 0-100 score"},
|
||||
{"path": "/api/routes", "params": ["db"], "returns": "GPS routes list"},
|
||||
{"path": "/api/route", "params": ["db", "id"], "returns": "one route's lat/lon points"},
|
||||
],
|
||||
"examples": [
|
||||
f"{base}/api/types?db={example_db}",
|
||||
f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierStepCount&agg=sum",
|
||||
f"{base}/api/daily?db={example_db}&type=HKQuantityTypeIdentifierHeartRate&agg=avg&from=2026-01-01",
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/databases")
|
||||
def api_databases():
|
||||
guard = _api_guard()
|
||||
if guard:
|
||||
return guard
|
||||
return jsonify([
|
||||
{"name": d["name"], "file": d["filename"], "active": bool(d["is_active"])}
|
||||
for d in db.list_databases()
|
||||
])
|
||||
|
||||
|
||||
def _with_db(fn):
|
||||
"""Run a db.api_* call, turning an unknown ?db= into a clean 404."""
|
||||
guard = _api_guard()
|
||||
if guard:
|
||||
return guard
|
||||
try:
|
||||
return jsonify(fn())
|
||||
except KeyError:
|
||||
return jsonify({"error": "unknown database; see /api/databases"}), 404
|
||||
|
||||
|
||||
@app.route("/api/types")
|
||||
def api_types():
|
||||
return _with_db(lambda: db.api_types(request.args.get("db")))
|
||||
|
||||
|
||||
@app.route("/api/records")
|
||||
def api_records():
|
||||
type_ = request.args.get("type")
|
||||
if not type_:
|
||||
return jsonify({"error": "type is required; see /api/types"}), 400
|
||||
limit = min(int(request.args.get("limit", 10000)), 200000)
|
||||
return _with_db(lambda: db.api_records(
|
||||
request.args.get("db"), type_, request.args.get("from"),
|
||||
request.args.get("to"), limit,
|
||||
))
|
||||
|
||||
|
||||
@app.route("/api/daily")
|
||||
def api_daily():
|
||||
type_ = request.args.get("type")
|
||||
if not type_:
|
||||
return jsonify({"error": "type is required; see /api/types"}), 400
|
||||
agg = request.args.get("agg", "avg")
|
||||
if agg not in ("avg", "sum", "min", "max", "count"):
|
||||
agg = "avg"
|
||||
return _with_db(lambda: db.api_daily(
|
||||
request.args.get("db"), type_, agg,
|
||||
request.args.get("from"), request.args.get("to"),
|
||||
))
|
||||
|
||||
|
||||
@app.route("/api/workouts")
|
||||
def api_workouts():
|
||||
return _with_db(lambda: db.api_workouts(request.args.get("db")))
|
||||
|
||||
|
||||
@app.route("/api/activity")
|
||||
def api_activity():
|
||||
return _with_db(lambda: db.api_activity(request.args.get("db")))
|
||||
|
||||
|
||||
@app.route("/api/sleep")
|
||||
def api_sleep():
|
||||
return _with_db(lambda: db.api_sleep(
|
||||
request.args.get("db"), request.args.get("from"), request.args.get("to")))
|
||||
|
||||
|
||||
@app.route("/api/routes")
|
||||
def api_routes():
|
||||
return _with_db(lambda: db.api_routes(request.args.get("db")))
|
||||
|
||||
|
||||
@app.route("/api/route")
|
||||
def api_route():
|
||||
# Missing / empty / unresolved-variable id -> latest route (handled in db).
|
||||
rid = request.args.get("id")
|
||||
rid = int(rid) if (rid and rid.isdigit()) else None
|
||||
return _with_db(lambda: db.api_route_points(request.args.get("db"), rid))
|
||||
|
||||
|
||||
@app.route("/api/route/stats")
|
||||
def api_route_stats():
|
||||
rid = request.args.get("id")
|
||||
rid = int(rid) if (rid and rid.isdigit()) else None
|
||||
return _with_db(lambda: db.api_route_stats(request.args.get("db"), rid))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080")))
|
||||
@@ -0,0 +1,730 @@
|
||||
"""
|
||||
Storage layer for AHDX.
|
||||
|
||||
Two kinds of database live under the data volume:
|
||||
|
||||
registry.db the list of health databases and which one is active
|
||||
databases/<name>.db one per health dataset (per person, per year, whatever)
|
||||
|
||||
Keeping them as separate files means you can hand someone a single .db and it's
|
||||
their whole dataset, nothing else tangled in. Everything here is plain stdlib
|
||||
sqlite3, no ORM.
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
|
||||
DATA_DIR = os.environ.get("AHDX_DATA", "/data")
|
||||
DB_DIR = os.path.join(DATA_DIR, "databases")
|
||||
UPLOAD_DIR = os.path.join(DATA_DIR, "uploads")
|
||||
INBOX_DIR = os.path.join(DATA_DIR, "inbox")
|
||||
INBOX_DONE = os.path.join(INBOX_DIR, "done")
|
||||
REGISTRY = os.path.join(DATA_DIR, "registry.db")
|
||||
SCHEMA = os.path.join(os.path.dirname(__file__), "schema.sql")
|
||||
|
||||
|
||||
def _ensure_dirs():
|
||||
for d in (DATA_DIR, DB_DIR, UPLOAD_DIR, INBOX_DIR, INBOX_DONE):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _open(path):
|
||||
conn = sqlite3.connect(path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init():
|
||||
_ensure_dirs()
|
||||
with _open(REGISTRY) as c:
|
||||
c.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS databases (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 0
|
||||
)"""
|
||||
)
|
||||
# Small app-wide key/value store: the GUI password hash, the session
|
||||
# secret, anything that isn't health data.
|
||||
c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)")
|
||||
# A fresh install has nothing, so give it one database to land data in.
|
||||
if not list_databases():
|
||||
create_database("My Health")
|
||||
if get_active() is None:
|
||||
set_active(list_databases()[0]["id"])
|
||||
|
||||
# Re-apply the schema to every database on startup. It's all CREATE ... IF
|
||||
# NOT EXISTS, so this is a no-op on current databases and quietly adds new
|
||||
# tables (routes, ecg, ...) to ones made by an older version.
|
||||
for row in list_databases():
|
||||
apply_schema(os.path.join(DB_DIR, row["filename"]))
|
||||
|
||||
|
||||
# ---------- settings + GUI password ----------
|
||||
|
||||
def get_setting(key, default=None):
|
||||
with _open(REGISTRY) as c:
|
||||
row = c.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row else default
|
||||
|
||||
|
||||
def set_setting(key, value):
|
||||
with _open(REGISTRY) as c:
|
||||
c.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, value),
|
||||
)
|
||||
|
||||
|
||||
def has_password():
|
||||
return get_setting("password_hash") is not None
|
||||
|
||||
|
||||
def set_password(pw):
|
||||
from werkzeug.security import generate_password_hash
|
||||
# pbkdf2 rather than the newer scrypt default, so it hashes on any Python
|
||||
# build (scrypt needs an OpenSSL that isn't always compiled in).
|
||||
set_setting("password_hash", generate_password_hash(pw, method="pbkdf2:sha256"))
|
||||
|
||||
|
||||
def check_password(pw):
|
||||
from werkzeug.security import check_password_hash
|
||||
h = get_setting("password_hash")
|
||||
return bool(h and check_password_hash(h, pw))
|
||||
|
||||
|
||||
# ---------- the database registry ----------
|
||||
|
||||
def list_databases():
|
||||
with _open(REGISTRY) as c:
|
||||
return c.execute("SELECT * FROM databases ORDER BY created_at, id").fetchall()
|
||||
|
||||
|
||||
def get_active():
|
||||
with _open(REGISTRY) as c:
|
||||
return c.execute("SELECT * FROM databases WHERE is_active = 1").fetchone()
|
||||
|
||||
|
||||
def set_active(db_id):
|
||||
with _open(REGISTRY) as c:
|
||||
c.execute("UPDATE databases SET is_active = 0")
|
||||
c.execute("UPDATE databases SET is_active = 1 WHERE id = ?", (db_id,))
|
||||
|
||||
|
||||
def _slug(name):
|
||||
s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return s or "db"
|
||||
|
||||
|
||||
def create_database(name):
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise ValueError("A database needs a name.")
|
||||
with _open(REGISTRY) as c:
|
||||
if c.execute("SELECT 1 FROM databases WHERE name = ?", (name,)).fetchone():
|
||||
raise ValueError("A database with that name already exists.")
|
||||
|
||||
# Pick a filename that doesn't collide with one already on disk.
|
||||
base = _slug(name)
|
||||
filename = base + ".db"
|
||||
n = 1
|
||||
while os.path.exists(os.path.join(DB_DIR, filename)):
|
||||
filename = f"{base}-{n}.db"
|
||||
n += 1
|
||||
|
||||
apply_schema(os.path.join(DB_DIR, filename))
|
||||
with _open(REGISTRY) as c:
|
||||
cur = c.execute(
|
||||
"INSERT INTO databases (name, filename) VALUES (?, ?)", (name, filename)
|
||||
)
|
||||
new_id = cur.lastrowid
|
||||
set_active(new_id)
|
||||
return new_id
|
||||
|
||||
|
||||
def _human_size(n):
|
||||
size = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB"):
|
||||
if size < 1024 or unit == "GB":
|
||||
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
|
||||
|
||||
def database_overview():
|
||||
"""The registry rows plus, for each database, its file size and a few
|
||||
counts. Opens each .db file, so it's a page-load query, not a hot path."""
|
||||
out = []
|
||||
for row in list_databases():
|
||||
path = os.path.join(DB_DIR, row["filename"])
|
||||
info = {
|
||||
"id": row["id"], "name": row["name"], "filename": row["filename"],
|
||||
"created_at": row["created_at"], "is_active": row["is_active"],
|
||||
"size": os.path.getsize(path) if os.path.exists(path) else 0,
|
||||
"records": 0, "workouts": 0, "last_import": None,
|
||||
}
|
||||
info["size_h"] = _human_size(info["size"])
|
||||
try:
|
||||
with _open(path) as c:
|
||||
info["records"] = c.execute("SELECT COUNT(*) AS n FROM records").fetchone()["n"]
|
||||
info["workouts"] = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"]
|
||||
st = c.execute("SELECT finished_at FROM import_status WHERE id = 1").fetchone()
|
||||
info["last_import"] = st["finished_at"] if st else None
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
out.append(info)
|
||||
return out
|
||||
|
||||
|
||||
def rename_database(db_id, new_name):
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
raise ValueError("A database needs a name.")
|
||||
with _open(REGISTRY) as c:
|
||||
clash = c.execute(
|
||||
"SELECT 1 FROM databases WHERE name = ? AND id <> ?", (new_name, db_id)
|
||||
).fetchone()
|
||||
if clash:
|
||||
raise ValueError("A database with that name already exists.")
|
||||
c.execute("UPDATE databases SET name = ? WHERE id = ?", (new_name, db_id))
|
||||
|
||||
|
||||
def delete_database(db_id):
|
||||
with _open(REGISTRY) as c:
|
||||
row = c.execute("SELECT * FROM databases WHERE id = ?", (db_id,)).fetchone()
|
||||
if not row:
|
||||
return
|
||||
if len(list_databases()) <= 1:
|
||||
raise ValueError("This is the only database, so there's nothing to switch to. Create another first.")
|
||||
|
||||
path = os.path.join(DB_DIR, row["filename"])
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
with _open(REGISTRY) as c:
|
||||
c.execute("DELETE FROM databases WHERE id = ?", (db_id,))
|
||||
if row["is_active"]:
|
||||
set_active(list_databases()[0]["id"])
|
||||
|
||||
|
||||
def apply_schema(path):
|
||||
conn = sqlite3.connect(path)
|
||||
try:
|
||||
with open(SCHEMA, encoding="utf-8") as f:
|
||||
conn.executescript(f.read())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------- the active health database ----------
|
||||
|
||||
def active_path():
|
||||
a = get_active()
|
||||
return os.path.join(DB_DIR, a["filename"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def health():
|
||||
with _open(active_path()) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def resolve_db(name):
|
||||
"""Path for a database by name or filename. None (or an unresolved Grafana
|
||||
variable like "${db}") means the active one; an unknown name raises KeyError
|
||||
so the API can answer 404."""
|
||||
if not name or name.startswith("$"):
|
||||
return active_path()
|
||||
with _open(REGISTRY) as c:
|
||||
row = c.execute(
|
||||
"SELECT filename FROM databases WHERE lower(name) = lower(?) OR lower(filename) = lower(?)",
|
||||
(name, name),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(name)
|
||||
return os.path.join(DB_DIR, row["filename"])
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_db(name=None):
|
||||
with _open(resolve_db(name)) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------- read API queries (pick the database by name) ----------
|
||||
|
||||
def api_types(name=None):
|
||||
with open_db(name) as c:
|
||||
return [dict(r) for r in c.execute(
|
||||
"SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last "
|
||||
"FROM records GROUP BY type ORDER BY n DESC"
|
||||
)]
|
||||
|
||||
|
||||
def api_records(name, type_, start, end, limit):
|
||||
q = ("SELECT type, value, value_num, unit, source_name, start_date, end_date "
|
||||
"FROM records WHERE type = ?")
|
||||
params = [type_]
|
||||
if start:
|
||||
q += " AND start_date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
q += " AND start_date <= ?"
|
||||
params.append(end)
|
||||
q += " ORDER BY start_date LIMIT ?"
|
||||
params.append(limit)
|
||||
with open_db(name) as c:
|
||||
return [dict(r) for r in c.execute(q, params)]
|
||||
|
||||
|
||||
def api_daily(name, type_, agg, start, end):
|
||||
"""One value per day, shaped as [{"time": "...", "value": n}] so a Grafana
|
||||
JSON/Infinity datasource can read it straight."""
|
||||
expr = _AGG.get(agg, _AGG["avg"])
|
||||
where = "type = ?"
|
||||
params = [type_]
|
||||
if agg != "count":
|
||||
where += " AND value_num IS NOT NULL"
|
||||
if start:
|
||||
where += " AND start_date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
where += " AND start_date <= ?"
|
||||
params.append(end)
|
||||
q = (f"SELECT substr(start_date, 1, 10) AS time, {expr} AS value "
|
||||
f"FROM records WHERE {where} GROUP BY time ORDER BY time")
|
||||
with open_db(name) as c:
|
||||
return [dict(r) for r in c.execute(q, params) if r["time"]]
|
||||
|
||||
|
||||
def api_workouts(name=None):
|
||||
with open_db(name) as c:
|
||||
rows = [dict(r) for r in c.execute(
|
||||
"SELECT activity_type, duration, duration_unit, total_distance, distance_unit, "
|
||||
"total_energy, energy_unit, start_date, end_date, source_name "
|
||||
"FROM workouts ORDER BY start_date DESC"
|
||||
)]
|
||||
for r in rows:
|
||||
if r["activity_type"]: # "HKWorkoutActivityTypeWalking" -> "Walking"
|
||||
r["activity_type"] = r["activity_type"].replace("HKWorkoutActivityType", "")
|
||||
return rows
|
||||
|
||||
|
||||
def api_activity(name=None):
|
||||
with open_db(name) as c:
|
||||
return [dict(r) for r in c.execute(
|
||||
"SELECT date AS time, active_energy, active_energy_goal, move_time, "
|
||||
"exercise_time, stand_hours FROM activity_summary ORDER BY date"
|
||||
)]
|
||||
|
||||
|
||||
def _parse_dt(s):
|
||||
from datetime import datetime
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z") # "... +0200"
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def api_sleep(name=None, start=None, end=None):
|
||||
"""One row per night with hours per stage and a 0–100 score. Apple has no
|
||||
native sleep score, so we make one from duration, efficiency, and (when the
|
||||
watch recorded stages) how much deep/REM sleep there was. Older data only
|
||||
has "asleep vs in bed", so the score falls back to duration + efficiency."""
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
q = ("SELECT value, start_date, end_date FROM records "
|
||||
"WHERE type = 'HKCategoryTypeIdentifierSleepAnalysis'")
|
||||
params = []
|
||||
if start:
|
||||
q += " AND start_date >= ?"; params.append(start)
|
||||
if end:
|
||||
q += " AND start_date <= ?"; params.append(end)
|
||||
with open_db(name) as c:
|
||||
rows = c.execute(q, params).fetchall()
|
||||
|
||||
nights = defaultdict(lambda: dict(in_bed=0.0, awake=0.0, deep=0.0, rem=0.0, core=0.0, unspec=0.0))
|
||||
for r in rows:
|
||||
s, e = _parse_dt(r["start_date"]), _parse_dt(r["end_date"])
|
||||
if not s or not e:
|
||||
continue
|
||||
hours = (e - s).total_seconds() / 3600.0
|
||||
if hours <= 0 or hours > 16:
|
||||
continue
|
||||
# Assign to a night by shifting 18h back, so an evening + the morning
|
||||
# after it land on the same date.
|
||||
night = (s - timedelta(hours=18)).date().isoformat()
|
||||
v = r["value"] or ""
|
||||
if v.endswith("InBed"):
|
||||
nights[night]["in_bed"] += hours
|
||||
elif v.endswith("Awake"):
|
||||
nights[night]["awake"] += hours
|
||||
elif v.endswith("AsleepDeep"):
|
||||
nights[night]["deep"] += hours
|
||||
elif v.endswith("AsleepREM"):
|
||||
nights[night]["rem"] += hours
|
||||
elif v.endswith("AsleepCore"):
|
||||
nights[night]["core"] += hours
|
||||
elif "Asleep" in v:
|
||||
nights[night]["unspec"] += hours
|
||||
|
||||
out = []
|
||||
for night in sorted(nights):
|
||||
d = nights[night]
|
||||
asleep = d["deep"] + d["rem"] + d["core"] + d["unspec"]
|
||||
in_bed = d["in_bed"] if d["in_bed"] > 0 else asleep + d["awake"]
|
||||
has_stages = (d["deep"] + d["rem"] + d["core"]) > 0
|
||||
dur = min(asleep / 8.0, 1.0) # 8h asleep = full marks
|
||||
eff = min(asleep / in_bed, 1.0) if in_bed else dur
|
||||
if has_stages:
|
||||
deep_s = min((d["deep"] / asleep) / 0.16, 1.0) if asleep else 0
|
||||
rem_s = min((d["rem"] / asleep) / 0.22, 1.0) if asleep else 0
|
||||
score = 100 * (0.40 * dur + 0.25 * eff + 0.175 * deep_s + 0.175 * rem_s)
|
||||
else:
|
||||
score = 100 * (0.60 * dur + 0.40 * eff)
|
||||
out.append({
|
||||
"time": night, "asleep_h": round(asleep, 2), "in_bed_h": round(in_bed, 2),
|
||||
"deep_h": round(d["deep"], 2), "rem_h": round(d["rem"], 2),
|
||||
"core_h": round(d["core"], 2), "unspecified_h": round(d["unspec"], 2),
|
||||
"awake_h": round(d["awake"], 2), "efficiency": round(100 * eff),
|
||||
"score": round(score),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def api_routes(name=None):
|
||||
with open_db(name) as c:
|
||||
return [dict(r) for r in c.execute(
|
||||
"SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC"
|
||||
)]
|
||||
|
||||
|
||||
def _haversine_km(a_lat, a_lon, b_lat, b_lon):
|
||||
import math
|
||||
r = 6371.0
|
||||
dlat, dlon = math.radians(b_lat - a_lat), math.radians(b_lon - a_lon)
|
||||
h = (math.sin(dlat / 2) ** 2 +
|
||||
math.cos(math.radians(a_lat)) * math.cos(math.radians(b_lat)) * math.sin(dlon / 2) ** 2)
|
||||
return 2 * r * math.asin(math.sqrt(h))
|
||||
|
||||
|
||||
def api_route_stats(name, route_id=None):
|
||||
"""Distance, duration, elevation gain and average speed for one route,
|
||||
worked out from its points. No id -> the latest route (matches the map)."""
|
||||
from datetime import datetime
|
||||
with open_db(name) as c:
|
||||
if not route_id:
|
||||
latest = c.execute("SELECT id FROM routes ORDER BY start_date DESC LIMIT 1").fetchone()
|
||||
if not latest:
|
||||
return {}
|
||||
route_id = latest["id"]
|
||||
route = c.execute("SELECT filename, start_date FROM routes WHERE id = ?", (route_id,)).fetchone()
|
||||
pts = c.execute(
|
||||
"SELECT lat, lon, ele, t FROM route_points WHERE route_id = ? ORDER BY rowid", (route_id,)
|
||||
).fetchall()
|
||||
workouts = c.execute(
|
||||
"SELECT activity_type, total_energy, start_date FROM workouts").fetchall()
|
||||
if not route or not pts:
|
||||
return {}
|
||||
|
||||
dist = 0.0
|
||||
gain = 0.0
|
||||
prev = prev_ele = None
|
||||
for p in pts:
|
||||
if p["lat"] is None or p["lon"] is None:
|
||||
continue
|
||||
if prev is not None:
|
||||
dist += _haversine_km(prev[0], prev[1], p["lat"], p["lon"])
|
||||
prev = (p["lat"], p["lon"])
|
||||
if p["ele"] is not None:
|
||||
if prev_ele is not None and p["ele"] > prev_ele:
|
||||
gain += p["ele"] - prev_ele
|
||||
prev_ele = p["ele"]
|
||||
|
||||
def _t(s):
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
times = [t for t in (_t(p["t"]) for p in pts) if t]
|
||||
dur_min = (times[-1] - times[0]).total_seconds() / 60.0 if len(times) >= 2 else None
|
||||
speed = dist / (dur_min / 60.0) if dur_min else None
|
||||
|
||||
# The GPX has no calories, so match this route to its workout by start time
|
||||
# (within an hour) and borrow the workout's energy. Route timestamps are
|
||||
# UTC ("...Z"); workout start dates carry a timezone offset.
|
||||
from datetime import datetime, timezone
|
||||
|
||||
def _utc(s):
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"):
|
||||
try:
|
||||
return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z").astimezone(timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
route_start = _utc(pts[0]["t"]) or _utc(route["start_date"])
|
||||
energy = activity = None
|
||||
if route_start:
|
||||
best = None
|
||||
for w in workouts:
|
||||
ws = _utc(w["start_date"])
|
||||
if not ws:
|
||||
continue
|
||||
diff = abs((ws - route_start).total_seconds())
|
||||
if diff < 3600 and (best is None or diff < best[0]):
|
||||
best = (diff, w)
|
||||
if best:
|
||||
energy = best[1]["total_energy"]
|
||||
activity = (best[1]["activity_type"] or "").replace("HKWorkoutActivityType", "")
|
||||
|
||||
return {
|
||||
"filename": route["filename"], "start_date": (route["start_date"] or "")[:10],
|
||||
"activity": activity, "distance_km": round(dist, 2), "elevation_gain_m": round(gain),
|
||||
"duration_min": round(dur_min, 1) if dur_min else None,
|
||||
"avg_speed_kmh": round(speed, 1) if speed else None,
|
||||
"energy_kcal": round(energy) if energy else None, "points": len(pts),
|
||||
}
|
||||
|
||||
|
||||
def api_route_points(name, route_id=None, max_points=800):
|
||||
"""A route's lat/lon, thinned so a map isn't handed 6,000 points. With no
|
||||
route_id, use the most recent route, so a map always has something to draw
|
||||
even before anyone picks one."""
|
||||
with open_db(name) as c:
|
||||
if not route_id:
|
||||
latest = c.execute(
|
||||
"SELECT id FROM routes ORDER BY start_date DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not latest:
|
||||
return []
|
||||
route_id = latest["id"]
|
||||
total = c.execute(
|
||||
"SELECT COUNT(*) AS n FROM route_points WHERE route_id = ?", (route_id,)
|
||||
).fetchone()["n"]
|
||||
step = max(1, total // max_points)
|
||||
rows = c.execute(
|
||||
"SELECT lat, lon FROM route_points WHERE route_id = ? AND rowid % ? = 0 ORDER BY rowid",
|
||||
(route_id, step),
|
||||
).fetchall()
|
||||
return [{"lat": r["lat"], "lon": r["lon"]} for r in rows if r["lat"] is not None]
|
||||
|
||||
|
||||
def import_status():
|
||||
with health() as c:
|
||||
return c.execute("SELECT * FROM import_status WHERE id = 1").fetchone()
|
||||
|
||||
|
||||
def dashboard_stats():
|
||||
with health() as c:
|
||||
totals = c.execute(
|
||||
"SELECT COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last FROM records"
|
||||
).fetchone()
|
||||
types = c.execute(
|
||||
"SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last "
|
||||
"FROM records GROUP BY type ORDER BY n DESC"
|
||||
).fetchall()
|
||||
workouts = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"]
|
||||
summaries = c.execute("SELECT COUNT(*) AS n FROM activity_summary").fetchone()["n"]
|
||||
status = c.execute("SELECT * FROM import_status WHERE id = 1").fetchone()
|
||||
return {
|
||||
"totals": totals, "types": types, "workouts": workouts,
|
||||
"summaries": summaries, "status": status,
|
||||
}
|
||||
|
||||
|
||||
def record_types():
|
||||
with health() as c:
|
||||
return [r["type"] for r in c.execute("SELECT DISTINCT type FROM records ORDER BY type")]
|
||||
|
||||
|
||||
def browse(type_=None, start=None, end=None, limit=500):
|
||||
q = "SELECT type, source_name, unit, value, start_date, end_date FROM records WHERE 1 = 1"
|
||||
params = []
|
||||
if type_:
|
||||
q += " AND type = ?"
|
||||
params.append(type_)
|
||||
if start:
|
||||
q += " AND start_date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
q += " AND start_date <= ?"
|
||||
params.append(end)
|
||||
q += " ORDER BY start_date DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
with health() as c:
|
||||
return c.execute(q, params).fetchall()
|
||||
|
||||
|
||||
def _fmt(num, raw):
|
||||
if num is None:
|
||||
return raw or ""
|
||||
if abs(num - round(num)) < 1e-9:
|
||||
return str(int(round(num)))
|
||||
return f"{num:.1f}"
|
||||
|
||||
|
||||
# The day lives in the first 10 chars of start_date ("2026-07-20 09:00 +0200").
|
||||
# SQLite's date() chokes on the trailing timezone, so we slice instead.
|
||||
_AGG = {
|
||||
"avg": "AVG(value_num)", "sum": "SUM(value_num)",
|
||||
"min": "MIN(value_num)", "max": "MAX(value_num)", "count": "COUNT(*)",
|
||||
}
|
||||
|
||||
|
||||
def daily_rollup(type_, agg="avg", start=None, end=None):
|
||||
"""One value per day for a record type. agg picks how the day's samples are
|
||||
combined: avg/min/max for things like heart rate, sum for step count and
|
||||
energy, count for how many samples landed that day."""
|
||||
expr = _AGG.get(agg, _AGG["avg"])
|
||||
where = "type = ?"
|
||||
params = [type_]
|
||||
if agg != "count":
|
||||
where += " AND value_num IS NOT NULL"
|
||||
if start:
|
||||
where += " AND start_date >= ?"
|
||||
params.append(start)
|
||||
if end:
|
||||
where += " AND start_date <= ?"
|
||||
params.append(end)
|
||||
q = (f"SELECT substr(start_date, 1, 10) AS day, {expr} AS v "
|
||||
f"FROM records WHERE {where} GROUP BY day ORDER BY day")
|
||||
with health() as c:
|
||||
return [(r["day"], r["v"]) for r in c.execute(q, params) if r["day"]]
|
||||
|
||||
|
||||
def type_stats(type_):
|
||||
with health() as c:
|
||||
return c.execute(
|
||||
"SELECT COUNT(*) AS n, AVG(value_num) AS avg, MIN(value_num) AS min, "
|
||||
"MAX(value_num) AS max, SUM(value_num) AS sum, MAX(unit) AS unit, "
|
||||
"MIN(start_date) AS first, MAX(start_date) AS last "
|
||||
"FROM records WHERE type = ?",
|
||||
(type_,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
# The handful of metrics worth showing at a glance, if the database has them.
|
||||
_CARD_LATEST = [
|
||||
("HKQuantityTypeIdentifierBodyMass", "Weight"),
|
||||
("HKQuantityTypeIdentifierRestingHeartRate", "Resting HR"),
|
||||
("HKQuantityTypeIdentifierHeartRate", "Heart rate"),
|
||||
("HKQuantityTypeIdentifierBodyMassIndex", "BMI"),
|
||||
("HKQuantityTypeIdentifierVO2Max", "VO2 max"),
|
||||
]
|
||||
|
||||
|
||||
def dashboard_cards():
|
||||
cards = []
|
||||
with health() as c:
|
||||
# Steps summed over the most recent day that has any.
|
||||
row = c.execute(
|
||||
"SELECT substr(start_date, 1, 10) AS day, SUM(value_num) AS s "
|
||||
"FROM records WHERE type = 'HKQuantityTypeIdentifierStepCount' "
|
||||
"AND value_num IS NOT NULL GROUP BY day ORDER BY day DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if row and row["day"]:
|
||||
cards.append({"label": "Steps", "value": str(int(row["s"])),
|
||||
"unit": "", "when": row["day"]})
|
||||
for type_, label in _CARD_LATEST:
|
||||
r = c.execute(
|
||||
"SELECT value, value_num, unit, start_date FROM records "
|
||||
"WHERE type = ? ORDER BY start_date DESC LIMIT 1", (type_,)
|
||||
).fetchone()
|
||||
if r:
|
||||
cards.append({"label": label, "value": _fmt(r["value_num"], r["value"]),
|
||||
"unit": r["unit"] or "", "when": (r["start_date"] or "")[:10]})
|
||||
return cards
|
||||
|
||||
|
||||
# ---------- routes + ECG ----------
|
||||
|
||||
def list_routes():
|
||||
with health() as c:
|
||||
return c.execute(
|
||||
"SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_route(route_id):
|
||||
with health() as c:
|
||||
route = c.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone()
|
||||
pts = c.execute(
|
||||
"SELECT lat, lon, ele FROM route_points WHERE route_id = ? ORDER BY rowid",
|
||||
(route_id,),
|
||||
).fetchall()
|
||||
return route, [(p["lat"], p["lon"]) for p in pts if p["lat"] is not None]
|
||||
|
||||
|
||||
def list_ecg():
|
||||
with health() as c:
|
||||
return c.execute(
|
||||
"SELECT id, filename, recorded_date, classification, sample_rate, duration_s "
|
||||
"FROM ecg ORDER BY recorded_date DESC"
|
||||
).fetchall()
|
||||
|
||||
|
||||
def get_ecg(ecg_id, max_points=1200):
|
||||
"""Return the ECG row plus its waveform, thinned to at most max_points so the
|
||||
browser isn't asked to draw 15,000 dots."""
|
||||
with health() as c:
|
||||
row = c.execute("SELECT * FROM ecg WHERE id = ?", (ecg_id,)).fetchone()
|
||||
total = c.execute(
|
||||
"SELECT COUNT(*) AS n FROM ecg_samples WHERE ecg_id = ?", (ecg_id,)
|
||||
).fetchone()["n"]
|
||||
step = max(1, total // max_points)
|
||||
samples = [
|
||||
r["uv"] for r in c.execute(
|
||||
"SELECT idx, uv FROM ecg_samples WHERE ecg_id = ? AND idx % ? = 0 ORDER BY idx",
|
||||
(ecg_id, step),
|
||||
)
|
||||
]
|
||||
return row, samples
|
||||
|
||||
|
||||
def counts_extras():
|
||||
with health() as c:
|
||||
r = c.execute("SELECT COUNT(*) AS n FROM routes").fetchone()["n"]
|
||||
e = c.execute("SELECT COUNT(*) AS n FROM ecg").fetchone()["n"]
|
||||
return {"routes": r, "ecg": e}
|
||||
|
||||
|
||||
def ingest_records(rows):
|
||||
"""Merge a list of record dicts into the active database. Used by the push
|
||||
endpoint. Returns how many were actually new. Same INSERT OR IGNORE dedup as
|
||||
the XML import, so the phone can re-send overlapping windows safely."""
|
||||
cols = ("type", "source_name", "source_version", "device", "unit",
|
||||
"value", "value_num", "start_date", "end_date", "creation_date")
|
||||
tuples = []
|
||||
for r in rows:
|
||||
value = r.get("value")
|
||||
try:
|
||||
value_num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
value_num = None
|
||||
tuples.append((
|
||||
r.get("type"), r.get("source_name"), r.get("source_version"),
|
||||
r.get("device"), r.get("unit"), None if value is None else str(value),
|
||||
value_num, r.get("start_date"), r.get("end_date"), r.get("creation_date"),
|
||||
))
|
||||
with health() as c:
|
||||
before = c.total_changes
|
||||
c.executemany(
|
||||
f"INSERT OR IGNORE INTO records ({','.join(cols)}) "
|
||||
f"VALUES ({','.join('?' * len(cols))})",
|
||||
tuples,
|
||||
)
|
||||
return c.total_changes - before
|
||||
@@ -0,0 +1,51 @@
|
||||
services:
|
||||
ahdx:
|
||||
build: .
|
||||
container_name: ahdx
|
||||
ports:
|
||||
# Host port is configurable; set AHDX_PORT to move it. Default 8088.
|
||||
- "${AHDX_PORT:-8088}:8080"
|
||||
volumes:
|
||||
# Your data stays in ./data on the host. Delete the container any time;
|
||||
# the databases and inbox are untouched.
|
||||
- ./data:/data
|
||||
environment:
|
||||
# How often the inbox folder is checked, in seconds. Set to 0 to turn the
|
||||
# watcher off and only import by hand.
|
||||
- AHDX_SCAN_INTERVAL=1800
|
||||
# Password-protect the web UI. On first run AHDX has no password in its DB,
|
||||
# so it takes you to a setup screen to create one (stored salted). That
|
||||
# same password is then pushed to Grafana, so one login covers both.
|
||||
- AHDX_AUTH=true
|
||||
# Optional shared key for the read API at /api (used by Grafana, scripts).
|
||||
# Leave blank to keep the API open on your LAN; set a value to require it
|
||||
# as the X-Api-Key header.
|
||||
- AHDX_API_KEY=
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 6
|
||||
restart: unless-stopped
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana-oss:latest
|
||||
container_name: ahdx-grafana
|
||||
ports:
|
||||
- "${GRAFANA_PORT:-3000}:3000"
|
||||
environment:
|
||||
# The Infinity datasource reads AHDX's JSON API. Installed on first boot.
|
||||
- GF_INSTALL_PLUGINS=yesoreyeram-infinity-datasource
|
||||
- GF_SECURITY_ADMIN_USER=admin
|
||||
# Grafana keeps its default admin/admin at first boot; AHDX changes it to
|
||||
# the password you set on the AHDX setup screen. Don't pin it here or AHDX
|
||||
# can't bootstrap it.
|
||||
volumes:
|
||||
- ./grafana-data:/var/lib/grafana
|
||||
# Auto-adds the "AHDX" datasource + the Apple Health dashboard.
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
- ./grafana/dashboards:/etc/dashboards
|
||||
depends_on:
|
||||
ahdx:
|
||||
condition: service_healthy # Grafana waits until AHDX is up
|
||||
restart: unless-stopped
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
# AHDX build plan
|
||||
|
||||
AHDX is a small self-hosted app for pulling your own Apple Health data out of
|
||||
Apple's export and into a SQLite database you control. It runs in Docker, works
|
||||
on Windows through Docker Desktop, and has a web page for loading the export and
|
||||
looking through it. Everything stays inside the container. Nothing is sent
|
||||
anywhere.
|
||||
|
||||
AHDX is short for Apple Health Data eXporter.
|
||||
|
||||
## Why it exists
|
||||
|
||||
You're in the EU, so the health data Apple holds about you is yours to take and
|
||||
keep. Apple will export it, but what you get back is one enormous XML file
|
||||
that's awkward to use. This app turns that file into an ordinary SQLite
|
||||
database and gives you a browser page to import and query it. No account, no
|
||||
cloud, no telemetry.
|
||||
|
||||
## What Apple actually gives you
|
||||
|
||||
On the iPhone: Health app, tap your profile picture, "Export All Health Data".
|
||||
You get `export.zip`. Inside it:
|
||||
|
||||
- `apple_health_export/export.xml` is the main file and the only one we need to
|
||||
start. It holds `<Record>` entries (heart rate, steps, weight, sleep, etc.),
|
||||
`<Workout>` entries, and `<ActivitySummary>` rows (one per day). This file
|
||||
gets big: real exports run from tens of megabytes to a few gigabytes.
|
||||
- `workout-routes/*.gpx` are GPS tracks for outdoor workouts.
|
||||
- `electrocardiograms/*.csv` are ECG readings.
|
||||
- `export_cda.xml` is a clinical-format copy of the same data. We skip it.
|
||||
|
||||
The size is the one real problem to solve. We can't load the whole XML into
|
||||
memory, so we stream it.
|
||||
|
||||
## Stack
|
||||
|
||||
Same shape as the other self-hosted apps here, because it works and there's
|
||||
little to break:
|
||||
|
||||
- Python + Flask, server-rendered pages, one CSS file. No frontend framework.
|
||||
- SQLite via the stdlib `sqlite3`. No ORM.
|
||||
- Docker + docker-compose. One service, one volume for the databases and uploads.
|
||||
- XML parsing with `xml.etree.ElementTree.iterparse`. It reads the file element
|
||||
by element and lets us throw each one away after we've stored it, so memory
|
||||
stays flat no matter how large the export is.
|
||||
|
||||
If we add charts, they get drawn in the browser from data the page already has,
|
||||
with the chart library shipped inside the image. No CDN, so it still works with
|
||||
the machine offline.
|
||||
|
||||
## Data model, first cut
|
||||
|
||||
Three tables cover nearly everything in export.xml:
|
||||
|
||||
- `records`: one row per measurement. Columns: type, source_name, source_version,
|
||||
device, unit, value, value_num, start_date, end_date, creation_date. Index on
|
||||
(type, start_date).
|
||||
- `workouts`: activity_type, duration, duration_unit, total_distance,
|
||||
distance_unit, total_energy, energy_unit, start_date, end_date, source_name.
|
||||
- `activity_summary`: date, active_energy_burned, active_energy_goal, move_time,
|
||||
exercise_time, stand_hours.
|
||||
|
||||
`value` keeps the raw text from the file. `value_num` holds the same thing as a
|
||||
REAL when it parses as a number, so numeric types (weight, heart rate) chart
|
||||
cleanly while text types (like sleep state) still survive the round trip.
|
||||
|
||||
Later, if you want them: workout GPS points, ECG samples, and the metadata
|
||||
key/value pairs Apple attaches to some records.
|
||||
|
||||
## The "add a database" part
|
||||
|
||||
You said standard SQLite, with a way to add databases. Here's what I'd build.
|
||||
Tell me if you meant something different.
|
||||
|
||||
The volume has a `databases/` folder. Each health database is one `.db` file in
|
||||
there. A small `registry.db` tracks the list and which one is active. The UI
|
||||
gives you:
|
||||
|
||||
- a picker to switch the active database,
|
||||
- "New database": name it, the app creates an empty `.db` and switches to it,
|
||||
- import always writes into whichever database is active.
|
||||
|
||||
That handles the cases I'd expect: one database per person in the house, a fresh
|
||||
one each year, or a scratch database to test an import before you trust it.
|
||||
|
||||
## Pages
|
||||
|
||||
- `/` import. Drop `export.zip` or `export.xml`. It parses into the active
|
||||
database and shows a live count while it runs.
|
||||
- `/dashboard` what's in here: record count per type, the date range covered,
|
||||
number of workouts, last import time.
|
||||
- `/browse` pick a type and a date range, read the rows, download them as CSV.
|
||||
- `/databases` list, switch, create, delete.
|
||||
- a status endpoint for Docker's health check.
|
||||
|
||||
## Import, the tricky bit
|
||||
|
||||
A big export takes a while to parse, so import can't block a request until it
|
||||
finishes. I'd run the parse in a background thread and write progress (rows
|
||||
seen, current record type) to a status row that the import page polls. That's
|
||||
enough for a single-user local app; a real job queue would be overkill.
|
||||
|
||||
Inserts go in batches, committing every few thousand rows, and each XML element
|
||||
gets cleared right after we read it.
|
||||
|
||||
Re-importing is a merge, not a reload. Every export holds your whole history, so
|
||||
loading a newer one shouldn't duplicate the old rows. Apple gives records no
|
||||
stable ID, so we make our own key: a UNIQUE index on
|
||||
(type, start_date, end_date, value, source_name) with `INSERT OR IGNORE`. Load
|
||||
the same export twice and nothing changes; load a fresher one and only the new
|
||||
rows land. Workouts key on (activity_type, start_date, end_date, source_name),
|
||||
activity summaries on the date. This is what keeps "the latest" flowing into the
|
||||
database without a wipe.
|
||||
|
||||
## Keeping it current (the interval scan)
|
||||
|
||||
You asked for a periodic scan that pulls recent data, free, with nothing behind
|
||||
a paid tier. The limit to be honest about: Apple has no free server-side API. A
|
||||
container on your PC can't reach into the phone and pull Health data by itself.
|
||||
The full `export.xml` is a manual export from the Health app. So the "interval
|
||||
scan" watches for exports rather than fetching them, and there's a push path for
|
||||
the automatable part.
|
||||
|
||||
Two free ways to feed it, both landing in the same merge:
|
||||
|
||||
1. Watched inbox. AHDX checks `data/inbox/` on a timer (default every 30
|
||||
minutes, set by `AHDX_SCAN_INTERVAL`). Drop an `export.zip` or `export.xml`
|
||||
in there, or point a synced folder at it, and the container ingests it on its
|
||||
own and moves the file to `data/inbox/done/`. No clicking.
|
||||
2. Push endpoint for iOS Shortcuts. A free Shortcut automation on the phone can
|
||||
read recent metrics (steps, heart rate, weight, sleep) on a schedule and POST
|
||||
them as JSON to `/ingest`. Built into iOS, no third-party app, no paid tier.
|
||||
It won't carry full history the way the manual export does, but it keeps
|
||||
recent data arriving hands-free.
|
||||
|
||||
What we can't do for free: a fully automatic full-history export. Apple only
|
||||
does that by hand. Automatic recent data is fine through the Shortcut push. The
|
||||
README will spell out both, with the exact Shortcut steps.
|
||||
|
||||
## Build order
|
||||
|
||||
1. Skeleton. Dockerfile, compose, a Flask app that boots, empty pages, status
|
||||
endpoint. Confirm it runs on Windows via Docker Desktop and the page loads at
|
||||
http://localhost:PORT.
|
||||
2. Parser and import. Streaming XML into the three tables, background parse with
|
||||
progress. This is the core. Get it solid against a real export.
|
||||
3. Browse and dashboard. Counts, filtering, CSV export.
|
||||
4. Multiple databases. Registry, switch, create, delete.
|
||||
5. Release polish. A README with the export steps and screenshots, a license, a
|
||||
small sample export.xml so people can try it, and a plain statement that the
|
||||
app makes no network calls.
|
||||
|
||||
## Open-source prep (later)
|
||||
|
||||
No git repo yet, per your note. When we set one up:
|
||||
|
||||
- License: MIT if you want the widest reuse, AGPL if you want anyone who runs a
|
||||
modified hosted copy to publish their changes. Your call.
|
||||
- README that leads with "your data, your machine" and the export steps.
|
||||
- No analytics and no outbound requests, stated plainly, since this is health data.
|
||||
|
||||
## What I need from you
|
||||
|
||||
1. Does AHDX stand for Apple Health Data eXport, or something else?
|
||||
2. Import scope to start: just `export.xml`, or accept the whole `export.zip` and
|
||||
pull the XML out ourselves? The zip is friendlier for you.
|
||||
3. "Add a database": is the registry idea above what you meant, or did you mean
|
||||
attaching an existing external `.db` file, or room for other engines (Postgres)
|
||||
down the line?
|
||||
4. A port preference. I'd default to 8080.
|
||||
5. If you can drop a real `export.xml` (or a trimmed chunk of one) into the
|
||||
project, I'll build the parser against actual data. Apple's XML has quirks
|
||||
that only show up in a real file.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Screenshots
|
||||
|
||||
Two images belong here, referenced from the top-level README:
|
||||
|
||||
- `dashboard-ola.png` — the Apple Health dashboard with Person = Ola
|
||||
- `dashboard-kari.png` — the same dashboard with Person = Kari
|
||||
|
||||
Grab them from Grafana (http://localhost:3000, the "Apple Health" dashboard),
|
||||
switch the Person dropdown, and save a screenshot of each with these names.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
# Loads the bundled dashboards from /etc/dashboards (mounted from
|
||||
# ./grafana/dashboards). allowUiUpdates lets you keep editing them in the UI.
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
- name: AHDX
|
||||
type: file
|
||||
disableDeletion: false
|
||||
allowUiUpdates: true
|
||||
updateIntervalSeconds: 30
|
||||
options:
|
||||
path: /etc/dashboards
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,17 @@
|
||||
# Auto-adds the Infinity datasource, pointed at the AHDX container over the
|
||||
# internal Docker network (so no host IP needed). Panels query paths like
|
||||
# /api/daily?db=Steffen&type=...&agg=sum against this base URL.
|
||||
apiVersion: 1
|
||||
|
||||
datasources:
|
||||
- name: AHDX
|
||||
uid: ahdx-infinity # fixed, so the shipped dashboard can reference it
|
||||
type: yesoreyeram-infinity-datasource
|
||||
access: proxy
|
||||
url: http://ahdx:8080
|
||||
isDefault: true
|
||||
jsonData:
|
||||
# If you set AHDX_API_KEY, add it as a header here:
|
||||
# httpHeaderName1: X-Api-Key
|
||||
# and put the value under secureJsonData.httpHeaderValue1
|
||||
tlsSkipVerify: true
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
One place that runs an import, shared by the upload page and the inbox scanner
|
||||
so the two can't parse into the same database at once. A plain lock is enough:
|
||||
this is a single-user app, imports are rare, and a second one can just wait.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
|
||||
import db
|
||||
import parser
|
||||
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def import_running():
|
||||
if _LOCK.acquire(blocking=False):
|
||||
_LOCK.release()
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def extract_xml(path, workdir):
|
||||
"""Return a path to the main export XML. If path is Apple's export zip, pull
|
||||
the XML out of it.
|
||||
|
||||
Apple localizes the filename to the phone's language, so it is not always
|
||||
'export.xml' (a Norwegian phone writes 'eksport.xml', German 'exportieren',
|
||||
and so on). The one steady name is the clinical copy 'export_cda.xml', which
|
||||
stays the same in every language and holds the same data in a format we don't
|
||||
use. So we take the largest .xml that isn't the _cda one."""
|
||||
if not zipfile.is_zipfile(path):
|
||||
return path # already an XML, or something we'll fail on cleanly
|
||||
|
||||
with zipfile.ZipFile(path) as z:
|
||||
xmls = [n for n in z.namelist() if n.lower().endswith(".xml")]
|
||||
main = [n for n in xmls if not n.rsplit("/", 1)[-1].lower().endswith("_cda.xml")]
|
||||
if not main:
|
||||
return None
|
||||
member = max(main, key=lambda n: z.getinfo(n).file_size)
|
||||
out = os.path.join(workdir, "export.xml")
|
||||
with z.open(member) as src, open(out, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=1024 * 1024)
|
||||
return out
|
||||
|
||||
|
||||
def run_import(file_path, source):
|
||||
"""Extract if needed, then merge into the active database. Blocks until done.
|
||||
Returns False if another import already holds the lock."""
|
||||
if not _LOCK.acquire(blocking=False):
|
||||
return False
|
||||
try:
|
||||
xml_path = extract_xml(file_path, db.UPLOAD_DIR)
|
||||
if not xml_path:
|
||||
with db.health() as c:
|
||||
c.execute(
|
||||
"UPDATE import_status SET state = 'error', "
|
||||
"message = 'No export.xml found in that file.' WHERE id = 1"
|
||||
)
|
||||
return False
|
||||
parser.parse(xml_path, db.active_path(), source=source)
|
||||
# GPS routes and ECG readings live only in the zip, so pull them too.
|
||||
if zipfile.is_zipfile(file_path):
|
||||
parser.parse_extras(file_path, db.active_path())
|
||||
return True
|
||||
finally:
|
||||
_LOCK.release()
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Streaming parser for Apple's Health export.xml.
|
||||
|
||||
The file is one <HealthData> root holding a flat list of <Record>, <Workout>
|
||||
and <ActivitySummary> children. Real exports run to gigabytes, so we never build
|
||||
the whole tree. iterparse hands us each element as it closes; we read its
|
||||
attributes, queue an insert, then clear the root so parsed elements don't pile
|
||||
up. Memory stays flat whether the file is 20 MB or 5 GB.
|
||||
|
||||
Import is a merge: INSERT OR IGNORE against the uniqueness indexes in schema.sql.
|
||||
Loading the same export twice changes nothing; a fresher export only adds rows
|
||||
that weren't there.
|
||||
"""
|
||||
import re
|
||||
import sqlite3
|
||||
import xml.etree.ElementTree as ET
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BATCH = 5000 # rows per executemany; bigger isn't faster once the disk is the limit
|
||||
|
||||
RECORD_COLS = ("type", "source_name", "source_version", "device", "unit",
|
||||
"value", "value_num", "start_date", "end_date", "creation_date")
|
||||
|
||||
|
||||
def _num(s):
|
||||
try:
|
||||
return float(s)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def parse(xml_path, db_path, source="upload"):
|
||||
"""Merge one export.xml into the database at db_path. Runs in its own thread,
|
||||
so it opens its own connection (sqlite connections can't cross threads)."""
|
||||
conn = sqlite3.connect(db_path, timeout=60)
|
||||
try:
|
||||
_run(conn, xml_path, source)
|
||||
except Exception as exc:
|
||||
conn.execute(
|
||||
"UPDATE import_status SET state = 'error', message = ?, finished_at = ? WHERE id = 1",
|
||||
(str(exc)[:1000], _now()),
|
||||
)
|
||||
conn.commit()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _run(conn, xml_path, source):
|
||||
conn.execute(
|
||||
"UPDATE import_status SET state = 'running', records_seen = 0, records_new = 0, "
|
||||
"current_type = NULL, source = ?, message = NULL, started_at = ?, finished_at = NULL "
|
||||
"WHERE id = 1",
|
||||
(source, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
insert_record = (
|
||||
f"INSERT OR IGNORE INTO records ({','.join(RECORD_COLS)}) "
|
||||
f"VALUES ({','.join('?' * len(RECORD_COLS))})"
|
||||
)
|
||||
|
||||
batch = []
|
||||
seen = 0
|
||||
new_before = conn.total_changes
|
||||
|
||||
# events=("start",) once to grab the root, then "end" for everything else.
|
||||
context = ET.iterparse(xml_path, events=("start", "end"))
|
||||
_, root = next(context)
|
||||
|
||||
for event, elem in context:
|
||||
if event != "end":
|
||||
continue
|
||||
tag = elem.tag
|
||||
|
||||
if tag == "Record":
|
||||
a = elem.attrib
|
||||
batch.append((
|
||||
a.get("type"), a.get("sourceName"), a.get("sourceVersion"),
|
||||
a.get("device"), a.get("unit"), a.get("value"), _num(a.get("value")),
|
||||
a.get("startDate"), a.get("endDate"), a.get("creationDate"),
|
||||
))
|
||||
seen += 1
|
||||
if len(batch) >= BATCH:
|
||||
conn.executemany(insert_record, batch)
|
||||
batch.clear()
|
||||
conn.execute(
|
||||
"UPDATE import_status SET records_seen = ?, records_new = ?, current_type = ? WHERE id = 1",
|
||||
(seen, conn.total_changes - new_before, a.get("type")),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
elif tag == "Workout":
|
||||
a = elem.attrib
|
||||
# Older exports carried energy/distance as Workout attributes; newer
|
||||
# ones moved them into <WorkoutStatistics> children. Start from the
|
||||
# attributes (usually empty now) and let the children override.
|
||||
energy = _num(a.get("totalEnergyBurned"))
|
||||
energy_unit = a.get("totalEnergyBurnedUnit")
|
||||
distance = _num(a.get("totalDistance"))
|
||||
distance_unit = a.get("totalDistanceUnit")
|
||||
for ch in elem:
|
||||
if _local(ch.tag) != "WorkoutStatistics":
|
||||
continue
|
||||
stat_type = ch.get("type")
|
||||
total = _num(ch.get("sum"))
|
||||
if total is None:
|
||||
continue
|
||||
if stat_type == "HKQuantityTypeIdentifierActiveEnergyBurned":
|
||||
energy, energy_unit = total, ch.get("unit")
|
||||
elif stat_type in (
|
||||
"HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
"HKQuantityTypeIdentifierDistanceCycling",
|
||||
"HKQuantityTypeIdentifierDistanceSwimming",
|
||||
):
|
||||
distance, distance_unit = total, ch.get("unit")
|
||||
# OR REPLACE (not IGNORE) so re-importing refreshes a workout that
|
||||
# was stored before this fix and had no energy/distance.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO workouts (activity_type, duration, duration_unit, "
|
||||
"total_distance, distance_unit, total_energy, energy_unit, start_date, "
|
||||
"end_date, source_name) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(a.get("workoutActivityType"), _num(a.get("duration")), a.get("durationUnit"),
|
||||
distance, distance_unit, energy, energy_unit,
|
||||
a.get("startDate"), a.get("endDate"), a.get("sourceName")),
|
||||
)
|
||||
|
||||
elif tag == "ActivitySummary":
|
||||
# Apple has renamed some of these attributes across iOS versions;
|
||||
# .get() just returns None for the ones a given export doesn't carry.
|
||||
a = elem.attrib
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO activity_summary (date, active_energy, active_energy_goal, "
|
||||
"move_time, move_time_goal, exercise_time, exercise_time_goal, stand_hours, "
|
||||
"stand_hours_goal) VALUES (?,?,?,?,?,?,?,?,?)",
|
||||
(a.get("dateComponents"), _num(a.get("activeEnergyBurned")),
|
||||
_num(a.get("activeEnergyBurnedGoal")), _num(a.get("appleMoveTime")),
|
||||
_num(a.get("appleMoveTimeGoal")), _num(a.get("appleExerciseTime")),
|
||||
_num(a.get("appleExerciseTimeGoal")), _num(a.get("appleStandHours")),
|
||||
_num(a.get("appleStandHoursGoal"))),
|
||||
)
|
||||
|
||||
else:
|
||||
# A child element closed (MetadataEntry, WorkoutEvent, and friends).
|
||||
# Skip it, and leave the root alone so we don't drop the parent we're
|
||||
# still inside.
|
||||
continue
|
||||
|
||||
# A top-level element is stored. Nothing is half-read at this point, so
|
||||
# wiping the root frees everything parsed so far.
|
||||
root.clear()
|
||||
|
||||
if batch:
|
||||
conn.executemany(insert_record, batch)
|
||||
|
||||
conn.execute(
|
||||
"UPDATE import_status SET state = 'done', records_seen = ?, records_new = ?, "
|
||||
"current_type = NULL, finished_at = ? WHERE id = 1",
|
||||
(seen, conn.total_changes - new_before, _now()),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------- extras: GPS routes + ECG (only present in the zip) ----------
|
||||
|
||||
def _local(tag):
|
||||
# Strip the XML namespace: "{http://...}trkpt" -> "trkpt".
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
|
||||
def parse_extras(zip_path, db_path):
|
||||
"""Pull the workout-routes/*.gpx and electrocardiograms/*.csv files out of
|
||||
the export zip. Both dedup on filename, so re-importing skips what's there."""
|
||||
if not zipfile.is_zipfile(zip_path):
|
||||
return
|
||||
conn = sqlite3.connect(db_path, timeout=60)
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as z:
|
||||
for name in z.namelist():
|
||||
low = name.lower()
|
||||
if low.endswith(".gpx"):
|
||||
_gpx(conn, name.rsplit("/", 1)[-1], z.read(name))
|
||||
elif low.endswith(".csv") and "electrocardiogram" in low:
|
||||
_ecg(conn, name.rsplit("/", 1)[-1], z.read(name).decode("utf-8", "replace"))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _gpx(conn, filename, data):
|
||||
try:
|
||||
root = ET.fromstring(data)
|
||||
except ET.ParseError:
|
||||
return
|
||||
points = []
|
||||
for el in root.iter():
|
||||
if _local(el.tag) != "trkpt":
|
||||
continue
|
||||
lat, lon = el.get("lat"), el.get("lon")
|
||||
ele = t = None
|
||||
for ch in el:
|
||||
if _local(ch.tag) == "ele":
|
||||
ele = ch.text
|
||||
elif _local(ch.tag) == "time":
|
||||
t = ch.text
|
||||
points.append((_num(lat), _num(lon), _num(ele), t))
|
||||
if not points:
|
||||
return
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO routes (filename, start_date, point_count) VALUES (?, ?, ?)",
|
||||
(filename, points[0][3], len(points)),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return # already imported
|
||||
rid = cur.lastrowid
|
||||
conn.executemany(
|
||||
"INSERT INTO route_points (route_id, lat, lon, ele, t) VALUES (?, ?, ?, ?, ?)",
|
||||
[(rid, p[0], p[1], p[2], p[3]) for p in points],
|
||||
)
|
||||
|
||||
|
||||
def _ecg(conn, filename, text):
|
||||
# An Apple ECG csv is a few "key,value" header lines then one voltage sample
|
||||
# per line. Locale varies (headers and even the decimal comma), so we treat
|
||||
# any line that parses as a number as a sample and everything else as header.
|
||||
samples = []
|
||||
meta = {}
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
samples.append(float(line.replace(",", ".")))
|
||||
continue
|
||||
except ValueError:
|
||||
pass
|
||||
if "," in line:
|
||||
k, v = line.split(",", 1)
|
||||
meta[k.strip().lower()] = v.strip()
|
||||
if not samples:
|
||||
return
|
||||
|
||||
recorded = classification = unit = None
|
||||
rate = None
|
||||
for k, v in meta.items():
|
||||
if recorded is None and ("recorded" in k or "date" in k or "dato" in k):
|
||||
recorded = v
|
||||
if classification is None and ("classif" in k or "klassif" in k):
|
||||
classification = v
|
||||
if rate is None and ("sample" in k or "rate" in k or "frekvens" in k):
|
||||
m = re.search(r"[\d.]+", v)
|
||||
rate = float(m.group(0)) if m else None
|
||||
if unit is None and ("unit" in k or "enhet" in k):
|
||||
unit = v
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO ecg (filename, recorded_date, classification, sample_rate, "
|
||||
"duration_s, unit) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(filename, recorded, classification, rate,
|
||||
(len(samples) / rate) if rate else None, unit),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
return
|
||||
eid = cur.lastrowid
|
||||
conn.executemany(
|
||||
"INSERT INTO ecg_samples (ecg_id, idx, uv) VALUES (?, ?, ?)",
|
||||
[(eid, i, s) for i, s in enumerate(samples)],
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
Flask>=3.0
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Background watcher for the inbox folder.
|
||||
|
||||
Every AHDX_SCAN_INTERVAL seconds it looks in data/inbox/ for a new export.zip or
|
||||
export.xml, merges it into the active database, and moves the file to
|
||||
data/inbox/done/ so it isn't picked up again. Drop exports there by hand or point
|
||||
a synced folder at it, and imports happen without anyone clicking a button.
|
||||
|
||||
Set AHDX_SCAN_INTERVAL to 0 to turn the watcher off.
|
||||
"""
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
import db
|
||||
import jobs
|
||||
|
||||
DEFAULT_INTERVAL = 1800 # 30 minutes
|
||||
|
||||
|
||||
def start():
|
||||
interval = int(os.environ.get("AHDX_SCAN_INTERVAL", DEFAULT_INTERVAL))
|
||||
if interval <= 0:
|
||||
return
|
||||
threading.Thread(target=_loop, args=(interval,), daemon=True).start()
|
||||
|
||||
|
||||
def _loop(interval):
|
||||
while True:
|
||||
try:
|
||||
scan_once()
|
||||
except Exception:
|
||||
# A bad file or a locked database shouldn't kill the watcher; it'll
|
||||
# try again next tick.
|
||||
pass
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def scan_once():
|
||||
"""Import every pending file, oldest name first. Returns how many it took."""
|
||||
done = 0
|
||||
for path in _pending():
|
||||
if jobs.run_import(path, source="inbox:" + os.path.basename(path)):
|
||||
_move_to_done(path)
|
||||
done += 1
|
||||
return done
|
||||
|
||||
|
||||
def _pending():
|
||||
files = []
|
||||
for name in sorted(os.listdir(db.INBOX_DIR)):
|
||||
p = os.path.join(db.INBOX_DIR, name)
|
||||
if os.path.isfile(p) and name.lower().endswith((".zip", ".xml")):
|
||||
files.append(p)
|
||||
return files
|
||||
|
||||
|
||||
def _move_to_done(path):
|
||||
name = os.path.basename(path)
|
||||
dest = os.path.join(db.INBOX_DONE, name)
|
||||
# Don't clobber an earlier file of the same name.
|
||||
n = 1
|
||||
stem, ext = os.path.splitext(name)
|
||||
while os.path.exists(dest):
|
||||
dest = os.path.join(db.INBOX_DONE, f"{stem}-{n}{ext}")
|
||||
n += 1
|
||||
os.replace(path, dest)
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
-- Schema for one Apple Health database.
|
||||
--
|
||||
-- Apple's export.xml is flat: a big pile of <Record> rows plus some <Workout>
|
||||
-- and <ActivitySummary> rows. We keep that shape instead of splitting the
|
||||
-- hundreds of health "types" into their own tables, because Apple adds new
|
||||
-- types with every iOS release and we don't want a migration each time.
|
||||
--
|
||||
-- Records have no ID from Apple, so we build our own uniqueness key and use
|
||||
-- INSERT OR IGNORE on import. That way re-scanning the same export is a no-op
|
||||
-- and a newer export only adds the rows we haven't seen.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL, -- e.g. HKQuantityTypeIdentifierHeartRate
|
||||
source_name TEXT,
|
||||
source_version TEXT,
|
||||
device TEXT,
|
||||
unit TEXT,
|
||||
value TEXT, -- raw value straight from the file
|
||||
value_num REAL, -- the same value as a number, or NULL if it isn't one
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
creation_date TEXT
|
||||
);
|
||||
|
||||
-- The natural key. COALESCE keeps NULLs from slipping past the uniqueness check
|
||||
-- (in SQLite two NULLs are treated as distinct, which would let duplicates in).
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_records ON records (
|
||||
type,
|
||||
COALESCE(start_date, ''),
|
||||
COALESCE(end_date, ''),
|
||||
COALESCE(value, ''),
|
||||
COALESCE(source_name, '')
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_records_type_start ON records (type, start_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_type TEXT,
|
||||
duration REAL,
|
||||
duration_unit TEXT,
|
||||
total_distance REAL,
|
||||
distance_unit TEXT,
|
||||
total_energy REAL,
|
||||
energy_unit TEXT,
|
||||
start_date TEXT,
|
||||
end_date TEXT,
|
||||
source_name TEXT
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_workouts ON workouts (
|
||||
COALESCE(activity_type, ''),
|
||||
COALESCE(start_date, ''),
|
||||
COALESCE(end_date, ''),
|
||||
COALESCE(source_name, '')
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activity_summary (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT UNIQUE, -- one summary per day
|
||||
active_energy REAL,
|
||||
active_energy_goal REAL,
|
||||
move_time REAL,
|
||||
move_time_goal REAL,
|
||||
exercise_time REAL,
|
||||
exercise_time_goal REAL,
|
||||
stand_hours REAL,
|
||||
stand_hours_goal REAL
|
||||
);
|
||||
|
||||
-- GPS tracks from outdoor workouts (the workout-routes/*.gpx files in the zip).
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT UNIQUE,
|
||||
start_date TEXT,
|
||||
point_count INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS route_points (
|
||||
route_id INTEGER NOT NULL REFERENCES routes(id),
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
ele REAL,
|
||||
t TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_route_points ON route_points(route_id);
|
||||
|
||||
-- ECG readings (the electrocardiograms/*.csv files). Each is a short waveform
|
||||
-- plus some header lines; we keep the metadata we can read and all the samples.
|
||||
CREATE TABLE IF NOT EXISTS ecg (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT UNIQUE,
|
||||
recorded_date TEXT,
|
||||
classification TEXT,
|
||||
sample_rate REAL,
|
||||
duration_s REAL,
|
||||
unit TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ecg_samples (
|
||||
ecg_id INTEGER NOT NULL REFERENCES ecg(id),
|
||||
idx INTEGER,
|
||||
uv REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ecg_samples ON ecg_samples(ecg_id);
|
||||
|
||||
-- One row, updated while an import runs so the web page can show progress.
|
||||
CREATE TABLE IF NOT EXISTS import_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
state TEXT, -- idle | running | done | error
|
||||
records_seen INTEGER DEFAULT 0, -- rows read from the file
|
||||
records_new INTEGER DEFAULT 0, -- rows that were actually new
|
||||
current_type TEXT,
|
||||
source TEXT, -- what triggered it: upload, inbox filename, or ingest
|
||||
message TEXT,
|
||||
started_at TEXT,
|
||||
finished_at TEXT
|
||||
);
|
||||
INSERT OR IGNORE INTO import_status (id, state) VALUES (1, 'idle');
|
||||
@@ -0,0 +1,159 @@
|
||||
/* One hand-written stylesheet, no framework. Plain and legible, works the same
|
||||
in Docker with the machine offline. */
|
||||
|
||||
/* Light by default. Dark follows the OS setting, and the toggle in the header
|
||||
overrides it either way (stored in localStorage as data-theme on <html>). */
|
||||
:root {
|
||||
--bg: #f5f5f4;
|
||||
--panel: #ffffff;
|
||||
--ink: #1c1c1e;
|
||||
--muted: #6b7280;
|
||||
--line: #e2e2e0;
|
||||
--accent: #0a84ff; /* the Apple Health blue, roughly */
|
||||
--danger: #c0392b;
|
||||
--ok: #2e7d32;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #16171a;
|
||||
--panel: #1e2024;
|
||||
--ink: #e8e8ea;
|
||||
--muted: #9aa0a8;
|
||||
--line: #2c2f36;
|
||||
--accent: #4a9eff;
|
||||
--danger: #ff6b5a;
|
||||
--ok: #5cba5c;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #16171a;
|
||||
--panel: #1e2024;
|
||||
--ink: #e8e8ea;
|
||||
--muted: #9aa0a8;
|
||||
--line: #2c2f36;
|
||||
--accent: #4a9eff;
|
||||
--danger: #ff6b5a;
|
||||
--ok: #5cba5c;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font: 15px/1.5 -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.brand { font-weight: 700; }
|
||||
.brand span { display: block; font-weight: 400; font-size: 12px; color: var(--muted); }
|
||||
|
||||
nav { display: flex; gap: 16px; }
|
||||
nav a { text-decoration: none; color: var(--ink); }
|
||||
nav a:hover { color: var(--accent); }
|
||||
|
||||
.db-picker { margin-left: auto; color: var(--muted); font-size: 13px; }
|
||||
.db-picker strong { color: var(--ink); }
|
||||
|
||||
main { max-width: 960px; margin: 24px auto; padding: 0 20px; }
|
||||
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 17px; margin-top: 0; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.err { color: var(--danger); }
|
||||
code { background: rgba(128,128,128,0.18); padding: 1px 5px; border-radius: 4px; font-size: 13px; }
|
||||
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border: 1px solid var(--line);
|
||||
padding: 4px 9px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.flash { padding: 10px 14px; border-radius: 8px; margin: 10px 0; }
|
||||
.flash.success { background: #e6f4ea; color: var(--ok); }
|
||||
.flash.error { background: #fdecea; color: var(--danger); }
|
||||
|
||||
button, .btn {
|
||||
font: inherit;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
button:hover, .btn:hover { opacity: 0.9; }
|
||||
button.danger, .btn.danger { background: var(--danger); border-color: var(--danger); }
|
||||
.btn-sm { padding: 5px 11px; font-size: 13px; border-radius: 7px; }
|
||||
|
||||
/* Subtle button for secondary actions (Rename, Make active). */
|
||||
button.secondary {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
button.secondary:hover { color: var(--accent); border-color: var(--accent); opacity: 1; }
|
||||
|
||||
/* One tidy row of actions per table row. */
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.actions form { display: inline-flex; gap: 6px; align-items: center; margin: 0; }
|
||||
.actions input[type=text] { width: 116px; margin: 0; padding: 5px 9px; }
|
||||
|
||||
input[type=file], input[type=text], input[type=date], input[type=password], select {
|
||||
font: inherit;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.filters { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.filters label { color: var(--muted); }
|
||||
|
||||
.tiles { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.tile {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 20px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.tile .n { display: block; font-size: 26px; font-weight: 700; }
|
||||
.tile .l { color: var(--muted); font-size: 13px; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin: 12px 0; background: var(--panel); }
|
||||
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
th { font-size: 13px; color: var(--muted); }
|
||||
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.badge {
|
||||
background: var(--ok); color: #fff; font-size: 11px;
|
||||
padding: 2px 7px; border-radius: 20px;
|
||||
}
|
||||
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -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