commit 850c13777c74a254649133d6ef8919fa4bf7aa1f Author: Steffen Skui Date: Tue Jul 21 21:23:34 2026 +0200 AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8d23df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +data/ +.git/ +__pycache__/ +*.pyc +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a69624e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..185e965 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d3b4de5 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f94c06c --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..272c558 --- /dev/null +++ b/README.md @@ -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 ahdx +cd ahdx +docker compose up -d --build +``` + +That starts two things: **AHDX** on http://localhost:8088 and **Grafana** on +http://localhost:3000. Grafana waits for AHDX to be healthy before it starts, so +the first boot takes a minute. + +Open http://localhost:8088. On the first visit it asks you to **set a password**. +That password is stored salted in AHDX, and it also becomes the Grafana admin +password (user `admin`), so you set it once and it works for both. + +Your data lives in `./data` next to the compose file. Blow away the containers +whenever you want; the databases stay. + +## Get your data out of the iPhone + +1. Open **Health**, tap your profile picture, choose **Export All Health Data**. +2. You get an `export.zip`. Send it to your computer. +3. In AHDX, go to **Import** and drop the zip in. Big exports take a few minutes; + the page shows a running count. + +Loading a newer export later only adds what's new, so re-exporting every month +doesn't make duplicates. + +Two ways to keep it fresh without clicking, both free: + +- **Watched folder** — drop an export into `data/inbox/` and AHDX imports it on a + timer (every 30 min by default), then moves it to `data/inbox/done/`. +- **Push from the phone** — a free iOS **Shortcut** can POST recent metrics to + `/ingest` on a schedule. See the steps further down. + +Apple has no free server-side API, so a fully automatic *full-history* export +isn't possible — that part stays a manual tap. Automatic *recent* data works fine +through the Shortcut. + +## More than one person + +Each person is their own database. On the **Databases** page you can create, +rename, switch, and delete them, and see each one's size and record counts. +Imports go into whichever database is active. + +In Grafana, the **Person** dropdown up top switches the whole dashboard between +them. + +## The Grafana dashboard + +It ships already set up — the AHDX datasource and the "Apple Health" dashboard +are provisioned from files, so there's nothing to import. Panels: + +- Latest weight, resting HR, VO₂ max, steps, active energy, exercise minutes. +- Steps, heart rate, weight, and energy over time. +- **Sleep**: a nightly score (0–100), stage breakdown (deep / core / REM / awake), + and the score trend. The score is computed from sleep duration, efficiency, and + how much deep/REM you got — Apple doesn't provide one. +- **Workout route** on an OpenStreetMap map, with a box under it showing the + activity, distance, duration, calories, elevation gain, and average speed. + +## The read API + +AHDX exposes a small read API at `/api` (open it in a browser for the menu). It's +what Grafana reads, and you can point your own scripts at it. Pick a person with +`?db=Name`: + +``` +/api/daily?db=Ola&type=HKQuantityTypeIdentifierStepCount&agg=sum +/api/sleep?db=Ola +/api/routes?db=Ola +``` + +Set `AHDX_API_KEY` in the compose file if you want to require an `X-Api-Key` +header on it. + +## Building the iOS Shortcut (the auto-trickle) + +This sends today's step samples; copy the pattern for other metrics. + +1. New Shortcut. Add **Find Health Samples** — Type *Steps*, sorted by End Date, + filtered to "within the last 1 day". +2. Add **Repeat with Each**. Inside it, build a **Dictionary** with keys `type` + (`HKQuantityTypeIdentifierStepCount`), `value` (the item's Value), `unit` + (`count`), `start_date`, `end_date`, `source_name`, then **Add to Variable** + `rows`. +3. After the loop, a **Dictionary** with one key `records` set to `rows`. +4. **Get Contents of URL** — POST to `http://: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). diff --git a/app.py b/app.py new file mode 100644 index 0000000..0692d8a --- /dev/null +++ b/app.py @@ -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/") +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/") +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//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//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//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= 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"))) diff --git a/db.py b/db.py new file mode 100644 index 0000000..34eb773 --- /dev/null +++ b/db.py @@ -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/.db one per health dataset (per person, per year, whatever) + +Keeping them as separate files means you can hand someone a single .db and it's +their whole dataset, nothing else tangled in. Everything here is plain stdlib +sqlite3, no ORM. +""" +import os +import re +import sqlite3 +from contextlib import contextmanager + +DATA_DIR = os.environ.get("AHDX_DATA", "/data") +DB_DIR = os.path.join(DATA_DIR, "databases") +UPLOAD_DIR = os.path.join(DATA_DIR, "uploads") +INBOX_DIR = os.path.join(DATA_DIR, "inbox") +INBOX_DONE = os.path.join(INBOX_DIR, "done") +REGISTRY = os.path.join(DATA_DIR, "registry.db") +SCHEMA = os.path.join(os.path.dirname(__file__), "schema.sql") + + +def _ensure_dirs(): + for d in (DATA_DIR, DB_DIR, UPLOAD_DIR, INBOX_DIR, INBOX_DONE): + os.makedirs(d, exist_ok=True) + + +@contextmanager +def _open(path): + conn = sqlite3.connect(path, timeout=30) + conn.row_factory = sqlite3.Row + try: + yield conn + conn.commit() + finally: + conn.close() + + +def init(): + _ensure_dirs() + with _open(REGISTRY) as c: + c.execute( + """CREATE TABLE IF NOT EXISTS databases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + is_active INTEGER NOT NULL DEFAULT 0 + )""" + ) + # Small app-wide key/value store: the GUI password hash, the session + # secret, anything that isn't health data. + c.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)") + # A fresh install has nothing, so give it one database to land data in. + if not list_databases(): + create_database("My Health") + if get_active() is None: + set_active(list_databases()[0]["id"]) + + # Re-apply the schema to every database on startup. It's all CREATE ... IF + # NOT EXISTS, so this is a no-op on current databases and quietly adds new + # tables (routes, ecg, ...) to ones made by an older version. + for row in list_databases(): + apply_schema(os.path.join(DB_DIR, row["filename"])) + + +# ---------- settings + GUI password ---------- + +def get_setting(key, default=None): + with _open(REGISTRY) as c: + row = c.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone() + return row["value"] if row else default + + +def set_setting(key, value): + with _open(REGISTRY) as c: + c.execute( + "INSERT INTO settings (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + +def has_password(): + return get_setting("password_hash") is not None + + +def set_password(pw): + from werkzeug.security import generate_password_hash + # pbkdf2 rather than the newer scrypt default, so it hashes on any Python + # build (scrypt needs an OpenSSL that isn't always compiled in). + set_setting("password_hash", generate_password_hash(pw, method="pbkdf2:sha256")) + + +def check_password(pw): + from werkzeug.security import check_password_hash + h = get_setting("password_hash") + return bool(h and check_password_hash(h, pw)) + + +# ---------- the database registry ---------- + +def list_databases(): + with _open(REGISTRY) as c: + return c.execute("SELECT * FROM databases ORDER BY created_at, id").fetchall() + + +def get_active(): + with _open(REGISTRY) as c: + return c.execute("SELECT * FROM databases WHERE is_active = 1").fetchone() + + +def set_active(db_id): + with _open(REGISTRY) as c: + c.execute("UPDATE databases SET is_active = 0") + c.execute("UPDATE databases SET is_active = 1 WHERE id = ?", (db_id,)) + + +def _slug(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "db" + + +def create_database(name): + name = name.strip() + if not name: + raise ValueError("A database needs a name.") + with _open(REGISTRY) as c: + if c.execute("SELECT 1 FROM databases WHERE name = ?", (name,)).fetchone(): + raise ValueError("A database with that name already exists.") + + # Pick a filename that doesn't collide with one already on disk. + base = _slug(name) + filename = base + ".db" + n = 1 + while os.path.exists(os.path.join(DB_DIR, filename)): + filename = f"{base}-{n}.db" + n += 1 + + apply_schema(os.path.join(DB_DIR, filename)) + with _open(REGISTRY) as c: + cur = c.execute( + "INSERT INTO databases (name, filename) VALUES (?, ?)", (name, filename) + ) + new_id = cur.lastrowid + set_active(new_id) + return new_id + + +def _human_size(n): + size = float(n) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024 or unit == "GB": + return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}" + size /= 1024 + + +def database_overview(): + """The registry rows plus, for each database, its file size and a few + counts. Opens each .db file, so it's a page-load query, not a hot path.""" + out = [] + for row in list_databases(): + path = os.path.join(DB_DIR, row["filename"]) + info = { + "id": row["id"], "name": row["name"], "filename": row["filename"], + "created_at": row["created_at"], "is_active": row["is_active"], + "size": os.path.getsize(path) if os.path.exists(path) else 0, + "records": 0, "workouts": 0, "last_import": None, + } + info["size_h"] = _human_size(info["size"]) + try: + with _open(path) as c: + info["records"] = c.execute("SELECT COUNT(*) AS n FROM records").fetchone()["n"] + info["workouts"] = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"] + st = c.execute("SELECT finished_at FROM import_status WHERE id = 1").fetchone() + info["last_import"] = st["finished_at"] if st else None + except sqlite3.Error: + pass + out.append(info) + return out + + +def rename_database(db_id, new_name): + new_name = new_name.strip() + if not new_name: + raise ValueError("A database needs a name.") + with _open(REGISTRY) as c: + clash = c.execute( + "SELECT 1 FROM databases WHERE name = ? AND id <> ?", (new_name, db_id) + ).fetchone() + if clash: + raise ValueError("A database with that name already exists.") + c.execute("UPDATE databases SET name = ? WHERE id = ?", (new_name, db_id)) + + +def delete_database(db_id): + with _open(REGISTRY) as c: + row = c.execute("SELECT * FROM databases WHERE id = ?", (db_id,)).fetchone() + if not row: + return + if len(list_databases()) <= 1: + raise ValueError("This is the only database, so there's nothing to switch to. Create another first.") + + path = os.path.join(DB_DIR, row["filename"]) + if os.path.exists(path): + os.remove(path) + with _open(REGISTRY) as c: + c.execute("DELETE FROM databases WHERE id = ?", (db_id,)) + if row["is_active"]: + set_active(list_databases()[0]["id"]) + + +def apply_schema(path): + conn = sqlite3.connect(path) + try: + with open(SCHEMA, encoding="utf-8") as f: + conn.executescript(f.read()) + finally: + conn.close() + + +# ---------- the active health database ---------- + +def active_path(): + a = get_active() + return os.path.join(DB_DIR, a["filename"]) + + +@contextmanager +def health(): + with _open(active_path()) as c: + yield c + + +def resolve_db(name): + """Path for a database by name or filename. None (or an unresolved Grafana + variable like "${db}") means the active one; an unknown name raises KeyError + so the API can answer 404.""" + if not name or name.startswith("$"): + return active_path() + with _open(REGISTRY) as c: + row = c.execute( + "SELECT filename FROM databases WHERE lower(name) = lower(?) OR lower(filename) = lower(?)", + (name, name), + ).fetchone() + if not row: + raise KeyError(name) + return os.path.join(DB_DIR, row["filename"]) + + +@contextmanager +def open_db(name=None): + with _open(resolve_db(name)) as c: + yield c + + +# ---------- read API queries (pick the database by name) ---------- + +def api_types(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records GROUP BY type ORDER BY n DESC" + )] + + +def api_records(name, type_, start, end, limit): + q = ("SELECT type, value, value_num, unit, source_name, start_date, end_date " + "FROM records WHERE type = ?") + params = [type_] + if start: + q += " AND start_date >= ?" + params.append(start) + if end: + q += " AND start_date <= ?" + params.append(end) + q += " ORDER BY start_date LIMIT ?" + params.append(limit) + with open_db(name) as c: + return [dict(r) for r in c.execute(q, params)] + + +def api_daily(name, type_, agg, start, end): + """One value per day, shaped as [{"time": "...", "value": n}] so a Grafana + JSON/Infinity datasource can read it straight.""" + expr = _AGG.get(agg, _AGG["avg"]) + where = "type = ?" + params = [type_] + if agg != "count": + where += " AND value_num IS NOT NULL" + if start: + where += " AND start_date >= ?" + params.append(start) + if end: + where += " AND start_date <= ?" + params.append(end) + q = (f"SELECT substr(start_date, 1, 10) AS time, {expr} AS value " + f"FROM records WHERE {where} GROUP BY time ORDER BY time") + with open_db(name) as c: + return [dict(r) for r in c.execute(q, params) if r["time"]] + + +def api_workouts(name=None): + with open_db(name) as c: + rows = [dict(r) for r in c.execute( + "SELECT activity_type, duration, duration_unit, total_distance, distance_unit, " + "total_energy, energy_unit, start_date, end_date, source_name " + "FROM workouts ORDER BY start_date DESC" + )] + for r in rows: + if r["activity_type"]: # "HKWorkoutActivityTypeWalking" -> "Walking" + r["activity_type"] = r["activity_type"].replace("HKWorkoutActivityType", "") + return rows + + +def api_activity(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT date AS time, active_energy, active_energy_goal, move_time, " + "exercise_time, stand_hours FROM activity_summary ORDER BY date" + )] + + +def _parse_dt(s): + from datetime import datetime + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z") # "... +0200" + except (ValueError, TypeError): + return None + + +def api_sleep(name=None, start=None, end=None): + """One row per night with hours per stage and a 0–100 score. Apple has no + native sleep score, so we make one from duration, efficiency, and (when the + watch recorded stages) how much deep/REM sleep there was. Older data only + has "asleep vs in bed", so the score falls back to duration + efficiency.""" + from collections import defaultdict + from datetime import timedelta + q = ("SELECT value, start_date, end_date FROM records " + "WHERE type = 'HKCategoryTypeIdentifierSleepAnalysis'") + params = [] + if start: + q += " AND start_date >= ?"; params.append(start) + if end: + q += " AND start_date <= ?"; params.append(end) + with open_db(name) as c: + rows = c.execute(q, params).fetchall() + + nights = defaultdict(lambda: dict(in_bed=0.0, awake=0.0, deep=0.0, rem=0.0, core=0.0, unspec=0.0)) + for r in rows: + s, e = _parse_dt(r["start_date"]), _parse_dt(r["end_date"]) + if not s or not e: + continue + hours = (e - s).total_seconds() / 3600.0 + if hours <= 0 or hours > 16: + continue + # Assign to a night by shifting 18h back, so an evening + the morning + # after it land on the same date. + night = (s - timedelta(hours=18)).date().isoformat() + v = r["value"] or "" + if v.endswith("InBed"): + nights[night]["in_bed"] += hours + elif v.endswith("Awake"): + nights[night]["awake"] += hours + elif v.endswith("AsleepDeep"): + nights[night]["deep"] += hours + elif v.endswith("AsleepREM"): + nights[night]["rem"] += hours + elif v.endswith("AsleepCore"): + nights[night]["core"] += hours + elif "Asleep" in v: + nights[night]["unspec"] += hours + + out = [] + for night in sorted(nights): + d = nights[night] + asleep = d["deep"] + d["rem"] + d["core"] + d["unspec"] + in_bed = d["in_bed"] if d["in_bed"] > 0 else asleep + d["awake"] + has_stages = (d["deep"] + d["rem"] + d["core"]) > 0 + dur = min(asleep / 8.0, 1.0) # 8h asleep = full marks + eff = min(asleep / in_bed, 1.0) if in_bed else dur + if has_stages: + deep_s = min((d["deep"] / asleep) / 0.16, 1.0) if asleep else 0 + rem_s = min((d["rem"] / asleep) / 0.22, 1.0) if asleep else 0 + score = 100 * (0.40 * dur + 0.25 * eff + 0.175 * deep_s + 0.175 * rem_s) + else: + score = 100 * (0.60 * dur + 0.40 * eff) + out.append({ + "time": night, "asleep_h": round(asleep, 2), "in_bed_h": round(in_bed, 2), + "deep_h": round(d["deep"], 2), "rem_h": round(d["rem"], 2), + "core_h": round(d["core"], 2), "unspecified_h": round(d["unspec"], 2), + "awake_h": round(d["awake"], 2), "efficiency": round(100 * eff), + "score": round(score), + }) + return out + + +def api_routes(name=None): + with open_db(name) as c: + return [dict(r) for r in c.execute( + "SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC" + )] + + +def _haversine_km(a_lat, a_lon, b_lat, b_lon): + import math + r = 6371.0 + dlat, dlon = math.radians(b_lat - a_lat), math.radians(b_lon - a_lon) + h = (math.sin(dlat / 2) ** 2 + + math.cos(math.radians(a_lat)) * math.cos(math.radians(b_lat)) * math.sin(dlon / 2) ** 2) + return 2 * r * math.asin(math.sqrt(h)) + + +def api_route_stats(name, route_id=None): + """Distance, duration, elevation gain and average speed for one route, + worked out from its points. No id -> the latest route (matches the map).""" + from datetime import datetime + with open_db(name) as c: + if not route_id: + latest = c.execute("SELECT id FROM routes ORDER BY start_date DESC LIMIT 1").fetchone() + if not latest: + return {} + route_id = latest["id"] + route = c.execute("SELECT filename, start_date FROM routes WHERE id = ?", (route_id,)).fetchone() + pts = c.execute( + "SELECT lat, lon, ele, t FROM route_points WHERE route_id = ? ORDER BY rowid", (route_id,) + ).fetchall() + workouts = c.execute( + "SELECT activity_type, total_energy, start_date FROM workouts").fetchall() + if not route or not pts: + return {} + + dist = 0.0 + gain = 0.0 + prev = prev_ele = None + for p in pts: + if p["lat"] is None or p["lon"] is None: + continue + if prev is not None: + dist += _haversine_km(prev[0], prev[1], p["lat"], p["lon"]) + prev = (p["lat"], p["lon"]) + if p["ele"] is not None: + if prev_ele is not None and p["ele"] > prev_ele: + gain += p["ele"] - prev_ele + prev_ele = p["ele"] + + def _t(s): + for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"): + try: + return datetime.strptime(s, fmt) + except (ValueError, TypeError): + pass + return None + + times = [t for t in (_t(p["t"]) for p in pts) if t] + dur_min = (times[-1] - times[0]).total_seconds() / 60.0 if len(times) >= 2 else None + speed = dist / (dur_min / 60.0) if dur_min else None + + # The GPX has no calories, so match this route to its workout by start time + # (within an hour) and borrow the workout's energy. Route timestamps are + # UTC ("...Z"); workout start dates carry a timezone offset. + from datetime import datetime, timezone + + def _utc(s): + for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S.%fZ"): + try: + return datetime.strptime(s, fmt).replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + pass + try: + return datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z").astimezone(timezone.utc) + except (ValueError, TypeError): + return None + + route_start = _utc(pts[0]["t"]) or _utc(route["start_date"]) + energy = activity = None + if route_start: + best = None + for w in workouts: + ws = _utc(w["start_date"]) + if not ws: + continue + diff = abs((ws - route_start).total_seconds()) + if diff < 3600 and (best is None or diff < best[0]): + best = (diff, w) + if best: + energy = best[1]["total_energy"] + activity = (best[1]["activity_type"] or "").replace("HKWorkoutActivityType", "") + + return { + "filename": route["filename"], "start_date": (route["start_date"] or "")[:10], + "activity": activity, "distance_km": round(dist, 2), "elevation_gain_m": round(gain), + "duration_min": round(dur_min, 1) if dur_min else None, + "avg_speed_kmh": round(speed, 1) if speed else None, + "energy_kcal": round(energy) if energy else None, "points": len(pts), + } + + +def api_route_points(name, route_id=None, max_points=800): + """A route's lat/lon, thinned so a map isn't handed 6,000 points. With no + route_id, use the most recent route, so a map always has something to draw + even before anyone picks one.""" + with open_db(name) as c: + if not route_id: + latest = c.execute( + "SELECT id FROM routes ORDER BY start_date DESC LIMIT 1" + ).fetchone() + if not latest: + return [] + route_id = latest["id"] + total = c.execute( + "SELECT COUNT(*) AS n FROM route_points WHERE route_id = ?", (route_id,) + ).fetchone()["n"] + step = max(1, total // max_points) + rows = c.execute( + "SELECT lat, lon FROM route_points WHERE route_id = ? AND rowid % ? = 0 ORDER BY rowid", + (route_id, step), + ).fetchall() + return [{"lat": r["lat"], "lon": r["lon"]} for r in rows if r["lat"] is not None] + + +def import_status(): + with health() as c: + return c.execute("SELECT * FROM import_status WHERE id = 1").fetchone() + + +def dashboard_stats(): + with health() as c: + totals = c.execute( + "SELECT COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last FROM records" + ).fetchone() + types = c.execute( + "SELECT type, COUNT(*) AS n, MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records GROUP BY type ORDER BY n DESC" + ).fetchall() + workouts = c.execute("SELECT COUNT(*) AS n FROM workouts").fetchone()["n"] + summaries = c.execute("SELECT COUNT(*) AS n FROM activity_summary").fetchone()["n"] + status = c.execute("SELECT * FROM import_status WHERE id = 1").fetchone() + return { + "totals": totals, "types": types, "workouts": workouts, + "summaries": summaries, "status": status, + } + + +def record_types(): + with health() as c: + return [r["type"] for r in c.execute("SELECT DISTINCT type FROM records ORDER BY type")] + + +def browse(type_=None, start=None, end=None, limit=500): + q = "SELECT type, source_name, unit, value, start_date, end_date FROM records WHERE 1 = 1" + params = [] + if type_: + q += " AND type = ?" + params.append(type_) + if start: + q += " AND start_date >= ?" + params.append(start) + if end: + q += " AND start_date <= ?" + params.append(end) + q += " ORDER BY start_date DESC LIMIT ?" + params.append(limit) + with health() as c: + return c.execute(q, params).fetchall() + + +def _fmt(num, raw): + if num is None: + return raw or "" + if abs(num - round(num)) < 1e-9: + return str(int(round(num))) + return f"{num:.1f}" + + +# The day lives in the first 10 chars of start_date ("2026-07-20 09:00 +0200"). +# SQLite's date() chokes on the trailing timezone, so we slice instead. +_AGG = { + "avg": "AVG(value_num)", "sum": "SUM(value_num)", + "min": "MIN(value_num)", "max": "MAX(value_num)", "count": "COUNT(*)", +} + + +def daily_rollup(type_, agg="avg", start=None, end=None): + """One value per day for a record type. agg picks how the day's samples are + combined: avg/min/max for things like heart rate, sum for step count and + energy, count for how many samples landed that day.""" + expr = _AGG.get(agg, _AGG["avg"]) + where = "type = ?" + params = [type_] + if agg != "count": + where += " AND value_num IS NOT NULL" + if start: + where += " AND start_date >= ?" + params.append(start) + if end: + where += " AND start_date <= ?" + params.append(end) + q = (f"SELECT substr(start_date, 1, 10) AS day, {expr} AS v " + f"FROM records WHERE {where} GROUP BY day ORDER BY day") + with health() as c: + return [(r["day"], r["v"]) for r in c.execute(q, params) if r["day"]] + + +def type_stats(type_): + with health() as c: + return c.execute( + "SELECT COUNT(*) AS n, AVG(value_num) AS avg, MIN(value_num) AS min, " + "MAX(value_num) AS max, SUM(value_num) AS sum, MAX(unit) AS unit, " + "MIN(start_date) AS first, MAX(start_date) AS last " + "FROM records WHERE type = ?", + (type_,), + ).fetchone() + + +# The handful of metrics worth showing at a glance, if the database has them. +_CARD_LATEST = [ + ("HKQuantityTypeIdentifierBodyMass", "Weight"), + ("HKQuantityTypeIdentifierRestingHeartRate", "Resting HR"), + ("HKQuantityTypeIdentifierHeartRate", "Heart rate"), + ("HKQuantityTypeIdentifierBodyMassIndex", "BMI"), + ("HKQuantityTypeIdentifierVO2Max", "VO2 max"), +] + + +def dashboard_cards(): + cards = [] + with health() as c: + # Steps summed over the most recent day that has any. + row = c.execute( + "SELECT substr(start_date, 1, 10) AS day, SUM(value_num) AS s " + "FROM records WHERE type = 'HKQuantityTypeIdentifierStepCount' " + "AND value_num IS NOT NULL GROUP BY day ORDER BY day DESC LIMIT 1" + ).fetchone() + if row and row["day"]: + cards.append({"label": "Steps", "value": str(int(row["s"])), + "unit": "", "when": row["day"]}) + for type_, label in _CARD_LATEST: + r = c.execute( + "SELECT value, value_num, unit, start_date FROM records " + "WHERE type = ? ORDER BY start_date DESC LIMIT 1", (type_,) + ).fetchone() + if r: + cards.append({"label": label, "value": _fmt(r["value_num"], r["value"]), + "unit": r["unit"] or "", "when": (r["start_date"] or "")[:10]}) + return cards + + +# ---------- routes + ECG ---------- + +def list_routes(): + with health() as c: + return c.execute( + "SELECT id, filename, start_date, point_count FROM routes ORDER BY start_date DESC" + ).fetchall() + + +def get_route(route_id): + with health() as c: + route = c.execute("SELECT * FROM routes WHERE id = ?", (route_id,)).fetchone() + pts = c.execute( + "SELECT lat, lon, ele FROM route_points WHERE route_id = ? ORDER BY rowid", + (route_id,), + ).fetchall() + return route, [(p["lat"], p["lon"]) for p in pts if p["lat"] is not None] + + +def list_ecg(): + with health() as c: + return c.execute( + "SELECT id, filename, recorded_date, classification, sample_rate, duration_s " + "FROM ecg ORDER BY recorded_date DESC" + ).fetchall() + + +def get_ecg(ecg_id, max_points=1200): + """Return the ECG row plus its waveform, thinned to at most max_points so the + browser isn't asked to draw 15,000 dots.""" + with health() as c: + row = c.execute("SELECT * FROM ecg WHERE id = ?", (ecg_id,)).fetchone() + total = c.execute( + "SELECT COUNT(*) AS n FROM ecg_samples WHERE ecg_id = ?", (ecg_id,) + ).fetchone()["n"] + step = max(1, total // max_points) + samples = [ + r["uv"] for r in c.execute( + "SELECT idx, uv FROM ecg_samples WHERE ecg_id = ? AND idx % ? = 0 ORDER BY idx", + (ecg_id, step), + ) + ] + return row, samples + + +def counts_extras(): + with health() as c: + r = c.execute("SELECT COUNT(*) AS n FROM routes").fetchone()["n"] + e = c.execute("SELECT COUNT(*) AS n FROM ecg").fetchone()["n"] + return {"routes": r, "ecg": e} + + +def ingest_records(rows): + """Merge a list of record dicts into the active database. Used by the push + endpoint. Returns how many were actually new. Same INSERT OR IGNORE dedup as + the XML import, so the phone can re-send overlapping windows safely.""" + cols = ("type", "source_name", "source_version", "device", "unit", + "value", "value_num", "start_date", "end_date", "creation_date") + tuples = [] + for r in rows: + value = r.get("value") + try: + value_num = float(value) + except (TypeError, ValueError): + value_num = None + tuples.append(( + r.get("type"), r.get("source_name"), r.get("source_version"), + r.get("device"), r.get("unit"), None if value is None else str(value), + value_num, r.get("start_date"), r.get("end_date"), r.get("creation_date"), + )) + with health() as c: + before = c.total_changes + c.executemany( + f"INSERT OR IGNORE INTO records ({','.join(cols)}) " + f"VALUES ({','.join('?' * len(cols))})", + tuples, + ) + return c.total_changes - before diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..913ea97 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..08064c6 --- /dev/null +++ b/docs/plan.md @@ -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 `` entries (heart rate, steps, weight, sleep, etc.), + `` entries, and `` 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. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..cdeae0c --- /dev/null +++ b/docs/screenshots/README.md @@ -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. diff --git a/grafana/dashboards/apple-health.json b/grafana/dashboards/apple-health.json new file mode 100644 index 0000000..e6d1734 --- /dev/null +++ b/grafana/dashboards/apple-health.json @@ -0,0 +1,1589 @@ +{ + "editable": true, + "id": null, + "panels": [ + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Steps", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierStepCount&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Steps", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 4, + "y": 0 + }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Resting HR", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierRestingHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Resting HR", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "masskg" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 8, + "y": 0 + }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Weight", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierBodyMass&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Weight", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#4ECDC4", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 12, + "y": 0 + }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "VO₂ max", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierVO2Max&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "VO₂ max", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "purple", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 16, + "y": 0 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Active energy", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierActiveEnergyBurned&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Active energy", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "orange", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 4, + "x": 20, + "y": 0 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Exercise min", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierAppleExerciseTime&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Exercise min", + "type": "stat" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "green", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 5 + }, + "id": 10, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Steps per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierStepCount&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Steps per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 5 + }, + "id": 11, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Active energy per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierActiveEnergyBurned&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Active energy per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "red", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 13 + }, + "id": 12, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Resting heart rate", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierRestingHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Resting heart rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "blue", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "always", + "spanNulls": true + }, + "unit": "masskg" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 13 + }, + "id": 13, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Weight", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierBodyMass&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Weight", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "orange", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 21 + }, + "id": 14, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Heart rate (daily avg)", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierHeartRate&agg=avg", + "url_options": { + "method": "GET" + } + } + ], + "title": "Heart rate (daily avg)", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "yellow", + "mode": "fixed" + }, + "custom": { + "drawStyle": "bars", + "fillOpacity": 14, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "showPoints": "never", + "spanNulls": true + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 21 + }, + "id": 15, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "value", + "text": "Exercise minutes per day", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/daily?db=${db}&type=HKQuantityTypeIdentifierAppleExerciseTime&agg=sum", + "url_options": { + "method": "GET" + } + } + ], + "title": "Exercise minutes per day", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 9, + "w": 24, + "x": 0, + "y": 59 + }, + "id": 20, + "options": { + "cellHeight": "sm", + "showHeader": true, + "sortBy": [ + { + "desc": true, + "displayName": "Start" + } + ] + }, + "targets": [ + { + "columns": [ + { + "selector": "start_date", + "text": "Start", + "type": "string" + }, + { + "selector": "activity_type", + "text": "Activity", + "type": "string" + }, + { + "selector": "total_distance", + "text": "Distance", + "type": "number" + }, + { + "selector": "total_energy", + "text": "Energy (kcal)", + "type": "number" + }, + { + "selector": "duration", + "text": "Duration (min)", + "type": "number" + }, + { + "selector": "source_name", + "text": "Source", + "type": "string" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "source": "url", + "type": "json", + "url": "/api/workouts?db=${db}", + "url_options": { + "method": "GET" + } + } + ], + "title": "Recent workouts", + "type": "table" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "max": 100, + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "orange", + "value": 60 + }, + { + "color": "green", + "value": 80 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 7, + "w": 6, + "x": 0, + "y": 29 + }, + "id": 30, + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "score", + "text": "Sleep score", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep score (last night)", + "type": "gauge" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "bars", + "fillOpacity": 80, + "lineWidth": 0, + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "h" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Deep" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "dark-blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Core" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "blue", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "REM" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "#4ECDC4", + "mode": "fixed" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Awake" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 7, + "w": 18, + "x": 6, + "y": 29 + }, + "id": 31, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "deep_h", + "text": "Deep", + "type": "number" + }, + { + "selector": "core_h", + "text": "Core", + "type": "number" + }, + { + "selector": "rem_h", + "text": "REM", + "type": "number" + }, + { + "selector": "awake_h", + "text": "Awake", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep stages per night (hours)", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "fixedColor": "purple", + "mode": "fixed" + }, + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineInterpolation": "smooth", + "lineWidth": 2, + "showPoints": "never" + }, + "max": 100, + "min": 0, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 6, + "w": 24, + "x": 0, + "y": 36 + }, + "id": 32, + "options": { + "legend": { + "showLegend": false + }, + "tooltip": { + "mode": "single" + } + }, + "targets": [ + { + "columns": [ + { + "selector": "time", + "text": "time", + "type": "timestamp" + }, + { + "selector": "score", + "text": "Sleep score", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "timeseries", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/sleep?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Sleep score over time", + "type": "timeseries" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": {}, + "overrides": [] + }, + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 42 + }, + "id": 33, + "options": { + "basemap": { + "config": { + "showLabels": true, + "theme": "auto" + }, + "name": "Basemap", + "tooltip": false, + "type": "carto" + }, + "controls": { + "mouseWheelZoom": true, + "showAttribution": true, + "showDebug": false, + "showMeasure": false, + "showScale": false, + "showZoom": true + }, + "layers": [ + { + "config": { + "showLegend": false, + "style": { + "color": { + "fixed": "blue" + }, + "opacity": 0.6, + "size": { + "fixed": 3, + "max": 8, + "min": 2 + }, + "symbol": { + "fixed": "img/icons/marker/circle.svg", + "mode": "fixed" + }, + "textConfig": { + "fontSize": 12, + "textAlign": "center", + "textBaseline": "middle" + } + } + }, + "filterData": { + "id": "byRefId", + "options": "A" + }, + "location": { + "latitude": "lat", + "longitude": "lon", + "mode": "coords" + }, + "name": "Layer 1", + "tooltip": false, + "type": "markers" + } + ], + "tooltip": { + "mode": "none" + }, + "view": { + "allLayers": true, + "id": "fit", + "lastOnly": false, + "lat": 60.9, + "layer": "Layer 1", + "lon": 10.9, + "padding": 10, + "zoom": 12 + } + }, + "targets": [ + { + "columns": [ + { + "selector": "lat", + "text": "lat", + "type": "number" + }, + { + "selector": "lon", + "text": "lon", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/route?db=${db}&id=${route}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Workout route (choose it in the Route dropdown up top)", + "type": "geomap" + }, + { + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "blue", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 5, + "w": 24, + "x": 0, + "y": 54 + }, + "id": 34, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "center", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "value_and_name" + }, + "targets": [ + { + "columns": [ + { + "selector": "activity", + "text": "Activity", + "type": "string" + }, + { + "selector": "distance_km", + "text": "Distance (km)", + "type": "number" + }, + { + "selector": "duration_min", + "text": "Duration (min)", + "type": "number" + }, + { + "selector": "energy_kcal", + "text": "Calories (kcal)", + "type": "number" + }, + { + "selector": "elevation_gain_m", + "text": "Elevation ↑ (m)", + "type": "number" + }, + { + "selector": "avg_speed_kmh", + "text": "Speed (km/h)", + "type": "number" + } + ], + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "format": "table", + "parser": "backend", + "refId": "A", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/route/stats?db=${db}&id=${route}", + "url_options": { + "data": "", + "method": "GET" + } + } + ], + "title": "Selected route", + "type": "stat" + } + ], + "refresh": "", + "schemaVersion": 39, + "tags": [ + "ahdx" + ], + "templating": { + "list": [ + { + "current": {}, + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "definition": "AHDX databases", + "hide": 0, + "includeAll": false, + "label": "Person", + "multi": false, + "name": "db", + "options": [], + "query": { + "infinityQuery": { + "columns": [ + { + "selector": "name", + "text": "", + "type": "string" + } + ], + "filters": [], + "format": "table", + "parser": "backend", + "refId": "variable", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/databases", + "url_options": { + "data": "", + "method": "GET" + } + }, + "query": "", + "queryType": "infinity" + }, + "refresh": 1, + "regex": "", + "sort": 1, + "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "ahdx-infinity" + }, + "definition": "AHDX routes", + "hide": 0, + "includeAll": false, + "label": "Route", + "multi": false, + "name": "route", + "options": [], + "query": { + "infinityQuery": { + "columns": [ + { + "selector": "filename", + "text": "__text", + "type": "string" + }, + { + "selector": "id", + "text": "__value", + "type": "string" + } + ], + "filters": [], + "format": "table", + "parser": "backend", + "refId": "variable", + "root_selector": "", + "source": "url", + "type": "json", + "url": "/api/routes?db=${db}", + "url_options": { + "data": "", + "method": "GET" + } + }, + "query": "", + "queryType": "infinity" + }, + "refresh": 2, + "regex": "", + "sort": 0, + "type": "query" + } + ] + }, + "time": { + "from": "now-1y", + "to": "now" + }, + "timezone": "", + "title": "Apple Health", + "uid": "ahdx-health", + "version": 1 +} \ No newline at end of file diff --git a/grafana/provisioning/dashboards/dashboards.yml b/grafana/provisioning/dashboards/dashboards.yml new file mode 100644 index 0000000..e5e15d5 --- /dev/null +++ b/grafana/provisioning/dashboards/dashboards.yml @@ -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 diff --git a/grafana/provisioning/datasources/ahdx.yml b/grafana/provisioning/datasources/ahdx.yml new file mode 100644 index 0000000..0fed75c --- /dev/null +++ b/grafana/provisioning/datasources/ahdx.yml @@ -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 diff --git a/jobs.py b/jobs.py new file mode 100644 index 0000000..b747b19 --- /dev/null +++ b/jobs.py @@ -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() diff --git a/parser.py b/parser.py new file mode 100644 index 0000000..8887fd9 --- /dev/null +++ b/parser.py @@ -0,0 +1,273 @@ +""" +Streaming parser for Apple's Health export.xml. + +The file is one root holding a flat list of , +and 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 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)], + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c37b670 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0 diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..72e3f91 --- /dev/null +++ b/scanner.py @@ -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) diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..26a5bb7 --- /dev/null +++ b/schema.sql @@ -0,0 +1,116 @@ +-- Schema for one Apple Health database. +-- +-- Apple's export.xml is flat: a big pile of rows plus some +-- and 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'); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..030082b --- /dev/null +++ b/static/style.css @@ -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 ). */ +: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; +} diff --git a/static/vendor/leaflet/leaflet.css b/static/vendor/leaflet/leaflet.css new file mode 100644 index 0000000..2961b76 --- /dev/null +++ b/static/vendor/leaflet/leaflet.css @@ -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; + } + } diff --git a/static/vendor/leaflet/leaflet.js b/static/vendor/leaflet/leaflet.js new file mode 100644 index 0000000..a3bf693 --- /dev/null +++ b/static/vendor/leaflet/leaflet.js @@ -0,0 +1,6 @@ +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ +!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n=this.min.x&&i.x<=this.max.x&&e.y>=this.min.y&&i.y<=this.max.y},intersects:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>=e.x&&n.x<=i.x,t=t.y>=e.y&&n.y<=i.y;return o&&t},overlaps:function(t){t=_(t);var e=this.min,i=this.max,n=t.min,t=t.max,o=t.x>e.x&&n.xe.y&&n.y=n.lat&&i.lat<=o.lat&&e.lng>=n.lng&&i.lng<=o.lng},intersects:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>=e.lat&&n.lat<=i.lat,t=t.lng>=e.lng&&n.lng<=i.lng;return o&&t},overlaps:function(t){t=g(t);var e=this._southWest,i=this._northEast,n=t.getSouthWest(),t=t.getNorthEast(),o=t.lat>e.lat&&n.late.lng&&n.lng","http://www.w3.org/2000/svg"===(Wt.firstChild&&Wt.firstChild.namespaceURI));function y(t){return 0<=navigator.userAgent.toLowerCase().indexOf(t)}var b={ie:pt,ielt9:mt,edge:n,webkit:ft,android:gt,android23:vt,androidStock:yt,opera:xt,chrome:wt,gecko:bt,safari:Pt,phantom:Lt,opera12:o,win:Tt,ie3d:Mt,webkit3d:zt,gecko3d:_t,any3d:Ct,mobile:Zt,mobileWebkit:St,mobileWebkit3d:Et,msPointer:kt,pointer:Ot,touch:Bt,touchNative:At,mobileOpera:It,mobileGecko:Rt,retina:Nt,passiveEvents:Dt,canvas:jt,svg:Ht,vml:!Ht&&function(){try{var t=document.createElement("div"),e=(t.innerHTML='',t.firstChild);return e.style.behavior="url(#default#VML)",e&&"object"==typeof e.adj}catch(t){return!1}}(),inlineSvg:Wt,mac:0===navigator.platform.indexOf("Mac"),linux:0===navigator.platform.indexOf("Linux")},Ft=b.msPointer?"MSPointerDown":"pointerdown",Ut=b.msPointer?"MSPointerMove":"pointermove",Vt=b.msPointer?"MSPointerUp":"pointerup",qt=b.msPointer?"MSPointerCancel":"pointercancel",Gt={touchstart:Ft,touchmove:Ut,touchend:Vt,touchcancel:qt},Kt={touchstart:function(t,e){e.MSPOINTER_TYPE_TOUCH&&e.pointerType===e.MSPOINTER_TYPE_TOUCH&&O(e);ee(t,e)},touchmove:ee,touchend:ee,touchcancel:ee},Yt={},Xt=!1;function Jt(t,e,i){return"touchstart"!==e||Xt||(document.addEventListener(Ft,$t,!0),document.addEventListener(Ut,Qt,!0),document.addEventListener(Vt,te,!0),document.addEventListener(qt,te,!0),Xt=!0),Kt[e]?(i=Kt[e].bind(this,i),t.addEventListener(Gt[e],i,!1),i):(console.warn("wrong event specified:",e),u)}function $t(t){Yt[t.pointerId]=t}function Qt(t){Yt[t.pointerId]&&(Yt[t.pointerId]=t)}function te(t){delete Yt[t.pointerId]}function ee(t,e){if(e.pointerType!==(e.MSPOINTER_TYPE_MOUSE||"mouse")){for(var i in e.touches=[],Yt)e.touches.push(Yt[i]);e.changedTouches=[e],t(e)}}var ie=200;function ne(t,i){t.addEventListener("dblclick",i);var n,o=0;function e(t){var e;1!==t.detail?n=t.detail:"mouse"===t.pointerType||t.sourceCapabilities&&!t.sourceCapabilities.firesTouchEvents||((e=Ne(t)).some(function(t){return t instanceof HTMLLabelElement&&t.attributes.for})&&!e.some(function(t){return t instanceof HTMLInputElement||t instanceof HTMLSelectElement})||((e=Date.now())-o<=ie?2===++n&&i(function(t){var e,i,n={};for(i in t)e=t[i],n[i]=e&&e.bind?e.bind(t):e;return(t=n).type="dblclick",n.detail=2,n.isTrusted=!1,n._simulated=!0,n}(t)):n=1,o=e))}return t.addEventListener("click",e),{dblclick:i,simDblclick:e}}var oe,se,re,ae,he,le,ue=we(["transform","webkitTransform","OTransform","MozTransform","msTransform"]),ce=we(["webkitTransition","transition","OTransition","MozTransition","msTransition"]),de="webkitTransition"===ce||"OTransition"===ce?ce+"End":"transitionend";function _e(t){return"string"==typeof t?document.getElementById(t):t}function pe(t,e){var i=t.style[e]||t.currentStyle&&t.currentStyle[e];return"auto"===(i=i&&"auto"!==i||!document.defaultView?i:(t=document.defaultView.getComputedStyle(t,null))?t[e]:null)?null:i}function P(t,e,i){t=document.createElement(t);return t.className=e||"",i&&i.appendChild(t),t}function T(t){var e=t.parentNode;e&&e.removeChild(t)}function me(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function fe(t){var e=t.parentNode;e&&e.lastChild!==t&&e.appendChild(t)}function ge(t){var e=t.parentNode;e&&e.firstChild!==t&&e.insertBefore(t,e.firstChild)}function ve(t,e){return void 0!==t.classList?t.classList.contains(e):0<(t=xe(t)).length&&new RegExp("(^|\\s)"+e+"(\\s|$)").test(t)}function M(t,e){var i;if(void 0!==t.classList)for(var n=F(e),o=0,s=n.length;othis.options.maxZoom)?this.setZoom(t):this},panInsideBounds:function(t,e){this._enforcingBounds=!0;var i=this.getCenter(),t=this._limitCenter(i,this._zoom,g(t));return i.equals(t)||this.panTo(t,e),this._enforcingBounds=!1,this},panInside:function(t,e){var i=m((e=e||{}).paddingTopLeft||e.padding||[0,0]),n=m(e.paddingBottomRight||e.padding||[0,0]),o=this.project(this.getCenter()),t=this.project(t),s=this.getPixelBounds(),i=_([s.min.add(i),s.max.subtract(n)]),s=i.getSize();return i.contains(t)||(this._enforcingBounds=!0,n=t.subtract(i.getCenter()),i=i.extend(t).getSize().subtract(s),o.x+=n.x<0?-i.x:i.x,o.y+=n.y<0?-i.y:i.y,this.panTo(this.unproject(o),e),this._enforcingBounds=!1),this},invalidateSize:function(t){if(!this._loaded)return this;t=l({animate:!1,pan:!0},!0===t?{animate:!0}:t);var e=this.getSize(),i=(this._sizeChanged=!0,this._lastCenter=null,this.getSize()),n=e.divideBy(2).round(),o=i.divideBy(2).round(),n=n.subtract(o);return n.x||n.y?(t.animate&&t.pan?this.panBy(n):(t.pan&&this._rawPanBy(n),this.fire("move"),t.debounceMoveend?(clearTimeout(this._sizeTimer),this._sizeTimer=setTimeout(a(this.fire,this,"moveend"),200)):this.fire("moveend")),this.fire("resize",{oldSize:e,newSize:i})):this},stop:function(){return this.setZoom(this._limitZoom(this._zoom)),this.options.zoomSnap||this.fire("viewreset"),this._stop()},locate:function(t){var e,i;return t=this._locateOptions=l({timeout:1e4,watch:!1},t),"geolocation"in navigator?(e=a(this._handleGeolocationResponse,this),i=a(this._handleGeolocationError,this),t.watch?this._locationWatchId=navigator.geolocation.watchPosition(e,i,t):navigator.geolocation.getCurrentPosition(e,i,t)):this._handleGeolocationError({code:0,message:"Geolocation not supported."}),this},stopLocate:function(){return navigator.geolocation&&navigator.geolocation.clearWatch&&navigator.geolocation.clearWatch(this._locationWatchId),this._locateOptions&&(this._locateOptions.setView=!1),this},_handleGeolocationError:function(t){var e;this._container._leaflet_id&&(e=t.code,t=t.message||(1===e?"permission denied":2===e?"position unavailable":"timeout"),this._locateOptions.setView&&!this._loaded&&this.fitWorld(),this.fire("locationerror",{code:e,message:"Geolocation error: "+t+"."}))},_handleGeolocationResponse:function(t){if(this._container._leaflet_id){var e,i,n=new v(t.coords.latitude,t.coords.longitude),o=n.toBounds(2*t.coords.accuracy),s=this._locateOptions,r=(s.setView&&(e=this.getBoundsZoom(o),this.setView(n,s.maxZoom?Math.min(e,s.maxZoom):e)),{latlng:n,bounds:o,timestamp:t.timestamp});for(i in t.coords)"number"==typeof t.coords[i]&&(r[i]=t.coords[i]);this.fire("locationfound",r)}},addHandler:function(t,e){return e&&(e=this[t]=new e(this),this._handlers.push(e),this.options[t]&&e.enable()),this},remove:function(){if(this._initEvents(!0),this.options.maxBounds&&this.off("moveend",this._panInsideMaxBounds),this._containerId!==this._container._leaflet_id)throw new Error("Map container is being reused by another instance");try{delete this._container._leaflet_id,delete this._containerId}catch(t){this._container._leaflet_id=void 0,this._containerId=void 0}for(var t in void 0!==this._locationWatchId&&this.stopLocate(),this._stop(),T(this._mapPane),this._clearControlPos&&this._clearControlPos(),this._resizeRequest&&(r(this._resizeRequest),this._resizeRequest=null),this._clearHandlers(),this._loaded&&this.fire("unload"),this._layers)this._layers[t].remove();for(t in this._panes)T(this._panes[t]);return this._layers=[],this._panes=[],delete this._mapPane,delete this._renderer,this},createPane:function(t,e){e=P("div","leaflet-pane"+(t?" leaflet-"+t.replace("Pane","")+"-pane":""),e||this._mapPane);return t&&(this._panes[t]=e),e},getCenter:function(){return this._checkIfLoaded(),this._lastCenter&&!this._moved()?this._lastCenter.clone():this.layerPointToLatLng(this._getCenterLayerPoint())},getZoom:function(){return this._zoom},getBounds:function(){var t=this.getPixelBounds();return new s(this.unproject(t.getBottomLeft()),this.unproject(t.getTopRight()))},getMinZoom:function(){return void 0===this.options.minZoom?this._layersMinZoom||0:this.options.minZoom},getMaxZoom:function(){return void 0===this.options.maxZoom?void 0===this._layersMaxZoom?1/0:this._layersMaxZoom:this.options.maxZoom},getBoundsZoom:function(t,e,i){t=g(t),i=m(i||[0,0]);var n=this.getZoom()||0,o=this.getMinZoom(),s=this.getMaxZoom(),r=t.getNorthWest(),t=t.getSouthEast(),i=this.getSize().subtract(i),t=_(this.project(t,n),this.project(r,n)).getSize(),r=b.any3d?this.options.zoomSnap:1,a=i.x/t.x,i=i.y/t.y,t=e?Math.max(a,i):Math.min(a,i),n=this.getScaleZoom(t,n);return r&&(n=Math.round(n/(r/100))*(r/100),n=e?Math.ceil(n/r)*r:Math.floor(n/r)*r),Math.max(o,Math.min(s,n))},getSize:function(){return this._size&&!this._sizeChanged||(this._size=new p(this._container.clientWidth||0,this._container.clientHeight||0),this._sizeChanged=!1),this._size.clone()},getPixelBounds:function(t,e){t=this._getTopLeftPoint(t,e);return new f(t,t.add(this.getSize()))},getPixelOrigin:function(){return this._checkIfLoaded(),this._pixelOrigin},getPixelWorldBounds:function(t){return this.options.crs.getProjectedBounds(void 0===t?this.getZoom():t)},getPane:function(t){return"string"==typeof t?this._panes[t]:t},getPanes:function(){return this._panes},getContainer:function(){return this._container},getZoomScale:function(t,e){var i=this.options.crs;return e=void 0===e?this._zoom:e,i.scale(t)/i.scale(e)},getScaleZoom:function(t,e){var i=this.options.crs,t=(e=void 0===e?this._zoom:e,i.zoom(t*i.scale(e)));return isNaN(t)?1/0:t},project:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.latLngToPoint(w(t),e)},unproject:function(t,e){return e=void 0===e?this._zoom:e,this.options.crs.pointToLatLng(m(t),e)},layerPointToLatLng:function(t){t=m(t).add(this.getPixelOrigin());return this.unproject(t)},latLngToLayerPoint:function(t){return this.project(w(t))._round()._subtract(this.getPixelOrigin())},wrapLatLng:function(t){return this.options.crs.wrapLatLng(w(t))},wrapLatLngBounds:function(t){return this.options.crs.wrapLatLngBounds(g(t))},distance:function(t,e){return this.options.crs.distance(w(t),w(e))},containerPointToLayerPoint:function(t){return m(t).subtract(this._getMapPanePos())},layerPointToContainerPoint:function(t){return m(t).add(this._getMapPanePos())},containerPointToLatLng:function(t){t=this.containerPointToLayerPoint(m(t));return this.layerPointToLatLng(t)},latLngToContainerPoint:function(t){return this.layerPointToContainerPoint(this.latLngToLayerPoint(w(t)))},mouseEventToContainerPoint:function(t){return De(t,this._container)},mouseEventToLayerPoint:function(t){return this.containerPointToLayerPoint(this.mouseEventToContainerPoint(t))},mouseEventToLatLng:function(t){return this.layerPointToLatLng(this.mouseEventToLayerPoint(t))},_initContainer:function(t){t=this._container=_e(t);if(!t)throw new Error("Map container not found.");if(t._leaflet_id)throw new Error("Map container is already initialized.");S(t,"scroll",this._onScroll,this),this._containerId=h(t)},_initLayout:function(){var t=this._container,e=(this._fadeAnimated=this.options.fadeAnimation&&b.any3d,M(t,"leaflet-container"+(b.touch?" leaflet-touch":"")+(b.retina?" leaflet-retina":"")+(b.ielt9?" leaflet-oldie":"")+(b.safari?" leaflet-safari":"")+(this._fadeAnimated?" leaflet-fade-anim":"")),pe(t,"position"));"absolute"!==e&&"relative"!==e&&"fixed"!==e&&"sticky"!==e&&(t.style.position="relative"),this._initPanes(),this._initControlPos&&this._initControlPos()},_initPanes:function(){var t=this._panes={};this._paneRenderers={},this._mapPane=this.createPane("mapPane",this._container),Z(this._mapPane,new p(0,0)),this.createPane("tilePane"),this.createPane("overlayPane"),this.createPane("shadowPane"),this.createPane("markerPane"),this.createPane("tooltipPane"),this.createPane("popupPane"),this.options.markerZoomAnimation||(M(t.markerPane,"leaflet-zoom-hide"),M(t.shadowPane,"leaflet-zoom-hide"))},_resetView:function(t,e,i){Z(this._mapPane,new p(0,0));var n=!this._loaded,o=(this._loaded=!0,e=this._limitZoom(e),this.fire("viewprereset"),this._zoom!==e);this._moveStart(o,i)._move(t,e)._moveEnd(o),this.fire("viewreset"),n&&this.fire("load")},_moveStart:function(t,e){return t&&this.fire("zoomstart"),e||this.fire("movestart"),this},_move:function(t,e,i,n){void 0===e&&(e=this._zoom);var o=this._zoom!==e;return this._zoom=e,this._lastCenter=t,this._pixelOrigin=this._getNewPixelOrigin(t),n?i&&i.pinch&&this.fire("zoom",i):((o||i&&i.pinch)&&this.fire("zoom",i),this.fire("move",i)),this},_moveEnd:function(t){return t&&this.fire("zoomend"),this.fire("moveend")},_stop:function(){return r(this._flyToFrame),this._panAnim&&this._panAnim.stop(),this},_rawPanBy:function(t){Z(this._mapPane,this._getMapPanePos().subtract(t))},_getZoomSpan:function(){return this.getMaxZoom()-this.getMinZoom()},_panInsideMaxBounds:function(){this._enforcingBounds||this.panInsideBounds(this.options.maxBounds)},_checkIfLoaded:function(){if(!this._loaded)throw new Error("Set map center and zoom first.")},_initEvents:function(t){this._targets={};var e=t?k:S;e((this._targets[h(this._container)]=this)._container,"click dblclick mousedown mouseup mouseover mouseout mousemove contextmenu keypress keydown keyup",this._handleDOMEvent,this),this.options.trackResize&&e(window,"resize",this._onResize,this),b.any3d&&this.options.transform3DLimit&&(t?this.off:this.on).call(this,"moveend",this._onMoveEnd)},_onResize:function(){r(this._resizeRequest),this._resizeRequest=x(function(){this.invalidateSize({debounceMoveend:!0})},this)},_onScroll:function(){this._container.scrollTop=0,this._container.scrollLeft=0},_onMoveEnd:function(){var t=this._getMapPanePos();Math.max(Math.abs(t.x),Math.abs(t.y))>=this.options.transform3DLimit&&this._resetView(this.getCenter(),this.getZoom())},_findEventTargets:function(t,e){for(var i,n=[],o="mouseout"===e||"mouseover"===e,s=t.target||t.srcElement,r=!1;s;){if((i=this._targets[h(s)])&&("click"===e||"preclick"===e)&&this._draggableMoved(i)){r=!0;break}if(i&&i.listens(e,!0)){if(o&&!We(s,t))break;if(n.push(i),o)break}if(s===this._container)break;s=s.parentNode}return n=n.length||r||o||!this.listens(e,!0)?n:[this]},_isClickDisabled:function(t){for(;t&&t!==this._container;){if(t._leaflet_disable_click)return!0;t=t.parentNode}},_handleDOMEvent:function(t){var e,i=t.target||t.srcElement;!this._loaded||i._leaflet_disable_events||"click"===t.type&&this._isClickDisabled(i)||("mousedown"===(e=t.type)&&Me(i),this._fireDOMEvent(t,e))},_mouseEvents:["click","dblclick","mouseover","mouseout","contextmenu"],_fireDOMEvent:function(t,e,i){"click"===t.type&&((a=l({},t)).type="preclick",this._fireDOMEvent(a,a.type,i));var n=this._findEventTargets(t,e);if(i){for(var o=[],s=0;sthis.options.zoomAnimationThreshold)return!1;var n=this.getZoomScale(e),n=this._getCenterOffset(t)._divideBy(1-1/n);if(!0!==i.animate&&!this.getSize().contains(n))return!1;x(function(){this._moveStart(!0,i.noMoveStart||!1)._animateZoom(t,e,!0)},this)}return!0},_animateZoom:function(t,e,i,n){this._mapPane&&(i&&(this._animatingZoom=!0,this._animateToCenter=t,this._animateToZoom=e,M(this._mapPane,"leaflet-zoom-anim")),this.fire("zoomanim",{center:t,zoom:e,noUpdate:n}),this._tempFireZoomEvent||(this._tempFireZoomEvent=this._zoom!==this._animateToZoom),this._move(this._animateToCenter,this._animateToZoom,void 0,!0),setTimeout(a(this._onZoomTransitionEnd,this),250))},_onZoomTransitionEnd:function(){this._animatingZoom&&(this._mapPane&&z(this._mapPane,"leaflet-zoom-anim"),this._animatingZoom=!1,this._move(this._animateToCenter,this._animateToZoom,void 0,!0),this._tempFireZoomEvent&&this.fire("zoom"),delete this._tempFireZoomEvent,this.fire("move"),this._moveEnd(!0))}});function Ue(t){return new B(t)}var B=et.extend({options:{position:"topright"},initialize:function(t){c(this,t)},getPosition:function(){return this.options.position},setPosition:function(t){var e=this._map;return e&&e.removeControl(this),this.options.position=t,e&&e.addControl(this),this},getContainer:function(){return this._container},addTo:function(t){this.remove(),this._map=t;var e=this._container=this.onAdd(t),i=this.getPosition(),t=t._controlCorners[i];return M(e,"leaflet-control"),-1!==i.indexOf("bottom")?t.insertBefore(e,t.firstChild):t.appendChild(e),this._map.on("unload",this.remove,this),this},remove:function(){return this._map&&(T(this._container),this.onRemove&&this.onRemove(this._map),this._map.off("unload",this.remove,this),this._map=null),this},_refocusOnMap:function(t){this._map&&t&&0",e=document.createElement("div");return e.innerHTML=t,e.firstChild},_addItem:function(t){var e,i=document.createElement("label"),n=this._map.hasLayer(t.layer),n=(t.overlay?((e=document.createElement("input")).type="checkbox",e.className="leaflet-control-layers-selector",e.defaultChecked=n):e=this._createRadioElement("leaflet-base-layers_"+h(this),n),this._layerControlInputs.push(e),e.layerId=h(t.layer),S(e,"click",this._onInputClick,this),document.createElement("span")),o=(n.innerHTML=" "+t.name,document.createElement("span"));return i.appendChild(o),o.appendChild(e),o.appendChild(n),(t.overlay?this._overlaysList:this._baseLayersList).appendChild(i),this._checkDisabledLayers(),i},_onInputClick:function(){if(!this._preventClick){var t,e,i=this._layerControlInputs,n=[],o=[];this._handlingClick=!0;for(var s=i.length-1;0<=s;s--)t=i[s],e=this._getLayer(t.layerId).layer,t.checked?n.push(e):t.checked||o.push(e);for(s=0;se.options.maxZoom},_expandIfNotCollapsed:function(){return this._map&&!this.options.collapsed&&this.expand(),this},_expandSafely:function(){var t=this._section,e=(this._preventClick=!0,S(t,"click",O),this.expand(),this);setTimeout(function(){k(t,"click",O),e._preventClick=!1})}})),qe=B.extend({options:{position:"topleft",zoomInText:'',zoomInTitle:"Zoom in",zoomOutText:'',zoomOutTitle:"Zoom out"},onAdd:function(t){var e="leaflet-control-zoom",i=P("div",e+" leaflet-bar"),n=this.options;return this._zoomInButton=this._createButton(n.zoomInText,n.zoomInTitle,e+"-in",i,this._zoomIn),this._zoomOutButton=this._createButton(n.zoomOutText,n.zoomOutTitle,e+"-out",i,this._zoomOut),this._updateDisabled(),t.on("zoomend zoomlevelschange",this._updateDisabled,this),i},onRemove:function(t){t.off("zoomend zoomlevelschange",this._updateDisabled,this)},disable:function(){return this._disabled=!0,this._updateDisabled(),this},enable:function(){return this._disabled=!1,this._updateDisabled(),this},_zoomIn:function(t){!this._disabled&&this._map._zoomthis._map.getMinZoom()&&this._map.zoomOut(this._map.options.zoomDelta*(t.shiftKey?3:1))},_createButton:function(t,e,i,n,o){i=P("a",i,n);return i.innerHTML=t,i.href="#",i.title=e,i.setAttribute("role","button"),i.setAttribute("aria-label",e),Ie(i),S(i,"click",Re),S(i,"click",o,this),S(i,"click",this._refocusOnMap,this),i},_updateDisabled:function(){var t=this._map,e="leaflet-disabled";z(this._zoomInButton,e),z(this._zoomOutButton,e),this._zoomInButton.setAttribute("aria-disabled","false"),this._zoomOutButton.setAttribute("aria-disabled","false"),!this._disabled&&t._zoom!==t.getMinZoom()||(M(this._zoomOutButton,e),this._zoomOutButton.setAttribute("aria-disabled","true")),!this._disabled&&t._zoom!==t.getMaxZoom()||(M(this._zoomInButton,e),this._zoomInButton.setAttribute("aria-disabled","true"))}}),Ge=(A.mergeOptions({zoomControl:!0}),A.addInitHook(function(){this.options.zoomControl&&(this.zoomControl=new qe,this.addControl(this.zoomControl))}),B.extend({options:{position:"bottomleft",maxWidth:100,metric:!0,imperial:!0},onAdd:function(t){var e="leaflet-control-scale",i=P("div",e),n=this.options;return this._addScales(n,e+"-line",i),t.on(n.updateWhenIdle?"moveend":"move",this._update,this),t.whenReady(this._update,this),i},onRemove:function(t){t.off(this.options.updateWhenIdle?"moveend":"move",this._update,this)},_addScales:function(t,e,i){t.metric&&(this._mScale=P("div",e,i)),t.imperial&&(this._iScale=P("div",e,i))},_update:function(){var t=this._map,e=t.getSize().y/2,t=t.distance(t.containerPointToLatLng([0,e]),t.containerPointToLatLng([this.options.maxWidth,e]));this._updateScales(t)},_updateScales:function(t){this.options.metric&&t&&this._updateMetric(t),this.options.imperial&&t&&this._updateImperial(t)},_updateMetric:function(t){var e=this._getRoundNum(t);this._updateScale(this._mScale,e<1e3?e+" m":e/1e3+" km",e/t)},_updateImperial:function(t){var e,i,t=3.2808399*t;5280'+(b.inlineSvg?' ':"")+"Leaflet"},initialize:function(t){c(this,t),this._attributions={}},onAdd:function(t){for(var e in(t.attributionControl=this)._container=P("div","leaflet-control-attribution"),Ie(this._container),t._layers)t._layers[e].getAttribution&&this.addAttribution(t._layers[e].getAttribution());return this._update(),t.on("layeradd",this._addAttribution,this),this._container},onRemove:function(t){t.off("layeradd",this._addAttribution,this)},_addAttribution:function(t){t.layer.getAttribution&&(this.addAttribution(t.layer.getAttribution()),t.layer.once("remove",function(){this.removeAttribution(t.layer.getAttribution())},this))},setPrefix:function(t){return this.options.prefix=t,this._update(),this},addAttribution:function(t){return t&&(this._attributions[t]||(this._attributions[t]=0),this._attributions[t]++,this._update()),this},removeAttribution:function(t){return t&&this._attributions[t]&&(this._attributions[t]--,this._update()),this},_update:function(){if(this._map){var t,e=[];for(t in this._attributions)this._attributions[t]&&e.push(t);var i=[];this.options.prefix&&i.push(this.options.prefix),e.length&&i.push(e.join(", ")),this._container.innerHTML=i.join(' ')}}}),n=(A.mergeOptions({attributionControl:!0}),A.addInitHook(function(){this.options.attributionControl&&(new Ke).addTo(this)}),B.Layers=Ve,B.Zoom=qe,B.Scale=Ge,B.Attribution=Ke,Ue.layers=function(t,e,i){return new Ve(t,e,i)},Ue.zoom=function(t){return new qe(t)},Ue.scale=function(t){return new Ge(t)},Ue.attribution=function(t){return new Ke(t)},et.extend({initialize:function(t){this._map=t},enable:function(){return this._enabled||(this._enabled=!0,this.addHooks()),this},disable:function(){return this._enabled&&(this._enabled=!1,this.removeHooks()),this},enabled:function(){return!!this._enabled}})),ft=(n.addTo=function(t,e){return t.addHandler(e,this),this},{Events:e}),Ye=b.touch?"touchstart mousedown":"mousedown",Xe=it.extend({options:{clickTolerance:3},initialize:function(t,e,i,n){c(this,n),this._element=t,this._dragStartTarget=e||t,this._preventOutline=i},enable:function(){this._enabled||(S(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!0)},disable:function(){this._enabled&&(Xe._dragging===this&&this.finishDrag(!0),k(this._dragStartTarget,Ye,this._onDown,this),this._enabled=!1,this._moved=!1)},_onDown:function(t){var e,i;this._enabled&&(this._moved=!1,ve(this._element,"leaflet-zoom-anim")||(t.touches&&1!==t.touches.length?Xe._dragging===this&&this.finishDrag():Xe._dragging||t.shiftKey||1!==t.which&&1!==t.button&&!t.touches||((Xe._dragging=this)._preventOutline&&Me(this._element),Le(),re(),this._moving||(this.fire("down"),i=t.touches?t.touches[0]:t,e=Ce(this._element),this._startPoint=new p(i.clientX,i.clientY),this._startPos=Pe(this._element),this._parentScale=Ze(e),i="mousedown"===t.type,S(document,i?"mousemove":"touchmove",this._onMove,this),S(document,i?"mouseup":"touchend touchcancel",this._onUp,this)))))},_onMove:function(t){var e;this._enabled&&(t.touches&&1e&&(i.push(t[n]),o=n);oe.max.x&&(i|=2),t.ye.max.y&&(i|=8),i}function ri(t,e,i,n){var o=e.x,e=e.y,s=i.x-o,r=i.y-e,a=s*s+r*r;return 0this._layersMaxZoom&&this.setZoom(this._layersMaxZoom),void 0===this.options.minZoom&&this._layersMinZoom&&this.getZoom()t.y!=n.y>t.y&&t.x<(n.x-i.x)*(t.y-i.y)/(n.y-i.y)+i.x&&(l=!l);return l||yi.prototype._containsPoint.call(this,t,!0)}});var wi=ci.extend({initialize:function(t,e){c(this,e),this._layers={},t&&this.addData(t)},addData:function(t){var e,i,n,o=d(t)?t:t.features;if(o){for(e=0,i=o.length;es.x&&(r=i.x+a-s.x+o.x),i.x-r-n.x<(a=0)&&(r=i.x-n.x),i.y+e+o.y>s.y&&(a=i.y+e-s.y+o.y),i.y-a-n.y<0&&(a=i.y-n.y),(r||a)&&(this.options.keepInView&&(this._autopanning=!0),t.fire("autopanstart").panBy([r,a]))))},_getAnchor:function(){return m(this._source&&this._source._getPopupAnchor?this._source._getPopupAnchor():[0,0])}})),Ii=(A.mergeOptions({closePopupOnClick:!0}),A.include({openPopup:function(t,e,i){return this._initOverlay(Bi,t,e,i).openOn(this),this},closePopup:function(t){return(t=arguments.length?t:this._popup)&&t.close(),this}}),o.include({bindPopup:function(t,e){return this._popup=this._initOverlay(Bi,this._popup,t,e),this._popupHandlersAdded||(this.on({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!0),this},unbindPopup:function(){return this._popup&&(this.off({click:this._openPopup,keypress:this._onKeyPress,remove:this.closePopup,move:this._movePopup}),this._popupHandlersAdded=!1,this._popup=null),this},openPopup:function(t){return this._popup&&(this instanceof ci||(this._popup._source=this),this._popup._prepareOpen(t||this._latlng)&&this._popup.openOn(this._map)),this},closePopup:function(){return this._popup&&this._popup.close(),this},togglePopup:function(){return this._popup&&this._popup.toggle(this),this},isPopupOpen:function(){return!!this._popup&&this._popup.isOpen()},setPopupContent:function(t){return this._popup&&this._popup.setContent(t),this},getPopup:function(){return this._popup},_openPopup:function(t){var e;this._popup&&this._map&&(Re(t),e=t.layer||t.target,this._popup._source!==e||e instanceof fi?(this._popup._source=e,this.openPopup(t.latlng)):this._map.hasLayer(this._popup)?this.closePopup():this.openPopup(t.latlng))},_movePopup:function(t){this._popup.setLatLng(t.latlng)},_onKeyPress:function(t){13===t.originalEvent.keyCode&&this._openPopup(t)}}),Ai.extend({options:{pane:"tooltipPane",offset:[0,0],direction:"auto",permanent:!1,sticky:!1,opacity:.9},onAdd:function(t){Ai.prototype.onAdd.call(this,t),this.setOpacity(this.options.opacity),t.fire("tooltipopen",{tooltip:this}),this._source&&(this.addEventParent(this._source),this._source.fire("tooltipopen",{tooltip:this},!0))},onRemove:function(t){Ai.prototype.onRemove.call(this,t),t.fire("tooltipclose",{tooltip:this}),this._source&&(this.removeEventParent(this._source),this._source.fire("tooltipclose",{tooltip:this},!0))},getEvents:function(){var t=Ai.prototype.getEvents.call(this);return this.options.permanent||(t.preclick=this.close),t},_initLayout:function(){var t="leaflet-tooltip "+(this.options.className||"")+" leaflet-zoom-"+(this._zoomAnimated?"animated":"hide");this._contentNode=this._container=P("div",t),this._container.setAttribute("role","tooltip"),this._container.setAttribute("id","leaflet-tooltip-"+h(this))},_updateLayout:function(){},_adjustPan:function(){},_setPosition:function(t){var e,i=this._map,n=this._container,o=i.latLngToContainerPoint(i.getCenter()),i=i.layerPointToContainerPoint(t),s=this.options.direction,r=n.offsetWidth,a=n.offsetHeight,h=m(this.options.offset),l=this._getAnchor(),i="top"===s?(e=r/2,a):"bottom"===s?(e=r/2,0):(e="center"===s?r/2:"right"===s?0:"left"===s?r:i.xthis.options.maxZoom||nthis.options.maxZoom||void 0!==this.options.minZoom&&oi.max.x)||!e.wrapLat&&(t.yi.max.y))return!1}return!this.options.bounds||(e=this._tileCoordsToBounds(t),g(this.options.bounds).overlaps(e))},_keyToBounds:function(t){return this._tileCoordsToBounds(this._keyToTileCoords(t))},_tileCoordsToNwSe:function(t){var e=this._map,i=this.getTileSize(),n=t.scaleBy(i),i=n.add(i);return[e.unproject(n,t.z),e.unproject(i,t.z)]},_tileCoordsToBounds:function(t){t=this._tileCoordsToNwSe(t),t=new s(t[0],t[1]);return t=this.options.noWrap?t:this._map.wrapLatLngBounds(t)},_tileCoordsToKey:function(t){return t.x+":"+t.y+":"+t.z},_keyToTileCoords:function(t){var t=t.split(":"),e=new p(+t[0],+t[1]);return e.z=+t[2],e},_removeTile:function(t){var e=this._tiles[t];e&&(T(e.el),delete this._tiles[t],this.fire("tileunload",{tile:e.el,coords:this._keyToTileCoords(t)}))},_initTile:function(t){M(t,"leaflet-tile");var e=this.getTileSize();t.style.width=e.x+"px",t.style.height=e.y+"px",t.onselectstart=u,t.onmousemove=u,b.ielt9&&this.options.opacity<1&&C(t,this.options.opacity)},_addTile:function(t,e){var i=this._getTilePos(t),n=this._tileCoordsToKey(t),o=this.createTile(this._wrapCoords(t),a(this._tileReady,this,t));this._initTile(o),this.createTile.length<2&&x(a(this._tileReady,this,t,null,o)),Z(o,i),this._tiles[n]={el:o,coords:t,current:!0},e.appendChild(o),this.fire("tileloadstart",{tile:o,coords:t})},_tileReady:function(t,e,i){e&&this.fire("tileerror",{error:e,tile:i,coords:t});var n=this._tileCoordsToKey(t);(i=this._tiles[n])&&(i.loaded=+new Date,this._map._fadeAnimated?(C(i.el,0),r(this._fadeFrame),this._fadeFrame=x(this._updateOpacity,this)):(i.active=!0,this._pruneTiles()),e||(M(i.el,"leaflet-tile-loaded"),this.fire("tileload",{tile:i.el,coords:t})),this._noTilesToLoad()&&(this._loading=!1,this.fire("load"),b.ielt9||!this._map._fadeAnimated?x(this._pruneTiles,this):setTimeout(a(this._pruneTiles,this),250)))},_getTilePos:function(t){return t.scaleBy(this.getTileSize()).subtract(this._level.origin)},_wrapCoords:function(t){var e=new p(this._wrapX?H(t.x,this._wrapX):t.x,this._wrapY?H(t.y,this._wrapY):t.y);return e.z=t.z,e},_pxBoundsToTileRange:function(t){var e=this.getTileSize();return new f(t.min.unscaleBy(e).floor(),t.max.unscaleBy(e).ceil().subtract([1,1]))},_noTilesToLoad:function(){for(var t in this._tiles)if(!this._tiles[t].loaded)return!1;return!0}});var Di=Ni.extend({options:{minZoom:0,maxZoom:18,subdomains:"abc",errorTileUrl:"",zoomOffset:0,tms:!1,zoomReverse:!1,detectRetina:!1,crossOrigin:!1,referrerPolicy:!1},initialize:function(t,e){this._url=t,(e=c(this,e)).detectRetina&&b.retina&&0')}}catch(t){}return function(t){return document.createElement("<"+t+' xmlns="urn:schemas-microsoft.com:vml" class="lvml">')}}(),zt={_initContainer:function(){this._container=P("div","leaflet-vml-container")},_update:function(){this._map._animatingZoom||(Wi.prototype._update.call(this),this.fire("update"))},_initPath:function(t){var e=t._container=Vi("shape");M(e,"leaflet-vml-shape "+(this.options.className||"")),e.coordsize="1 1",t._path=Vi("path"),e.appendChild(t._path),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){var e=t._container;this._container.appendChild(e),t.options.interactive&&t.addInteractiveTarget(e)},_removePath:function(t){var e=t._container;T(e),t.removeInteractiveTarget(e),delete this._layers[h(t)]},_updateStyle:function(t){var e=t._stroke,i=t._fill,n=t.options,o=t._container;o.stroked=!!n.stroke,o.filled=!!n.fill,n.stroke?(e=e||(t._stroke=Vi("stroke")),o.appendChild(e),e.weight=n.weight+"px",e.color=n.color,e.opacity=n.opacity,n.dashArray?e.dashStyle=d(n.dashArray)?n.dashArray.join(" "):n.dashArray.replace(/( *, *)/g," "):e.dashStyle="",e.endcap=n.lineCap.replace("butt","flat"),e.joinstyle=n.lineJoin):e&&(o.removeChild(e),t._stroke=null),n.fill?(i=i||(t._fill=Vi("fill")),o.appendChild(i),i.color=n.fillColor||n.color,i.opacity=n.fillOpacity):i&&(o.removeChild(i),t._fill=null)},_updateCircle:function(t){var e=t._point.round(),i=Math.round(t._radius),n=Math.round(t._radiusY||i);this._setPath(t,t._empty()?"M0 0":"AL "+e.x+","+e.y+" "+i+","+n+" 0,23592600")},_setPath:function(t,e){t._path.v=e},_bringToFront:function(t){fe(t._container)},_bringToBack:function(t){ge(t._container)}},qi=b.vml?Vi:ct,Gi=Wi.extend({_initContainer:function(){this._container=qi("svg"),this._container.setAttribute("pointer-events","none"),this._rootGroup=qi("g"),this._container.appendChild(this._rootGroup)},_destroyContainer:function(){T(this._container),k(this._container),delete this._container,delete this._rootGroup,delete this._svgSize},_update:function(){var t,e,i;this._map._animatingZoom&&this._bounds||(Wi.prototype._update.call(this),e=(t=this._bounds).getSize(),i=this._container,this._svgSize&&this._svgSize.equals(e)||(this._svgSize=e,i.setAttribute("width",e.x),i.setAttribute("height",e.y)),Z(i,t.min),i.setAttribute("viewBox",[t.min.x,t.min.y,e.x,e.y].join(" ")),this.fire("update"))},_initPath:function(t){var e=t._path=qi("path");t.options.className&&M(e,t.options.className),t.options.interactive&&M(e,"leaflet-interactive"),this._updateStyle(t),this._layers[h(t)]=t},_addPath:function(t){this._rootGroup||this._initContainer(),this._rootGroup.appendChild(t._path),t.addInteractiveTarget(t._path)},_removePath:function(t){T(t._path),t.removeInteractiveTarget(t._path),delete this._layers[h(t)]},_updatePath:function(t){t._project(),t._update()},_updateStyle:function(t){var e=t._path,t=t.options;e&&(t.stroke?(e.setAttribute("stroke",t.color),e.setAttribute("stroke-opacity",t.opacity),e.setAttribute("stroke-width",t.weight),e.setAttribute("stroke-linecap",t.lineCap),e.setAttribute("stroke-linejoin",t.lineJoin),t.dashArray?e.setAttribute("stroke-dasharray",t.dashArray):e.removeAttribute("stroke-dasharray"),t.dashOffset?e.setAttribute("stroke-dashoffset",t.dashOffset):e.removeAttribute("stroke-dashoffset")):e.setAttribute("stroke","none"),t.fill?(e.setAttribute("fill",t.fillColor||t.color),e.setAttribute("fill-opacity",t.fillOpacity),e.setAttribute("fill-rule",t.fillRule||"evenodd")):e.setAttribute("fill","none"))},_updatePoly:function(t,e){this._setPath(t,dt(t._parts,e))},_updateCircle:function(t){var e=t._point,i=Math.max(Math.round(t._radius),1),n="a"+i+","+(Math.max(Math.round(t._radiusY),1)||i)+" 0 1,0 ",e=t._empty()?"M0 0":"M"+(e.x-i)+","+e.y+n+2*i+",0 "+n+2*-i+",0 ";this._setPath(t,e)},_setPath:function(t,e){t._path.setAttribute("d",e)},_bringToFront:function(t){fe(t._path)},_bringToBack:function(t){ge(t._path)}});function Ki(t){return b.svg||b.vml?new Gi(t):null}b.vml&&Gi.include(zt),A.include({getRenderer:function(t){t=(t=t.options.renderer||this._getPaneRenderer(t.options.pane)||this.options.renderer||this._renderer)||(this._renderer=this._createRenderer());return this.hasLayer(t)||this.addLayer(t),t},_getPaneRenderer:function(t){var e;return"overlayPane"!==t&&void 0!==t&&(void 0===(e=this._paneRenderers[t])&&(e=this._createRenderer({pane:t}),this._paneRenderers[t]=e),e)},_createRenderer:function(t){return this.options.preferCanvas&&Ui(t)||Ki(t)}});var Yi=xi.extend({initialize:function(t,e){xi.prototype.initialize.call(this,this._boundsToLatLngs(t),e)},setBounds:function(t){return this.setLatLngs(this._boundsToLatLngs(t))},_boundsToLatLngs:function(t){return[(t=g(t)).getSouthWest(),t.getNorthWest(),t.getNorthEast(),t.getSouthEast()]}});Gi.create=qi,Gi.pointsToPath=dt,wi.geometryToLayer=bi,wi.coordsToLatLng=Li,wi.coordsToLatLngs=Ti,wi.latLngToCoords=Mi,wi.latLngsToCoords=zi,wi.getFeature=Ci,wi.asFeature=Zi,A.mergeOptions({boxZoom:!0});var _t=n.extend({initialize:function(t){this._map=t,this._container=t._container,this._pane=t._panes.overlayPane,this._resetStateTimeout=0,t.on("unload",this._destroy,this)},addHooks:function(){S(this._container,"mousedown",this._onMouseDown,this)},removeHooks:function(){k(this._container,"mousedown",this._onMouseDown,this)},moved:function(){return this._moved},_destroy:function(){T(this._pane),delete this._pane},_resetState:function(){this._resetStateTimeout=0,this._moved=!1},_clearDeferredResetState:function(){0!==this._resetStateTimeout&&(clearTimeout(this._resetStateTimeout),this._resetStateTimeout=0)},_onMouseDown:function(t){if(!t.shiftKey||1!==t.which&&1!==t.button)return!1;this._clearDeferredResetState(),this._resetState(),re(),Le(),this._startPoint=this._map.mouseEventToContainerPoint(t),S(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseMove:function(t){this._moved||(this._moved=!0,this._box=P("div","leaflet-zoom-box",this._container),M(this._container,"leaflet-crosshair"),this._map.fire("boxzoomstart")),this._point=this._map.mouseEventToContainerPoint(t);var t=new f(this._point,this._startPoint),e=t.getSize();Z(this._box,t.min),this._box.style.width=e.x+"px",this._box.style.height=e.y+"px"},_finish:function(){this._moved&&(T(this._box),z(this._container,"leaflet-crosshair")),ae(),Te(),k(document,{contextmenu:Re,mousemove:this._onMouseMove,mouseup:this._onMouseUp,keydown:this._onKeyDown},this)},_onMouseUp:function(t){1!==t.which&&1!==t.button||(this._finish(),this._moved&&(this._clearDeferredResetState(),this._resetStateTimeout=setTimeout(a(this._resetState,this),0),t=new s(this._map.containerPointToLatLng(this._startPoint),this._map.containerPointToLatLng(this._point)),this._map.fitBounds(t).fire("boxzoomend",{boxZoomBounds:t})))},_onKeyDown:function(t){27===t.keyCode&&(this._finish(),this._clearDeferredResetState(),this._resetState())}}),Ct=(A.addInitHook("addHandler","boxZoom",_t),A.mergeOptions({doubleClickZoom:!0}),n.extend({addHooks:function(){this._map.on("dblclick",this._onDoubleClick,this)},removeHooks:function(){this._map.off("dblclick",this._onDoubleClick,this)},_onDoubleClick:function(t){var e=this._map,i=e.getZoom(),n=e.options.zoomDelta,i=t.originalEvent.shiftKey?i-n:i+n;"center"===e.options.doubleClickZoom?e.setZoom(i):e.setZoomAround(t.containerPoint,i)}})),Zt=(A.addInitHook("addHandler","doubleClickZoom",Ct),A.mergeOptions({dragging:!0,inertia:!0,inertiaDeceleration:3400,inertiaMaxSpeed:1/0,easeLinearity:.2,worldCopyJump:!1,maxBoundsViscosity:0}),n.extend({addHooks:function(){var t;this._draggable||(t=this._map,this._draggable=new Xe(t._mapPane,t._container),this._draggable.on({dragstart:this._onDragStart,drag:this._onDrag,dragend:this._onDragEnd},this),this._draggable.on("predrag",this._onPreDragLimit,this),t.options.worldCopyJump&&(this._draggable.on("predrag",this._onPreDragWrap,this),t.on("zoomend",this._onZoomEnd,this),t.whenReady(this._onZoomEnd,this))),M(this._map._container,"leaflet-grab leaflet-touch-drag"),this._draggable.enable(),this._positions=[],this._times=[]},removeHooks:function(){z(this._map._container,"leaflet-grab"),z(this._map._container,"leaflet-touch-drag"),this._draggable.disable()},moved:function(){return this._draggable&&this._draggable._moved},moving:function(){return this._draggable&&this._draggable._moving},_onDragStart:function(){var t,e=this._map;e._stop(),this._map.options.maxBounds&&this._map.options.maxBoundsViscosity?(t=g(this._map.options.maxBounds),this._offsetLimit=_(this._map.latLngToContainerPoint(t.getNorthWest()).multiplyBy(-1),this._map.latLngToContainerPoint(t.getSouthEast()).multiplyBy(-1).add(this._map.getSize())),this._viscosity=Math.min(1,Math.max(0,this._map.options.maxBoundsViscosity))):this._offsetLimit=null,e.fire("movestart").fire("dragstart"),e.options.inertia&&(this._positions=[],this._times=[])},_onDrag:function(t){var e,i;this._map.options.inertia&&(e=this._lastTime=+new Date,i=this._lastPos=this._draggable._absPos||this._draggable._newPos,this._positions.push(i),this._times.push(e),this._prunePositions(e)),this._map.fire("move",t).fire("drag",t)},_prunePositions:function(t){for(;1e.max.x&&(t.x=this._viscousLimit(t.x,e.max.x)),t.y>e.max.y&&(t.y=this._viscousLimit(t.y,e.max.y)),this._draggable._newPos=this._draggable._startPos.add(t))},_onPreDragWrap:function(){var t=this._worldWidth,e=Math.round(t/2),i=this._initialWorldOffset,n=this._draggable._newPos.x,o=(n-e+i)%t+e-i,n=(n+e+i)%t-e-i,t=Math.abs(o+i)e.getMaxZoom()&&1 + + + + + {{ 'Set a password' if mode == 'setup' else 'Log in' }} — AHDX + + + + +
+
AHDXApple Health Data eXporter
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} +
+ {% if mode == 'setup' %} +

Set a password

+

The web UI is protected. Pick a password to use from now on.

+
+

+

+ +
+ {% else %} +

Log in

+
+

+ +
+ {% endif %} +
+
+ + diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..87963eb --- /dev/null +++ b/templates/base.html @@ -0,0 +1,54 @@ + + + + + + {% block title %}AHDX{% endblock %} + + + {% block head %}{% endblock %} + + +
+
AHDXApple Health Data eXporter
+ +
+ Database: {{ active_db['name'] if active_db else '—' }} +
+ + {% if auth_enabled %} + Log out + {% endif %} +
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, msg in messages %} +
{{ msg }}
+ {% endfor %} + {% endwith %} + {% block body %}{% endblock %} +
+ + diff --git a/templates/browse.html b/templates/browse.html new file mode 100644 index 0000000..85355e2 --- /dev/null +++ b/templates/browse.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% block title %}Browse — AHDX{% endblock %} +{% block body %} +

Browse records

+ +
+ + + + + Download CSV +
+ +

Showing up to 500 rows, newest first. The CSV download has no limit.

+ + + + + {% for r in rows %} + + + + + + + + {% else %} + + {% endfor %} + +
TypeValueUnitSourceStart
{{ r['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}{{ r['value'] }}{{ r['unit'] or '' }}{{ r['source_name'] or '' }}{{ r['start_date'] }}
No records match. Try a different type or date range.
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..e2c2103 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Dashboard — AHDX{% endblock %} +{% block body %} +

Dashboard

+ +{% if cards %} +
+ {% for c in cards %} +
+ {{ c['value'] }}{% if c['unit'] %} {{ c['unit'] }}{% endif %} + {{ c['label'] }}{% if c['when'] %} · {{ c['when'] }}{% endif %} +
+ {% endfor %} +
+{% endif %} + +

What's in here

+
+
{{ totals['n'] or 0 }}records
+
{{ types|length }}types
+
{{ workouts }}workouts
+
{{ summaries }}day summaries
+
+ +

+ {% if totals['first'] %} + Data spans {{ totals['first'][:10] }} to {{ totals['last'][:10] }}. + {% else %} + This database is empty. Import an export on the Import page. + {% endif %} +

+ +{% if types %} +

Record types

+ + + + {% for t in types %} + + + + + + + + {% endfor %} + +
TypeCountFromTo
{{ t['type'].replace('HKQuantityTypeIdentifier','').replace('HKCategoryTypeIdentifier','') }}{{ t['n'] }}{{ t['first'][:10] if t['first'] else '' }}{{ t['last'][:10] if t['last'] else '' }}browse
+{% endif %} +{% endblock %} diff --git a/templates/databases.html b/templates/databases.html new file mode 100644 index 0000000..8bde116 --- /dev/null +++ b/templates/databases.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Databases — AHDX{% endblock %} +{% block body %} +

Databases

+

+ Each database is its own .db 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. +

+ + + + + + + + + + + + + + {% for d in overview %} + + + + + + + + + {% endfor %} + +
NameSizeRecordsWorkoutsLast importActions
+ {{ d['name'] }} + {% if d['is_active'] %}active{% endif %} +
{{ d['filename'] }}
+
{{ d['size_h'] }}{{ '{:,}'.format(d['records']) }}{{ d['workouts'] }}{{ d['last_import'][:16] if d['last_import'] else '—' }} +
+ {% if not d['is_active'] %} +
+ +
+ {% endif %} +
+ + +
+
+ +
+
+
+ +
+

New database

+ + +
+ +
+

Read API

+

Pull data out for Grafana or scripts. Open + /api for the menu. + Pick a database with ?db=Name. Example: + /api/daily?db=Steffen&type=HKQuantityTypeIdentifierStepCount&agg=sum

+
+{% endblock %} diff --git a/templates/ecg.html b/templates/ecg.html new file mode 100644 index 0000000..97dabd4 --- /dev/null +++ b/templates/ecg.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}ECG — AHDX{% endblock %} +{% block body %} +

ECG readings

+

From the electrocardiograms in your export. Click one to see the trace.

+ + + + {% for e in items %} + + + + + + + + {% else %} + + {% endfor %} + +
RecordedClassificationRateLengthFile
{{ (e['recorded_date'] or '')[:19] or 'unknown' }}{{ e['classification'] or '' }}{{ (e['sample_rate']|int) ~ ' Hz' if e['sample_rate'] else '' }}{{ ('%.0f s'|format(e['duration_s'])) if e['duration_s'] else '' }}{{ e['filename'] }}
No ECG readings. They only exist if you've taken ECGs and you import the full export.zip.
+{% endblock %} diff --git a/templates/ecg_detail.html b/templates/ecg_detail.html new file mode 100644 index 0000000..a62de5a --- /dev/null +++ b/templates/ecg_detail.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}ECG — AHDX{% endblock %} +{% block body %} +← ECG +

ECG {{ (ecg['recorded_date'] or '')[:19] }}

+

+ {{ 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 %} +

+ +
+ +

Thinned for display. The full waveform is in the database.

+
+ + +{% endblock %} diff --git a/templates/import.html b/templates/import.html new file mode 100644 index 0000000..0f60fa1 --- /dev/null +++ b/templates/import.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}Import — AHDX{% endblock %} +{% block body %} +

Import health data

+

+ On your iPhone: open Health, tap your profile picture, choose "Export All + Health Data". You get a file called export.zip. Drop it here. + You can also drop the export.xml from inside it. +

+ +
+ + +
+ +
+

Last import

+
+ {% include "includes/status.html" %} +
+
+ +
+

Hands-free

+

+ Drop exports into the data/inbox/ folder and AHDX picks them up + on a timer (every 30 minutes by default). Or have a free iOS Shortcut POST + recent metrics to /ingest. Both feed the same database, and + re-sending data you already have does nothing. +

+
+ + +{% endblock %} diff --git a/templates/includes/status.html b/templates/includes/status.html new file mode 100644 index 0000000..5db4405 --- /dev/null +++ b/templates/includes/status.html @@ -0,0 +1,11 @@ +{% if status %} +

State: {{ status['state'] or 'idle' }}

+ {% if status['source'] %}

Source: {{ status['source'] }}

{% endif %} +

Rows read: {{ status['records_seen'] or 0 }} +   new rows added: {{ status['records_new'] or 0 }}

+ {% if status['current_type'] %}

Now reading: {{ status['current_type'] }}

{% endif %} + {% if status['message'] %}

{{ status['message'] }}

{% endif %} + {% if status['finished_at'] %}

Finished {{ status['finished_at'] }} UTC

{% endif %} +{% else %} +

Nothing imported yet.

+{% endif %} diff --git a/templates/route_detail.html b/templates/route_detail.html new file mode 100644 index 0000000..7e7f6c3 --- /dev/null +++ b/templates/route_detail.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}Route — AHDX{% endblock %} +{% block head %} + +{% endblock %} +{% block body %} +← Routes +

{{ route['filename'] }}

+

{{ (route['start_date'] or '')[:19] }} · {{ route['point_count'] }} points

+ +
+
+
+

Track is from your data. Map tiles come from OpenStreetMap over the network.

+ + + +{% endblock %} diff --git a/templates/routes.html b/templates/routes.html new file mode 100644 index 0000000..7d7fa45 --- /dev/null +++ b/templates/routes.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Routes — AHDX{% endblock %} +{% block body %} +

GPS routes

+

From the workout-routes files in your export. Click one to see its shape.

+ + + + {% for r in routes %} + + + + + + {% else %} + + {% endfor %} + +
DateFilePoints
{{ (r['start_date'] or '')[:10] }}{{ r['filename'] }}{{ r['point_count'] }}
No routes yet. Import an export.zip (not just the xml) and they'll show up.
+{% endblock %} diff --git a/templates/trends.html b/templates/trends.html new file mode 100644 index 0000000..86bdb36 --- /dev/null +++ b/templates/trends.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}Trends — AHDX{% endblock %} +{% block body %} +

Trends

+ +
+ + + +
+ +{% if stats and stats['n'] %} +
+
{{ stats['n'] }}samples
+
{{ '%.1f'|format(stats['avg']) if stats['avg'] is not none else '–' }}avg {{ stats['unit'] or '' }}
+
{{ '%.1f'|format(stats['min']) if stats['min'] is not none else '–' }}min
+
{{ '%.1f'|format(stats['max']) if stats['max'] is not none else '–' }}max
+
+{% endif %} + +
+ +

+
+ + +{% endblock %}