AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT)

This commit is contained in:
Steffen Skui
2026-07-21 21:23:34 +02:00
commit 850c13777c
34 changed files with 5108 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
data/
.git/
__pycache__/
*.pyc
.env
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+21
View File
@@ -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.
+142
View File
@@ -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:
![Dashboard — Ola](docs/screenshots/dashboard-ola.png)
![Dashboard — Kari](docs/screenshots/dashboard-kari.png)
## 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 (0100), 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).
+481
View File
@@ -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")))
+730
View File
@@ -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 0100 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
+51
View File
@@ -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
View File
@@ -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.
+9
View 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
+17
View File
@@ -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
+68
View File
@@ -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()
+273
View File
@@ -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)],
)
+1
View File
@@ -0,0 +1 @@
Flask>=3.0
+67
View File
@@ -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
View File
@@ -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');
+159
View File
@@ -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;
}
+661
View File
@@ -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;
}
}
File diff suppressed because one or more lines are too long
+42
View File
@@ -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>
+54
View File
@@ -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>
+39
View File
@@ -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 %}
+50
View File
@@ -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 %}
+69
View File
@@ -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&amp;type=HKQuantityTypeIdentifierStepCount&amp;agg=sum</code></p>
</div>
{% endblock %}
+22
View File
@@ -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 %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}ECG — AHDX{% endblock %}
{% block body %}
<a href="{{ url_for('ecg') }}">&larr; 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 %}
+54
View File
@@ -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) +
" &nbsp; <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 %}
+11
View File
@@ -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 }}
&nbsp; <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 %}
+34
View File
@@ -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') }}">&larr; 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: "&copy; 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 %}
+20
View File
@@ -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 %}
+64
View File
@@ -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 %}