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

|
||||||
|
|
||||||
|

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