From 8555695c5e6568cb244c9069b9c3ee85060e831b Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Thu, 1 Oct 2026 20:42:28 +1000 Subject: [PATCH] Deploy Stride with encrypted manual and scheduled Garmin sync --- .dockerignore | 11 ++ Dockerfile | 15 ++ Procfile | 2 + README.md | 24 ++- app.json | 5 + app/__init__.py | 17 +- app/models.py | 14 ++ app/routes.py | 111 +++++++++++- app/static/activity.js | 41 ++++- app/static/dashboard.js | 67 ++++++- app/static/style.css | 21 ++- app/sync.py | 170 ++++++++++++++++++ app/templates/_activities.html | 4 +- app/templates/_ui.html | 22 +++ app/templates/activity.html | 9 +- app/templates/auth.html | 4 +- app/templates/base.html | 36 ++-- app/templates/dashboard.html | 25 ++- app/templates/error.html | 2 +- app/templates/friends.html | 4 +- app/templates/settings.html | 18 +- docs/DEPLOYMENT.md | 45 +++++ .../c72418a19e30_add_activity_share_links.py | 21 +++ .../d81b97ae5401_garmin_connections.py | 29 +++ pyproject.toml | 1 + tests/test_app.py | 81 +++++++++ tests/test_web_sync.py | 116 ++++++++++++ uv.lock | 41 +++++ 28 files changed, 900 insertions(+), 56 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 Procfile create mode 100644 app.json create mode 100644 app/sync.py create mode 100644 app/templates/_ui.html create mode 100644 docs/DEPLOYMENT.md create mode 100644 migrations/versions/c72418a19e30_add_activity_share_links.py create mode 100644 migrations/versions/d81b97ae5401_garmin_connections.py create mode 100644 tests/test_web_sync.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f5768aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +instance +data +tests +.env* +__pycache__ +*.db +*.fit +*.json +!app.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ba028d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM ghcr.io/astral-sh/uv:0.10.6 AS uv +FROM python:3.12-slim +COPY --from=uv /uv /usr/local/bin/uv +WORKDIR /app +ENV PYTHONUNBUFFERED=1 PATH=/app/.venv/bin:$PATH UV_COMPILE_BYTECODE=1 +COPY pyproject.toml uv.lock ./ +RUN uv sync --locked --no-dev --no-install-project +COPY app ./app +COPY garmin_sync ./garmin_sync +COPY migrations ./migrations +COPY Procfile app.json ./ +RUN useradd --uid 1000 --create-home stride && mkdir /storage && chown stride:stride /storage +USER stride +EXPOSE 8000 +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120", "app:create_app()"] diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..4f6227b --- /dev/null +++ b/Procfile @@ -0,0 +1,2 @@ +web: gunicorn --bind 0.0.0.0:8000 --workers 2 --timeout 120 'app:create_app()' +worker: flask --app app sync-worker diff --git a/README.md b/README.md index 3f0931a..d8a9802 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ account exists. Subsequent accounts start empty. The local app uses SQLite in `instance/dashboard.db`. Set `DATABASE_URL` to a PostgreSQL connection string to use PostgreSQL. Database changes use Alembic migrations. Local schema creation/upgrade has been verified on SQLite; PostgreSQL -runtime validation remains pending because Docker was unavailable locally. +runtime validation is recorded in docs/DEPLOYMENT.md. ### Accounts and sharing @@ -38,7 +38,12 @@ runtime validation remains pending because Docker was unavailable locally. the in-app request. No invitation email is sent by this local version. - Accepted friends see each other's shared activities and full recorded routes. - Removing a friend immediately revokes access to shared profiles, pages and JSON. -- There is no Explore page, public feed, or anonymous access to activity data. +- Use the link icon on your run page to copy a public link. Anyone with that link + can view the run, full route and charts without signing in. Public links are + separate from friend visibility; disable a link in the run's sharing settings + to revoke access. There is no Explore page or public feed. +- Share links use the address you opened the app with. A localhost link only works + on your own computer; sharing with others requires a reachable app address. - Routes are not masked or trimmed. Access control, rather than geographic masking, determines who can see the full route, including start and finish. @@ -58,7 +63,7 @@ uv run --locked flask --app app import-local --email owner@example.com --account The application uses CSRF protection, hashed passwords, login throttling, and server-side authorization on every activity page and data endpoint. Before public deployment, finish email verification/recovery, shared production rate-limit -storage, encrypted token uploads, scheduled jobs and PostgreSQL/Dokku validation. +storage, and account recovery. Deployment instructions and sync behavior are in docs/DEPLOYMENT.md. The current server binds only to loopback and runs without debug mode. Users will upload FIT files or connect Garmin by authenticating locally with @@ -134,8 +139,7 @@ operations, but possession of its token file provides broader account access. Local credential storage is plain JSON with restricted permissions on POSIX; on Windows it relies on your directory ACLs. Use a private Windows user directory or -restrict this workspace's ACLs on a shared machine. Encrypted database credential -storage belongs to the future web implementation, not this local proof of concept. +restrict this workspace's ACLs on a shared machine. Uploaded web sessions are encrypted in PostgreSQL using TOKEN_ENCRYPTION_KEY. No credentials or real activity files should be committed to Git. ## Verification @@ -151,10 +155,16 @@ A live verification requires running login, syncing at least one activity, then running sync again to confirm it skips the previously downloaded files. Record the actual outcome in `docs/SYNC_VALIDATION.md`. -Current scope: local Flask app and sync CLI. Scheduled worker, token-upload web -endpoint, email verification/recovery, and Dokku deployment remain to be implemented. +The web app supports encrypted Garmin session uploads, manual queued sync, hourly scheduled sync, and a Dokku worker. Email verification/recovery remains unimplemented. For an isolated browser test with synthetic data, run `uv run python scripts/preview-fixture.py` and visit port 5001. This test process uses an in-memory database and auto-login, never reads Garmin files, and must not be deployed. The regular app does not have auto-login or seeded test accounts. + +## Server sync + +Under Settings & imports, upload your exported Garmin session JSON to connect. +Use **Sync now** for manual sync; hourly sync is enabled for new connections. +Both use a separate persistent worker and import the latest 100 activities privately. +Pause hourly sync or disconnect at any time. See [deployment notes](docs/DEPLOYMENT.md). diff --git a/app.json b/app.json new file mode 100644 index 0000000..e55d719 --- /dev/null +++ b/app.json @@ -0,0 +1,5 @@ +{ + "scripts": {"dokku": {"predeploy": "flask --app app db upgrade"}}, + "formation": {"web": {"quantity": 1}, "worker": {"quantity": 1}}, + "healthchecks": {"web": [{"type": "startup", "name": "database", "description": "Application and PostgreSQL", "path": "/health", "attempts": 10, "wait": 5, "timeout": 5}]} +} diff --git a/app/__init__.py b/app/__init__.py index 5fee5a6..d57c238 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -5,7 +5,7 @@ from pathlib import Path from zoneinfo import ZoneInfo import click -from flask import Flask, render_template +from flask import Flask, render_template, request from .extensions import csrf, db, limiter, login_manager, migrate @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] def create_app(config=None): - app = Flask(__name__, instance_path=str(ROOT / "instance")) + app = Flask(__name__, instance_path=os.environ.get("INSTANCE_PATH", str(ROOT / "instance"))) production = os.environ.get("APP_ENV") == "production" app.config.from_mapping( SQLALCHEMY_DATABASE_URI=os.environ.get("DATABASE_URL", "sqlite:///dashboard.db"), @@ -24,10 +24,15 @@ def create_app(config=None): PERMANENT_SESSION_LIFETIME=timedelta(days=7), MAX_CONTENT_LENGTH=16 * 1024 * 1024, LOCAL_SETUP=not production, - DATA_ROOT=str(ROOT / "data" / "garmin"), + DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")), + TOKEN_ENCRYPTION_KEY=os.environ.get("TOKEN_ENCRYPTION_KEY"), RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"), ) app.config.update(config or {}) + if production: + from werkzeug.middleware.proxy_fix import ProxyFix + + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1) if production and (not app.config["SECRET_KEY"] or not os.environ.get("DATABASE_URL")): raise RuntimeError("Production requires SECRET_KEY and DATABASE_URL.") if not app.config["SECRET_KEY"]: @@ -59,6 +64,9 @@ def create_app(config=None): from .routes import web app.register_blueprint(web) + from .sync import register_cli + + register_cli(app) @login_manager.user_loader def load_user(user_id): @@ -84,6 +92,9 @@ def create_app(config=None): response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if request.path.startswith("/shared/runs/"): + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["X-Robots-Tag"] = "noindex, nofollow" return response for code in (400, 403, 404, 413, 429): diff --git a/app/models.py b/app/models.py index 2a2bce3..5ac2b34 100644 --- a/app/models.py +++ b/app/models.py @@ -27,6 +27,19 @@ class User(UserMixin, db.Model): return check_password_hash(self.password_hash, password) +class GarminConnection(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), unique=True, nullable=False) + account_key = db.Column(db.String(24), unique=True, nullable=False) + tokens = db.Column(db.Text, nullable=False) + pending = db.Column(db.Boolean, nullable=False, default=True) + scheduled = db.Column(db.Boolean, nullable=False, default=True) + next_sync = db.Column(db.DateTime(timezone=True), nullable=False) + last_success = db.Column(db.DateTime(timezone=True)) + message = db.Column(db.String(200), nullable=False, default="Sync queued.") + failures = db.Column(db.Integer, nullable=False, default=0) + + class Activity(db.Model): id = db.Column(db.Integer, primary_key=True) owner_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) @@ -40,6 +53,7 @@ class Activity(db.Model): avg_hr = db.Column(db.Float) metrics = db.Column(db.JSON, nullable=False, default=dict, server_default="{}") visibility = db.Column(db.String(12), nullable=False, default="private") + share_token = db.Column(db.String(64), unique=True) samples = db.Column(db.JSON, nullable=False, default=list) laps = db.Column(db.JSON, nullable=False, default=list) created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC)) diff --git a/app/routes.py b/app/routes.py index f50ba55..89c1699 100644 --- a/app/routes.py +++ b/app/routes.py @@ -26,7 +26,7 @@ from garmin_sync.service import SyncError, atomic_write from .analytics import analyse, pace_text from .extensions import db, limiter -from .models import Activity, Friendship, User +from .models import Activity, Friendship, GarminConnection, User from .services import activity_data, import_cache, import_upload web = Blueprint("web", __name__) @@ -73,6 +73,17 @@ def are_friends(first, second): ) +def latest_mapped_run(rows): + return next( + ( + row + for row in rows + if row.sport == "running" and any(sample.get("point") for sample in row.samples) + ), + None, + ) + + def overview(rows, timezone="Australia/Sydney"): total_distance = sum(r.distance_m for r in rows) total_time = sum(r.duration_s for r in rows) @@ -208,6 +219,7 @@ def dashboard(): stats=overview(rows, current_user.timezone), profile=current_user, personal=True, + mapped_run=latest_mapped_run(rows), ) @@ -290,6 +302,7 @@ def profile(username): stats=overview(rows, user.timezone), profile=user, personal=False, + mapped_run=latest_mapped_run(rows), ) @@ -308,6 +321,52 @@ def data(activity_id): return jsonify(activity_data(row, owner)) +@web.post("/activities//share") +@login_required +def share_activity(activity_id): + row, _ = get_activity(activity_id, True) + if not row.share_token: + # Conditional update keeps simultaneous copy requests on the same link. + db.session.execute( + db.update(Activity) + .where(Activity.id == row.id, Activity.share_token.is_(None)) + .values(share_token=secrets.token_urlsafe(32)) + ) + db.session.commit() + db.session.refresh(row) + return jsonify(url=url_for("web.shared_activity", token=row.share_token, _external=True)) + + +@web.post("/activities//share/revoke") +@login_required +def revoke_activity_share(activity_id): + row, _ = get_activity(activity_id, True) + row.share_token = None + db.session.commit() + flash("Public link disabled. Previously copied links no longer work.") + return redirect(url_for("web.activity", activity_id=row.id)) + + +@web.get("/shared/runs/") +def shared_activity(token): + row = db.first_or_404(db.select(Activity).filter_by(share_token=token)) + return render_template( + "activity.html", + activity=row, + owner_view=False, + public_view=True, + analysis=analyse(row), + pace_text=pace_text, + data_url=url_for("web.shared_activity_data", token=token), + ) + + +@web.get("/shared/runs//data") +def shared_activity_data(token): + row = db.first_or_404(db.select(Activity).filter_by(share_token=token)) + return jsonify(activity_data(row, False)) + + @web.post("/activities//edit") @login_required def edit_activity(activity_id): @@ -334,7 +393,55 @@ def settings(): db.session.commit() flash("Profile saved.") return redirect(url_for("web.settings")) - return render_template("settings.html") + connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=current_user.id)) + return render_template("settings.html", connection=connection) + + +@web.post("/garmin/") +@login_required +@limiter.limit("10 per hour") +def garmin_action(action): + from filelock import Timeout + + from .sync import connect, session_lock + + if action not in {"connect", "sync", "schedule", "disconnect"}: + abort(404) + try: + if action == "connect": + file = request.files.get("tokens") + if not file: + raise SyncError("Choose your exported Garmin session JSON file.") + connect(current_user, file.read(65537)) + flash("Garmin connected. Your first sync is queued.") + else: + with session_lock(current_user.id): + connection = db.session.scalar( + db.select(GarminConnection).filter_by(user_id=current_user.id) + ) + if not connection: + raise SyncError("Connect Garmin first.") + if action == "sync": + connection.pending = True + connection.message = "Manual sync queued." + elif action == "schedule": + connection.scheduled = request.form.get("enabled") == "yes" + connection.next_sync = datetime.now(UTC) + else: + db.session.delete(connection) + current_user.local_account_key = None + db.session.commit() + flash("Garmin sync settings updated.") + except Timeout: + db.session.rollback() + flash("A sync is running. Please retry shortly.", "error") + except SyncError as error: + db.session.rollback() + flash(str(error), "error") + except Exception: # noqa: BLE001 -- suppress provider credentials at boundary + db.session.rollback() + flash("Garmin connection failed. Check your session file and try again.", "error") + return redirect(url_for("web.settings")) @web.post("/imports/cache") diff --git a/app/static/activity.js b/app/static/activity.js index 7fd1986..faf75f9 100644 --- a/app/static/activity.js +++ b/app/static/activity.js @@ -1,4 +1,36 @@ "use strict"; +const shareButton = document.getElementById("copy-run-link"); +if (shareButton) shareButton.addEventListener("click", async () => { + const status = document.getElementById("share-status"); + const fallback = document.getElementById("share-fallback"); + const input = document.getElementById("share-link-value"); + shareButton.disabled = true; + fallback.hidden = true; + status.textContent = "Creating share link…"; + try { + const response = await fetch(shareButton.dataset.url, { + method: "POST", headers: {"X-CSRFToken": shareButton.dataset.csrf}, + }); + if (!response.ok || response.redirected) throw new Error("Share unavailable"); + const data = await response.json(); + input.value = data.url; + document.getElementById("revoke-share").hidden = false; + try { + await navigator.clipboard.writeText(data.url); + status.textContent = "Link copied! Anyone with the link can view this run."; + } catch { + // Clipboard permissions and non-HTTPS hosts can prevent automatic copying. + fallback.hidden = false; + input.focus(); + input.select(); + status.textContent = "Link ready. Copy it below to share this run without sign-in."; + } + } catch { + status.textContent = "Couldn’t create a share link. Please try again."; + } finally { + shareButton.disabled = false; + } +}); const valid = v => typeof v === "number" && Number.isFinite(v); const paceLabel = v => {const s = Math.round(v); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;}; async function showActivity() { @@ -67,6 +99,7 @@ async function showActivity() { document.getElementById("fit-route").disabled = true; } if (!window.Chart) return; + Chart.defaults.color = "#a0aaac"; const line = (label,key,colour,unit,axis="y") => ({label,key,borderColor:colour,unit,yAxisID:axis}); const chart = (id, series, reverse=false) => { const canvas = document.getElementById(id); @@ -74,12 +107,12 @@ async function showActivity() { const message=document.createElement("p"); message.className="chart-empty";message.textContent="Not recorded by your watch for this activity.";canvas.replaceWith(message);return; } const datasets = series.map(s=>({...s,data:data.chart.filter(r=>valid(r.distance)).map(r=>({x:r.distance/1000,y:valid(r[s.key])?r[s.key]:null})),borderWidth:1.6,pointRadius:0,pointHitRadius:8,tension:.15,spanGaps:false})); - const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#edf0e9",drawOnChartArea:!right},border:{display:false},ticks:{color:"#7d847b",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}}); - new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#7d847b",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}}); + const axis = right => ({position:right?"right":"left",reverse:reverse&&!right,grid:{color:"#30383b",drawOnChartArea:!right},border:{display:false},ticks:{color:"#a0aaac",font:{size:10},callback:v=>reverse&&!right?paceLabel(v):Number(v.toFixed(2))}}); + new Chart(canvas,{type:"line",data:{datasets},options:{responsive:true,maintainAspectRatio:false,animation:false,parsing:false,interaction:{mode:"index",intersect:false},onHover:(_event,items,instance)=>{if(items.length)locate(instance.data.datasets[items[0].datasetIndex].data[items[0].index].x*1000);},plugins:{legend:{position:"bottom",labels:{usePointStyle:true,boxWidth:7,font:{size:11}}},tooltip:{callbacks:{title:items=>`${items[0].parsed.x.toFixed(2)} km`,label:item=>`${item.dataset.label}: ${item.dataset.unit==="/km"?paceLabel(item.parsed.y):item.parsed.y.toFixed(1)} ${item.dataset.unit}`}}},scales:{x:{type:"linear",grid:{display:false},border:{display:false},ticks:{maxTicksLimit:7,color:"#a0aaac",font:{size:10},callback:v=>`${v} km`}},y:axis(false),...(series.some(s=>s.yAxisID==="y2")?{y2:axis(true)}:{})}}}); }; - chart("pace-chart",[line("Pace","pace","#38795b","/km"),line("GAP estimate","gap","#bc9050","/km")],true); + chart("pace-chart",[line("Pace","pace","#ff9268","/km"),line("GAP estimate","gap","#bc9050","/km")],true); chart("hr-chart",[line("Heart rate","heart_rate","#bd685b","bpm")]); - chart("elevation-chart",[line("Elevation","elevation","#668b47","m"),line("Gradient","grade","#ba965a","%","y2")]); + chart("elevation-chart",[line("Elevation","elevation","#a6c896","m"),line("Gradient","grade","#ba965a","%","y2")]); chart("cadence-chart",[line("Cadence","cadence","#5a83ab","spm"),line("Step length","stride_length","#9b83b3","m","y2")]); chart("power-chart",[line("Power","power","#b48a38","W")]); chart("dynamics-chart",[line("Vertical oscillation","vertical_oscillation","#699f96","cm"),line("Ground contact","ground_contact","#a57b9c","ms","y2")]); diff --git a/app/static/dashboard.js b/app/static/dashboard.js index 3d993bc..90ee64e 100644 --- a/app/static/dashboard.js +++ b/app/static/dashboard.js @@ -4,7 +4,70 @@ if (volume && window.Chart) { const data = JSON.parse(volume.textContent); new Chart(document.getElementById("volume-chart"), { type: "bar", - data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#294f3a" : "#dbe6bf"), hoverBackgroundColor: "#87a364", borderRadius: 5, maxBarThickness: 34}]}, - options: {responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#f0f2eb"}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}}} + data: {labels: data.labels, datasets: [{data: data.values, backgroundColor: data.values.map((_, i) => i === 7 ? "#ff9268" : "#586367"), hoverBackgroundColor: "#ffb08b", borderRadius: 2, maxBarThickness: 34}]}, + options: {animation: !window.matchMedia("(prefers-reduced-motion: reduce)").matches, responsive: true, maintainAspectRatio: false, plugins: {legend: {display: false}, tooltip: {callbacks: {label: context => `${context.raw.toFixed(2)} km`}}}, scales: {x: {grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#a0aaac"}}, y: {beginAtZero: true, border: {display: false}, grid: {color: "#30383b"}, ticks: {font: {size: 10}, color: "#a0aaac", callback: v => `${v} km`}}}} }); } + +async function showDashboardRoute() { + const container = document.getElementById("dashboard-route-map"); + if (!container?.dataset.url) return; + const status = document.getElementById("dashboard-route-status"); + const toggle = document.getElementById("toggle-route-replay"); + try { + const response = await fetch(container.dataset.url); + if (!response.ok) throw new Error("Route unavailable"); + const data = await response.json(); + const segments = data.segments.filter(segment => segment.length); + if (!segments.length) { status.textContent = "No GPS route recorded for this run."; return; } + const map = L.map(container, {scrollWheelZoom: false}); + L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", { + maxZoom: 19, attribution: '© OpenStreetMap', + }).addTo(map); + const route = L.polyline(segments, {color: "#ff9268", weight: 4, opacity: .85}).addTo(map); + const fit = () => map.fitBounds(route.getBounds(), {padding: [28, 28], maxZoom: 16}); + fit(); + new ResizeObserver(() => { map.invalidateSize(); fit(); }).observe(container); + const first = segments[0][0], last = segments.at(-1).at(-1); + L.circleMarker(first, {radius: 6, color: "#fff", fillColor: "#70b992", fillOpacity: 1}).bindTooltip("Start").addTo(map); + L.circleMarker(last, {radius: 6, color: "#fff", fillColor: "#ff9268", fillOpacity: 1}).bindTooltip("Finish").addTo(map); + const runner = L.marker(first, {interactive: false, icon: L.divIcon({ + className: "dashboard-runner", html: '', iconSize: [20,20], iconAnchor: [10,10], + })}).addTo(map); + // Measure each recorded segment separately so GPS gaps are never drawn across. + let length = 0; + const edges = []; + for (const segment of segments) for (let i = 1; i < segment.length; i++) { + const a = L.latLng(segment[i-1]), b = L.latLng(segment[i]), distance = a.distanceTo(b); + if (distance > 0) { edges.push({a, b, start: length, end: length + distance}); length += distance; } + } + if (!length) { status.textContent = "Recorded GPS location. No movement to replay."; return; } + const preference = window.matchMedia("(prefers-reduced-motion: reduce)"); + let paused = preference.matches, elapsed = 0, previous = null, frame = null; + const tick = now => { + if (paused || document.hidden) { previous = null; frame = null; return; } + if (previous !== null) elapsed += now - previous; + previous = now; + const distance = ((elapsed % 20000) / 20000) * length; + const edge = edges.find(edge => edge.end >= distance) || edges.at(-1); + const fraction = (distance - edge.start) / (edge.end - edge.start); + runner.setLatLng([edge.a.lat + (edge.b.lat-edge.a.lat)*fraction, edge.a.lng + (edge.b.lng-edge.a.lng)*fraction]); + frame = requestAnimationFrame(tick); + }; + const update = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; previous = null; + toggle.textContent = paused ? "Play replay" : "Pause replay"; + status.textContent = paused ? "Replay paused. Explore the recorded route." : "Following the recorded route · 20-second replay"; + if (!paused && !document.hidden) frame = requestAnimationFrame(tick); + }; + toggle.hidden = false; + toggle.onclick = () => { paused = !paused; update(); }; + preference.addEventListener("change", event => { paused = event.matches; update(); }); + document.addEventListener("visibilitychange", update); + update(); + } catch { + status.textContent = "Couldn’t load the route. Refresh to try again."; + } +} +showDashboardRoute(); diff --git a/app/static/style.css b/app/static/style.css index 5127664..8856b3e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,4 +1,4 @@ -:root{--ink:#263d32;--muted:#7d847b;--paper:#f6f7f2;--line:#e5e8df;--green:#294f3a;--lime:#dbea9e;--white:#fff;--radius:15px}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,"Segoe UI",Arial,sans-serif;font-size:14px;line-height:1.5}a{color:inherit;text-decoration:none}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer}a:focus-visible,button:focus-visible{outline:3px solid #97b350;outline-offset:4px}button{border:0}input,select{min-width:0}h1,h2,h3,p{margin:0}h1{font-size:38px;line-height:1.18;letter-spacing:-1.4px;font-weight:600}h2{font-size:21px;font-weight:600;letter-spacing:-.6px}h3{font-size:19px;font-weight:600}small{font-size:12px}.sidebar{position:fixed;top:0;bottom:0;left:0;width:236px;background:#f0f2e9;border-right:1px solid var(--line);padding:33px 23px;display:flex;flex-direction:column;z-index:1000}.brand{font-size:34px;font-weight:750;letter-spacing:-1.8px;display:flex;align-items:center;gap:11px}.brand-mark{display:inline-flex;background:var(--ink);color:var(--lime);width:35px;height:35px;align-items:center;justify-content:center;border-radius:11px;font-size:31px;line-height:1;font-style:italic;letter-spacing:-4px;padding-right:4px}.brand-dot{color:#9aad5b;margin-left:-10px}.sidebar-label{font-size:9px;letter-spacing:1.7px;color:#89917c;margin:42px 12px 17px}.nav-item{display:flex;align-items:center;gap:12px;border-radius:9px;padding:12px;margin:5px 0;color:#717967;font-weight:500;font-size:13px}.nav-item span{font-size:19px;width:20px;text-align:center}.nav-item:hover{background:#e7ebdc}.nav-item.active{color:var(--green);background:#e1e8d0}.sidebar-note{position:relative;margin-top:auto;padding:25px 12px 80px;overflow:hidden}.sidebar-note p{font-size:21px;letter-spacing:-.7px;line-height:1.5;margin-top:12px}.tiny-label,.eyebrow{font-size:10px;font-weight:600;letter-spacing:1.7px}.tiny-label{color:#89917c}.trail-art{position:absolute;bottom:0;width:180px;left:5px;opacity:.6}.trail-art path{stroke:#a7b785;stroke-width:1.3;fill:none}.sidebar-bottom{border-top:1px solid #dce2d0;padding-top:23px;margin-top:30px}.account{display:flex;gap:10px;align-items:center}.avatar{background:#dce5c8;color:var(--green);border-radius:50%;width:36px;height:36px;display:grid;place-items:center;font-weight:600}.account strong{display:block;font-size:12px;max-width:130px;overflow:hidden;text-overflow:ellipsis}.account small{color:var(--muted);font-size:10px;display:block}.text-button{padding:0;background:none;color:var(--muted)}.logout{font-size:11px;margin:12px 0 0 47px}.muted{color:var(--muted)}main{margin-left:236px;min-height:100vh}.topbar{height:78px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 42px}.breadcrumb{font-size:10px;letter-spacing:1.3px;color:#7c8577}.breadcrumb span{margin:0 12px;color:#b9c0b3}.local-badge{font-size:11px;color:#7c8577;display:flex;gap:8px;align-items:center}.local-badge i{background:#77965f;width:5px;height:5px;border-radius:50%}.page{padding:36px 42px 20px;max-width:1590px;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:22px;margin:0 0 30px}.eyebrow{color:#819075;display:block;margin-bottom:10px}.page-heading p{margin-top:11px;color:var(--muted);font-size:13px}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:12px 18px;border-radius:8px;font-size:12px;font-weight:600;white-space:nowrap;transition:background .15s}.button.primary{background:var(--green);color:#fff}.button.primary:hover{background:#376348}.button.secondary{background:#edf1e4;border:1px solid #d9e0cf}.button.full{width:100%;justify-content:space-between;margin-top:3px}.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:17px;margin-bottom:24px}.stat-card{background:white;border:1px solid var(--line);border-radius:var(--radius);padding:22px 23px}.stat-card.featured{background:var(--green);color:white;border-color:var(--green)}.stat-label{font-size:9px;font-weight:600;letter-spacing:1px;display:flex;justify-content:space-between;align-items:center;color:#85907e}.stat-label span{font-size:17px;line-height:1}.featured .stat-label{color:#cfdeb7}.stat-value{font-size:39px;font-weight:500;letter-spacing:-1.9px;line-height:1.6;font-variant-numeric:tabular-nums;margin-top:5px;white-space:nowrap}.stat-value small{font-size:14px;letter-spacing:0;margin-left:7px;font-weight:400;color:#87937e}.featured .stat-value small{color:#cedebc}.stat-card p{font-size:10px;color:#8a9381;margin-top:3px}.featured p{color:#c5d4b2}.time-value{font-size:34px;line-height:1.84}.panel{background:#fff;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.overview-grid{display:grid;grid-template-columns:minmax(0,2.5fr) minmax(225px,1fr);gap:22px;margin-bottom:28px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 25px 14px}.section-head .eyebrow{font-size:9px;margin-bottom:5px}.subtle-label{font-size:10px;color:#8e9589;white-space:nowrap}.chart-wrap{height:225px;padding:15px 24px 24px}.milestone-card{background:#e9eddc;border:1px solid #dfe5d0;border-radius:var(--radius);padding:26px;position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;min-height:310px}.milestone-card>.eyebrow{position:absolute;top:27px;font-size:9px}.mountain{position:absolute;top:66px;left:0;width:100%;opacity:.8}.mountain path{fill:none;stroke:#a4b68b;stroke-width:1.2}.mountain circle{fill:#d5dda9}.milestone-number{font-size:39px;letter-spacing:-1.5px;position:relative;line-height:1.4}.milestone-number small{font-size:16px;margin-left:6px}.milestone-card h3{font-size:17px}.milestone-card p{font-size:11px;color:#7e8a72;margin-top:5px}.activities-panel>.section-head{padding-bottom:23px}.activity-table-wrap{overflow-x:auto}.activity-table{width:100%;border-collapse:collapse;white-space:nowrap}.activity-table th{text-align:left;background:#fafbf7;border-top:1px solid #f0f1eb;border-bottom:1px solid #f0f1eb;padding:13px 16px;font-size:9px;font-weight:500;color:#8d9585;letter-spacing:1px}.activity-table th:first-child{padding-left:25px}.activity-table td{padding:18px 16px;font-size:12px;border-bottom:1px solid #eef0e9;font-variant-numeric:tabular-nums}.activity-table tr:last-child td{border-bottom:none}.activity-table td:first-child{padding-left:25px}.activity-table td small{font-size:10px;color:#8a9481}.activity-name{display:flex;align-items:center;gap:12px}.activity-name strong{font-weight:550;display:block;font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis}.activity-name small{display:block;margin-top:4px}.activity-name small span{margin:0 5px}.sport-icon{display:grid;place-items:center;border-radius:10px;background:#edf2e5;color:#638052;width:36px;height:36px;font-size:20px;flex-shrink:0}.activity-table tr:hover td{background:#fcfdf9}.pill{display:inline-flex;padding:4px 10px;border-radius:5px;font-size:10px;background:#eff0eb;color:#7d8475}.pill.public,.pill.friends{background:#e9f0dd;color:#607746}.row-arrow{color:#81916f;font-size:19px}.athlete-link{color:#647e4f}.footer{display:flex;justify-content:space-between;margin-top:38px;padding-top:19px;border-top:1px solid var(--line);font-size:9px;color:#99a08f;letter-spacing:.8px}.footer span:last-child{letter-spacing:0}.empty-state{text-align:center;padding:65px 25px;color:var(--muted)}.empty-state h1,.empty-state h3{color:var(--ink);margin:12px 0}.empty-state p{margin-bottom:22px}.empty-symbol{font-size:30px;color:#819569}.auth-layout{display:grid;grid-template-columns:1fr 450px;gap:45px;align-items:center;min-height:680px}.auth-intro{position:relative;padding:30px 0}.auth-intro h1{font-size:clamp(42px,4vw,61px);line-height:1.15;letter-spacing:-2.4px}.auth-intro h1 em{font-style:normal;color:#8da65f}.auth-intro p{margin-top:25px;color:#858e7b;line-height:1.8;font-size:14px}.auth-art{margin-top:20px;width:100%;max-width:380px}.auth-art path{fill:none;stroke:#cad5b2;stroke-width:2}.auth-art circle{fill:#a2b66c;stroke:#f6f7f2;stroke-width:4}.auth-card{padding:32px}.auth-card h2{font-size:26px}.auth-card>p{font-size:12px;margin:12px 0 24px;line-height:1.7}.stack{display:flex;flex-direction:column;gap:17px}.stack label{display:flex;flex-direction:column;gap:7px;font-size:11px;font-weight:600;color:#59694f}.stack input:not([type=checkbox]),.stack select{border:1px solid #dee4d6;background:#fcfdf9;padding:12px;border-radius:7px;width:100%;color:var(--ink);font-size:12px;outline:none}.stack input:focus,.stack select:focus{border-color:#8da65f;box-shadow:0 0 0 3px #eaf0df}.stack input::placeholder{color:#a1a899}.stack .checkbox-label{flex-direction:row;align-items:center;gap:9px;font-weight:400}.checkbox-label input{accent-color:var(--green);width:16px;height:16px}.auth-foot{text-align:center;font-size:11px!important;margin-bottom:12px!important}.auth-foot a{color:var(--green);text-decoration:underline}.auth-card>small{display:block;text-align:center;font-size:10px}.form-error,.flash.error{background:#fff0e9;color:#99573b;border:1px solid #f0d8c7;border-radius:7px;padding:12px}.flash{background:#e9f0dd;color:#587644;border:1px solid #d6e3c6;padding:13px 18px;border-radius:8px;margin-bottom:22px;font-size:12px}.back-link{display:inline-block;color:#7c8d70;margin-bottom:24px;font-size:11px}.map-panel{margin-bottom:23px}.map-panel .section-head{padding-bottom:20px}#route-map{height:370px;background:#edf1e5;position:relative;z-index:1}.map-placeholder{position:absolute;left:0;right:0;top:45%;text-align:center;color:#87957c}.map-status{font-size:10px;color:#87957c;padding:9px 25px;min-height:30px}.detail-charts{display:grid;grid-template-columns:1fr 1fr;gap:23px;margin-bottom:23px}.detail-charts .chart-wrap{height:220px}.detail-bottom{display:grid;grid-template-columns:1.5fr 1fr;gap:23px}.splits-table{width:100%;border-collapse:collapse;font-size:12px}.splits-table th{font-size:9px;color:#86937a;font-weight:500;letter-spacing:.5px}.splits-table td,.splits-table th{padding:12px 18px;text-align:left;border-bottom:1px solid #eff1e9;font-variant-numeric:tabular-nums}.splits-table tr:last-child td{border:0}.publishing-panel{padding:25px;align-self:start}.publishing-panel h2{margin-bottom:22px}.field-help{color:#8a9481;font-size:11px;line-height:1.7;font-weight:400}.field-help a{text-decoration:underline}.dot-separator{margin:0 7px}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;max-width:1100px}.settings-card{padding:29px}.settings-card h2{margin-bottom:22px}.settings-card>p{margin-top:-6px;margin-bottom:22px;font-size:12px}.upload-zone{border:1px dashed #becdad;border-radius:10px;padding:25px;background:#f8faf4;align-items:center;text-align:center}.upload-icon{font-size:28px;color:#7c965e;font-weight:400}.upload-zone small{font-size:10px;font-weight:400;color:#8b977f}.upload-zone input{background:white!important;margin-top:12px}.connection-card{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:30px}.connection-card h2{margin-bottom:10px}.connection-card p{font-size:12px;line-height:1.8}.connection-actions .field-help{font-size:11px;margin:12px 0}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)} +:root{--ink:#eeeae4;--muted:#9ba4a5;--paper:#101416;--line:#303638;--green:#f48459;--lime:#15191b;--white:#1a1f22;--radius:15px}*{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font-family:Inter,"Segoe UI",Arial,sans-serif;font-size:14px;line-height:1.5}a{color:inherit;text-decoration:none}button,input,select{font:inherit}button,a,input,select{touch-action:manipulation}button{cursor:pointer}a:focus-visible,button:focus-visible{outline:3px solid #97b350;outline-offset:4px}button{border:0}input,select{min-width:0}h1,h2,h3,p{margin:0}h1{font-size:38px;line-height:1.18;letter-spacing:-1.4px;font-weight:600}h2{font-size:21px;font-weight:600;letter-spacing:-.6px}h3{font-size:19px;font-weight:600}small{font-size:12px}.sidebar{position:fixed;top:0;bottom:0;left:0;width:236px;background:#14181a;border-right:1px solid var(--line);padding:33px 23px;display:flex;flex-direction:column;z-index:1000}.brand{font-size:34px;font-weight:750;letter-spacing:-1.8px;display:flex;align-items:center;gap:11px}.brand-mark{display:inline-flex;background:var(--ink);color:var(--lime);width:35px;height:35px;align-items:center;justify-content:center;border-radius:11px;font-size:31px;line-height:1;font-style:italic;letter-spacing:-4px;padding-right:4px}.brand-dot{color:#9aad5b;margin-left:-10px}.sidebar-label{font-size:9px;letter-spacing:1.7px;color:#89917c;margin:42px 12px 17px}.nav-item{display:flex;align-items:center;gap:12px;border-radius:9px;padding:12px;margin:5px 0;color:#a2aaac;font-weight:500;font-size:13px}.nav-item span{font-size:19px;width:20px;text-align:center}.nav-item:hover{background:#242b2e}.nav-item.active{color:var(--green);background:#2e2622}.sidebar-note{position:relative;margin-top:auto;padding:25px 12px 80px;overflow:hidden}.sidebar-note p{font-size:21px;letter-spacing:-.7px;line-height:1.5;margin-top:12px}.tiny-label,.eyebrow{font-size:10px;font-weight:600;letter-spacing:1.7px}.tiny-label{color:#89917c}.trail-art{position:absolute;bottom:0;width:180px;left:5px;opacity:.6}.trail-art path{stroke:#a7b785;stroke-width:1.3;fill:none}.sidebar-bottom{border-top:1px solid #303638;padding-top:23px;margin-top:30px}.account{display:flex;gap:10px;align-items:center}.avatar{background:#35302b;color:var(--green);border-radius:50%;width:36px;height:36px;display:grid;place-items:center;font-weight:600}.account strong{display:block;font-size:12px;max-width:130px;overflow:hidden;text-overflow:ellipsis}.account small{color:var(--muted);font-size:10px;display:block}.text-button{padding:0;background:none;color:var(--muted)}.logout{font-size:11px;margin:12px 0 0 47px}.muted{color:var(--muted)}main{margin-left:236px;min-height:100vh}.topbar{height:78px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 42px}.breadcrumb{font-size:10px;letter-spacing:1.3px;color:#7c8577}.breadcrumb span{margin:0 12px;color:#b9c0b3}.local-badge{font-size:11px;color:#7c8577;display:flex;gap:8px;align-items:center}.local-badge i{background:#77965f;width:5px;height:5px;border-radius:50%}.page{padding:36px 42px 20px;max-width:1590px;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:22px;margin:0 0 30px}.eyebrow{color:#819075;display:block;margin-bottom:10px}.page-heading p{margin-top:11px;color:var(--muted);font-size:13px}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:12px 18px;border-radius:8px;font-size:12px;font-weight:600;white-space:nowrap;transition:background .15s}.button.primary{background:var(--green);color:#1a1f22}.button.primary:hover{background:#376348}.button.secondary{background:#242b2e;border:1px solid #3c4447}.button.full{width:100%;justify-content:space-between;margin-top:3px}.stats-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:17px;margin-bottom:24px}.stat-card{background:#1a1f22;border:1px solid var(--line);border-radius:var(--radius);padding:22px 23px}.stat-card.featured{background:var(--green);color:#eeeae4;border-color:var(--green)}.stat-label{font-size:9px;font-weight:600;letter-spacing:1px;display:flex;justify-content:space-between;align-items:center;color:#85907e}.stat-label span{font-size:17px;line-height:1}.featured .stat-label{color:#cfdeb7}.stat-value{font-size:39px;font-weight:500;letter-spacing:-1.9px;line-height:1.6;font-variant-numeric:tabular-nums;margin-top:5px;white-space:nowrap}.stat-value small{font-size:14px;letter-spacing:0;margin-left:7px;font-weight:400;color:#87937e}.featured .stat-value small{color:#cedebc}.stat-card p{font-size:10px;color:#8a9381;margin-top:3px}.featured p{color:#c5d4b2}.time-value{font-size:34px;line-height:1.84}.panel{background:#1a1f22;border:1px solid var(--line);border-radius:var(--radius);overflow:hidden}.overview-grid{display:grid;grid-template-columns:minmax(0,2.5fr) minmax(225px,1fr);gap:22px;margin-bottom:28px}.section-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:24px 25px 14px}.section-head .eyebrow{font-size:9px;margin-bottom:5px}.subtle-label{font-size:10px;color:#8e9589;white-space:nowrap}.chart-wrap{height:225px;padding:15px 24px 24px}.milestone-card{background:#1a1f22;border:1px solid #303638;border-radius:var(--radius);padding:26px;position:relative;overflow:hidden;display:flex;flex-direction:column;justify-content:flex-end;min-height:310px}.milestone-card>.eyebrow{position:absolute;top:27px;font-size:9px}.mountain{position:absolute;top:66px;left:0;width:100%;opacity:.8}.mountain path{fill:none;stroke:#a4b68b;stroke-width:1.2}.mountain circle{fill:#d5dda9}.milestone-number{font-size:39px;letter-spacing:-1.5px;position:relative;line-height:1.4}.milestone-number small{font-size:16px;margin-left:6px}.milestone-card h3{font-size:17px}.milestone-card p{font-size:11px;color:#7e8a72;margin-top:5px}.activities-panel>.section-head{padding-bottom:23px}.activity-table-wrap{overflow-x:auto}.activity-table{width:100%;border-collapse:collapse;white-space:nowrap}.activity-table th{text-align:left;background:#151a1c;border-top:1px solid #303638;border-bottom:1px solid #303638;padding:13px 16px;font-size:9px;font-weight:500;color:#8d9585;letter-spacing:1px}.activity-table th:first-child{padding-left:25px}.activity-table td{padding:18px 16px;font-size:12px;border-bottom:1px solid #2a3134;font-variant-numeric:tabular-nums}.activity-table tr:last-child td{border-bottom:none}.activity-table td:first-child{padding-left:25px}.activity-table td small{font-size:10px;color:#8a9481}.activity-name{display:flex;align-items:center;gap:12px}.activity-name strong{font-weight:550;display:block;font-size:13px;max-width:300px;overflow:hidden;text-overflow:ellipsis}.activity-name small{display:block;margin-top:4px}.activity-name small span{margin:0 5px}.sport-icon{display:grid;place-items:center;border-radius:10px;background:#2b2925;color:#638052;width:36px;height:36px;font-size:20px;flex-shrink:0}.activity-table tr:hover td{background:#202629}.pill{display:inline-flex;padding:4px 10px;border-radius:5px;font-size:10px;background:#2b3134;color:#7d8475}.pill.public,.pill.friends{background:#29332c;color:#a8c1ac}.row-arrow{color:#81916f;font-size:19px}.athlete-link{color:#647e4f}.footer{display:flex;justify-content:space-between;margin-top:38px;padding-top:19px;border-top:1px solid var(--line);font-size:9px;color:#99a08f;letter-spacing:.8px}.footer span:last-child{letter-spacing:0}.empty-state{text-align:center;padding:65px 25px;color:var(--muted)}.empty-state h1,.empty-state h3{color:var(--ink);margin:12px 0}.empty-state p{margin-bottom:22px}.empty-symbol{font-size:30px;color:#819569}.auth-layout{display:grid;grid-template-columns:1fr 450px;gap:45px;align-items:center;min-height:680px}.auth-intro{position:relative;padding:30px 0}.auth-intro h1{font-size:clamp(42px,4vw,61px);line-height:1.15;letter-spacing:-2.4px}.auth-intro h1 em{font-style:normal;color:#8da65f}.auth-intro p{margin-top:25px;color:#858e7b;line-height:1.8;font-size:14px}.auth-art{margin-top:20px;width:100%;max-width:380px}.auth-card{padding:32px}.auth-card h2{font-size:26px}.auth-card>p{font-size:12px;margin:12px 0 24px;line-height:1.7}.stack{display:flex;flex-direction:column;gap:17px}.stack label{display:flex;flex-direction:column;gap:7px;font-size:11px;font-weight:600;color:#c2c9c9}.stack input:not([type=checkbox]),.stack select{border:1px solid #3c4447;background:#202629;padding:12px;border-radius:7px;width:100%;color:var(--ink);font-size:12px;outline:none}.stack input:focus,.stack select:focus{border-color:#8da65f;box-shadow:0 0 0 3px #3e3029}.stack input::placeholder{color:#a1a899}.stack .checkbox-label{flex-direction:row;align-items:center;gap:9px;font-weight:400}.checkbox-label input{accent-color:var(--green);width:16px;height:16px}.auth-foot{text-align:center;font-size:11px!important;margin-bottom:12px!important}.auth-foot a{color:var(--green);text-decoration:underline}.auth-card>small{display:block;text-align:center;font-size:10px}.form-error,.flash.error{background:#fff0e9;color:#99573b;border:1px solid #f0d8c7;border-radius:7px;padding:12px}.flash{background:#29332c;color:#587644;border:1px solid #d6e3c6;padding:13px 18px;border-radius:8px;margin-bottom:22px;font-size:12px}.back-link{display:inline-block;color:#7c8d70;margin-bottom:24px;font-size:11px}.map-panel{margin-bottom:23px}.map-panel .section-head{padding-bottom:20px}#route-map{height:370px;background:#171d20;position:relative;z-index:1}.map-placeholder{position:absolute;left:0;right:0;top:45%;text-align:center;color:#87957c}.map-status{font-size:10px;color:#87957c;padding:9px 25px;min-height:30px}.detail-charts{display:grid;grid-template-columns:1fr 1fr;gap:23px;margin-bottom:23px}.detail-charts .chart-wrap{height:220px}.detail-bottom{display:grid;grid-template-columns:1.5fr 1fr;gap:23px}.splits-table{width:100%;border-collapse:collapse;font-size:12px}.splits-table th{font-size:9px;color:#86937a;font-weight:500;letter-spacing:.5px}.splits-table td,.splits-table th{padding:12px 18px;text-align:left;border-bottom:1px solid #303638;font-variant-numeric:tabular-nums}.splits-table tr:last-child td{border:0}.publishing-panel{padding:25px;align-self:start}.publishing-panel h2{margin-bottom:22px}.field-help{color:#8a9481;font-size:11px;line-height:1.7;font-weight:400}.field-help a{text-decoration:underline}.dot-separator{margin:0 7px}.settings-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px;max-width:1100px}.settings-card{padding:29px}.settings-card h2{margin-bottom:22px}.settings-card>p{margin-top:-6px;margin-bottom:22px;font-size:12px}.upload-zone{border:1px dashed #becdad;border-radius:10px;padding:25px;background:#171c1f;align-items:center;text-align:center}.upload-icon{font-size:28px;color:#7c965e;font-weight:400}.upload-zone small{font-size:10px;font-weight:400;color:#8b977f}.upload-zone input{background:#1a1f22!important;margin-top:12px}.connection-card{grid-column:1/-1;display:grid;grid-template-columns:1fr 1fr;gap:30px}.connection-card h2{margin-bottom:10px}.connection-card p{font-size:12px;line-height:1.8}.connection-actions .field-help{font-size:11px;margin:12px 0}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)} .friends-top{margin-bottom:25px;max-width:none}.friend-row{display:flex;align-items:center;justify-content:space-between;gap:15px;padding:15px 0;border-bottom:1px solid var(--line)}.friend-row:last-child{border-bottom:0}.friend-row strong{font-size:13px;display:block}.friend-row small{display:block;font-size:10px;margin-top:5px}.friend-actions{display:flex;align-items:center;gap:15px}.friend-actions button{font-size:11px} @media(max-height:780px) and (min-width:681px){.sidebar-note{display:none}.sidebar-bottom{margin-top:auto}} @media(min-width:1500px){.page{padding-top:45px}.stat-value{font-size:45px}.time-value{font-size:40px}.chart-wrap{height:260px}} @@ -6,5 +6,22 @@ @media(max-width:950px){.stats-grid{grid-template-columns:repeat(2,1fr)}.overview-grid{grid-template-columns:1fr}.milestone-card{min-height:210px}.mountain{width:45%;left:50%;top:20px}.milestone-card>.eyebrow{position:static;margin-bottom:15px}.auth-layout{grid-template-columns:1fr;max-width:480px;margin:auto;min-height:0}.auth-intro{padding:0}.auth-intro h1{font-size:39px}.auth-intro p,.auth-art{display:none}.detail-bottom,.settings-grid{grid-template-columns:1fr}.connection-card{grid-template-columns:1fr}.detail-charts{grid-template-columns:1fr}.page-heading h1{font-size:32px}.page-heading{align-items:flex-start}.activity-table td{padding:15px 12px}} @media(max-width:680px){.sidebar{position:relative;width:100%;height:auto;padding:17px 19px;border-right:0;border-bottom:1px solid var(--line);display:block}.brand{font-size:27px}.brand-mark{width:30px;height:30px;font-size:26px}.sidebar-label,.sidebar-note,.sidebar-bottom{display:none}.sidebar nav{display:flex;gap:5px;margin-top:12px;overflow-x:auto}.nav-item{white-space:nowrap;font-size:10px;padding:7px 10px;margin:0}.nav-item span{display:none}main{margin:0}.topbar{height:49px;padding:0 20px}.local-badge{display:none}.page{padding:25px 18px}.page-heading{flex-direction:column;gap:16px}.page-heading h1{font-size:32px}.page-heading p{font-size:12px}.stats-grid{gap:11px}.stat-card{padding:17px}.stat-value{font-size:32px}.time-value{font-size:27px}.stat-value small{font-size:11px}.stat-label{font-size:8px}.stat-card p{font-size:9px}.section-head{padding:19px;align-items:flex-start}.section-head h2{font-size:18px}.section-head .subtle-label{max-width:110px;text-align:right}.chart-wrap{padding:10px 15px 20px;height:200px}.auth-card{padding:24px}.auth-intro h1{font-size:38px}.auth-intro{padding-bottom:10px}.footer{font-size:8px;gap:10px}.footer span:last-child{display:none}#route-map{height:290px}.publishing-panel,.settings-card{padding:22px}.splits-table td,.splits-table th{padding:10px;font-size:10px}} -.activity-nav{display:flex;gap:22px;overflow-x:auto;margin:-8px 0 25px;border-bottom:1px solid var(--line);padding-bottom:13px;font-size:12px;white-space:nowrap}.activity-nav a:hover{color:#67934c}.run-map .section-head{flex-wrap:wrap}.map-controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:11px}.map-controls label{display:flex;gap:8px;align-items:center}.map-controls select{padding:8px;border:1px solid var(--line);border-radius:6px;color:var(--ink);background:#f6f7f2}.directions-toggle input{accent-color:var(--green)}.run-map #route-map{height:440px;background:#ecf0e7}.route-legend{display:flex;align-items:center;gap:10px;padding:17px 25px 0;font-size:11px;flex-wrap:wrap}.legend-ramp{height:8px;width:180px;border-radius:8px}.legend-note{margin-left:auto;color:var(--muted);font-size:10px}.route-arrow span{display:block;color:white;font-size:18px;line-height:20px;text-shadow:0 0 3px #263d32,0 1px 2px #263d32}.route-end{background:#263d32;color:white;border:2px solid white;border-radius:50%;text-align:center;font-weight:700;line-height:21px;box-shadow:0 1px 6px #263d3266}.route-F{background:#ac654f}.performance-section{margin-top:34px}.section-title{margin:0 0 20px}.section-title p{margin-top:7px;font-size:12px;color:var(--muted)}.metrics-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-bottom:22px}.metric-tile{padding:20px 23px;background:#fff}.metric-tile>span{font-size:9px;letter-spacing:1px;color:var(--muted)}.metric-tile strong{display:block;font-weight:500;font-size:29px;letter-spacing:-1px;font-variant-numeric:tabular-nums;margin:5px 0}.metric-tile small,.terrain-grid small{font-size:12px;font-weight:400;margin-left:7px;letter-spacing:0;color:var(--muted)}.metric-tile p{font-size:10px;color:var(--muted)}.analytics-charts .chart-wrap{height:275px}.analytics-charts .section-head{align-items:flex-start}.analytics-charts h2{font-size:17px}.chart-empty{padding:55px 15px;text-align:center;color:var(--muted);font-size:12px}.terrain-panel{margin:25px 0}.section-description{padding:0 25px;color:var(--muted);font-size:12px;max-width:850px}.terrain-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:22px 25px;gap:20px}.terrain-grid article{padding:17px;background:#f6f8f0;border-radius:10px}.terrain-grid article>span{font-size:12px}.terrain-grid strong{display:block;font-size:30px;font-weight:500;margin:7px 0}.terrain-grid article>div{font-size:11px;color:var(--muted)}.terrain-grid article>div span{margin:0 7px}.methodology{margin-top:25px;padding:22px 25px;font-size:12px;color:var(--muted)}.methodology summary{cursor:pointer;color:var(--ink);font-weight:600}.methodology summary span{float:right;font-weight:400;font-size:11px}.methodology p{margin-top:16px;max-width:1000px;line-height:1.8}.methodology a{text-decoration:underline}section[id],details[id]{scroll-margin-top:20px} +.activity-nav{display:flex;gap:22px;overflow-x:auto;margin:-8px 0 25px;border-bottom:1px solid var(--line);padding-bottom:13px;font-size:12px;white-space:nowrap}.activity-nav a:hover{color:#67934c}.run-map .section-head{flex-wrap:wrap}.map-controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap;font-size:11px}.map-controls label{display:flex;gap:8px;align-items:center}.map-controls select{padding:8px;border:1px solid var(--line);border-radius:6px;color:var(--ink);background:#101416}.directions-toggle input{accent-color:var(--green)}.run-map #route-map{height:440px;background:#171d20}.route-legend{display:flex;align-items:center;gap:10px;padding:17px 25px 0;font-size:11px;flex-wrap:wrap}.legend-ramp{height:8px;width:180px;border-radius:8px}.legend-note{margin-left:auto;color:var(--muted);font-size:10px}.route-arrow span{display:block;color:#eeeae4;font-size:18px;line-height:20px;text-shadow:0 0 3px #eeeae4,0 1px 2px #eeeae4}.route-end{background:#eeeae4;color:#eeeae4;border:2px solid white;border-radius:50%;text-align:center;font-weight:700;line-height:21px;box-shadow:0 1px 6px #263d3266}.route-F{background:#ac654f}.performance-section{margin-top:34px}.section-title{margin:0 0 20px}.section-title p{margin-top:7px;font-size:12px;color:var(--muted)}.metrics-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-bottom:22px}.metric-tile{padding:20px 23px;background:#1a1f22}.metric-tile>span{font-size:9px;letter-spacing:1px;color:var(--muted)}.metric-tile strong{display:block;font-weight:500;font-size:29px;letter-spacing:-1px;font-variant-numeric:tabular-nums;margin:5px 0}.metric-tile small,.terrain-grid small{font-size:12px;font-weight:400;margin-left:7px;letter-spacing:0;color:var(--muted)}.metric-tile p{font-size:10px;color:var(--muted)}.analytics-charts .chart-wrap{height:275px}.analytics-charts .section-head{align-items:flex-start}.analytics-charts h2{font-size:17px}.chart-empty{padding:55px 15px;text-align:center;color:var(--muted);font-size:12px}.terrain-panel{margin:25px 0}.section-description{padding:0 25px;color:var(--muted);font-size:12px;max-width:850px}.terrain-grid{display:grid;grid-template-columns:repeat(3,1fr);padding:22px 25px;gap:20px}.terrain-grid article{padding:17px;background:#202629;border-radius:10px}.terrain-grid article>span{font-size:12px}.terrain-grid strong{display:block;font-size:30px;font-weight:500;margin:7px 0}.terrain-grid article>div{font-size:11px;color:var(--muted)}.terrain-grid article>div span{margin:0 7px}.methodology{margin-top:25px;padding:22px 25px;font-size:12px;color:var(--muted)}.methodology summary{cursor:pointer;color:var(--ink);font-weight:600}.methodology summary span{float:right;font-weight:400;font-size:11px}.methodology p{margin-top:16px;max-width:1000px;line-height:1.8}.methodology a{text-decoration:underline}section[id],details[id]{scroll-margin-top:20px} @media(max-width:680px){.metrics-strip{grid-template-columns:repeat(2,minmax(0,1fr))}.metric-tile{padding:15px}.metric-tile strong{font-size:25px}.terrain-grid{grid-template-columns:1fr;gap:10px;padding:18px}.terrain-grid article{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}.terrain-grid strong{font-size:24px}.run-map #route-map{height:340px}.legend-note{margin-left:0;width:100%}.route-legend{padding:15px 18px 0}.map-controls{gap:10px}.methodology summary span{display:none}.analytics-charts .chart-wrap{height:250px}} +/* Stride: charcoal, warm signal orange, compact instrument typography. */ +:root{color-scheme:dark;--ink:#eeeae4;--muted:#a0a9ac;--paper:#101416;--line:#303638;--green:#ff9268;--lime:#ff9268;--white:#1a1f22;--radius:9px} +body{font-family:"Segoe UI",Arial,sans-serif}h1{font-weight:650;letter-spacing:-1.5px}h2{letter-spacing:-.4px}.icon{width:20px;height:20px;flex-shrink:0;vertical-align:middle}.button .icon{width:16px;height:16px}.sidebar{width:220px;padding:30px 20px}.brand{color:#f1ece6;font-size:32px}.brand-mark{background:#ff8659;color:#15191b;border-radius:5px;font-weight:800}.brand-dot{color:#ff8659}.sidebar-label,.tiny-label{color:#a0aaac}.nav-item{border-radius:5px;gap:12px}.nav-item.active{color:#ff9a74;border-left:2px solid #ff8659;padding-left:10px}.nav-item:hover{color:#f1ece6}.sidebar-note{padding:24px 0 0;overflow:visible}.sidebar-note .tiny-label{font-size:8px;letter-spacing:1.5px}.sidebar-bottom{margin-top:25px}.avatar{color:#ffc1a7}.account small,.breadcrumb,.local-badge{color:#a0aaac}.local-badge i{background:#ff8659}main{margin-left:220px}.topbar{height:68px}.eyebrow{color:#a2acae;letter-spacing:1.6px;font-size:9px}.button{border-radius:5px;gap:9px}.button.primary{background:#ff8659;color:#161a1c}.button.primary:hover{background:#ffa078}.button.secondary{color:#e0e5e4}.page-heading{margin-bottom:25px}.page-heading p{color:#a0a9ac}.stat-card{border-radius:7px;padding:21px}.stat-card.featured{background:#24211f;border-color:#72503e;color:#f5eae1}.stat-card .stat-label,.featured .stat-label{color:#aeb6b7}.stat-card p,.featured p{color:#a0a9ac}.stat-value{font-weight:500;letter-spacing:-1.5px;font-size:38px;line-height:1.65}.featured .stat-value{color:#ff9a74}.stat-value small,.featured .stat-value small{color:#abb3b4}.time-value{font-size:32px;line-height:1.96}.stat-label .icon{width:17px;height:17px;color:#98a3a6}.featured .stat-label .icon{color:#ff9268}.overview-grid{grid-template-columns:minmax(0,3fr) minmax(225px,1fr)}.section-head h2{font-size:19px}.subtle-label{color:#a0aaac}.milestone-card{min-height:290px;justify-content:flex-start;padding:25px}.milestone-card>.eyebrow{position:static}.milestone-number{font-size:46px;color:#ff9a74;margin-top:15px}.milestone-card h3{font-size:13px;color:#c6cecf}.milestone-card p{color:#a0aaac;margin-top:auto}.elevation-profile{margin:16px -25px 4px}.elevation-profile svg{width:100%;height:70px}.elevation-profile path{fill:none;stroke:#cc8868;stroke-width:1.5}.activity-table th{color:#a0aaac;font-size:9px}.activity-table td small{color:#a0aaac}.activity-name strong{font-weight:600}.sport-icon{background:#2e2824;color:#ffa37e;border-radius:5px}.pill{color:#b2bbbd;border:1px solid #3c4447}.pill.public,.pill.friends{color:#b4ccba}.row-arrow,.athlete-link,.back-link{color:#f3ad8d}.footer{color:#929da0}.stack label{color:#c5cccc}.stack input:focus,.stack select:focus{border-color:#ff9268;box-shadow:0 0 0 3px #ff92681a}.field-help,.upload-zone small{color:#a0aaac}.upload-zone{border-color:#596165}.upload-zone input{background:#202629!important}.upload-zone input::file-selector-button{background:#353e42;color:#eaece9;border:0;padding:8px 12px;margin-right:12px;border-radius:4px}.auth-foot a{color:#ff9268}.auth-intro h1 em{color:#ff9268}.auth-intro p{color:#a0aaac}.auth-art{max-width:540px}.auth-art .running-map{height:250px}.flash{background:#25362d;color:#c6dfcb;border-color:#405849}.form-error,.flash.error{background:#3c2824;color:#ffc2af;border-color:#795044}.map-controls select{background:#202629}.metric-tile{background:#1a1f22}.leaflet-tile-pane{filter:invert(1) hue-rotate(180deg) saturate(.45) brightness(.8)}.leaflet-container{background:#171d20}.leaflet-control-zoom a{background:#202629!important;color:#eeeae4!important;border-color:#485154!important}.leaflet-control-attribution{background:#1a1f22e6!important;color:#bbc5c7}.leaflet-control-attribution a{color:#ffa37e}.map-status{color:#a0aaac}.map-controls .button{color:#eeeae4}.running-map{position:relative;height:260px;overflow:hidden;background:#171d20;border:1px solid #343c3f;border-radius:8px}.running-map>svg{display:block;width:100%;height:100%}.map-route-glow{fill:none;stroke:#ff8659;stroke-width:14;opacity:.09;stroke-linejoin:round}.map-route{fill:none;stroke:#ff956a;stroke-width:3;stroke-linejoin:round;stroke-linecap:round}.map-runner{offset-path:path('M166 215V52H430L475 112 449 175H290L250 128H166V215');animation:run-route 18s linear infinite;offset-rotate:0deg}.map-caption{position:absolute;bottom:0;left:0;right:0;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;background:#14191be8;font:9px monospace;letter-spacing:1.2px;color:#e6e9e4}.map-caption i{display:inline-block;width:5px;height:5px;background:#ff956a;border-radius:50%;margin-right:7px}.map-caption small{margin-left:14px;font:9px "Segoe UI",sans-serif;color:#a6b0b2;letter-spacing:.3px}.dashboard-map{position:relative;margin-bottom:24px}.dashboard-map .running-map{height:255px}.dashboard-map .running-map>svg{width:72%;margin-left:auto}.dashboard-map .running-map:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,#171d20 26%,transparent 58%)}.dashboard-map .map-caption{z-index:2}.map-heading{position:absolute;top:27px;left:27px;pointer-events:none}.map-heading .eyebrow{color:#ffac89;font-size:8px}.map-heading h2{font-size:31px;line-height:1.12;font-weight:600;letter-spacing:-1px}.map-heading p{font-size:11px;margin-top:12px;color:#acb6b7}.compact-map{height:120px;margin-top:14px;border-radius:5px}.compact-map .map-caption{padding:7px}.compact-map .map-caption>span{font-size:7px;letter-spacing:.3px}.compact-map .map-caption small{display:none}.auth-art .map-route{stroke:#ff956a;stroke-width:3}.auth-art .map-route-glow{stroke:#ff8659;stroke-width:14} +@keyframes run-route{to{offset-distance:100%}} +@media(prefers-reduced-motion:reduce){.map-runner{animation:none}} +@media(max-width:1180px){.sidebar{width:205px}main{margin-left:205px}.stat-value{font-size:32px}.time-value{font-size:28px}} +@media(max-width:950px){.overview-grid{grid-template-columns:1fr}.milestone-card{min-height:200px}.elevation-profile{position:absolute;right:25px;top:55px;width:45%}.milestone-card p{margin-top:10px}} +@media(max-width:680px){.sidebar{width:100%;padding:16px 18px}main{margin-left:0}.nav-item .icon{width:15px;height:15px}.nav-item{gap:6px}.topbar{height:45px}.dashboard-map .running-map{height:250px}.dashboard-map .running-map>svg{width:100%;opacity:.55}.dashboard-map .running-map:after{background:linear-gradient(90deg,#171d20d9,transparent)}.map-heading{left:20px;top:22px}.map-heading h2{font-size:28px}.map-caption small{display:none}.stat-card{padding:16px}.stat-value{font-size:30px}.time-value{font-size:25px}.auth-art{display:block}.auth-art .running-map{height:175px}.auth-layout{gap:22px}.auth-intro h1{font-size:38px}.nav-item.active{padding-left:8px}} + +.activity-actions{display:flex;align-items:center;gap:12px;flex-shrink:0}.copy-link-button{width:44px;height:44px;padding:0;justify-content:center}.copy-link-button:disabled{opacity:.55;cursor:wait}.share-feedback{margin-bottom:16px;color:var(--green);font-size:13px}.share-feedback:has(p:empty):has(label[hidden]){display:none}.share-feedback input{display:block;width:100%;margin-top:8px;padding:10px;background:var(--white);color:var(--ink);border:1px solid var(--line);border-radius:5px}.share-feedback label{display:block;margin-top:8px}.share-feedback [hidden],#revoke-share[hidden]{display:none}#revoke-share{margin-top:16px} +.dashboard-route{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);overflow:hidden;margin-bottom:24px}.dashboard-route-info{padding:26px;display:flex;flex-direction:column;align-items:flex-start;gap:13px}.dashboard-route-info h2{font-size:28px;line-height:1.2}.dashboard-route-info>p{color:var(--muted);font-size:12px}.dashboard-route-info .field-help{margin-top:auto}.dashboard-route-info [hidden]{display:none}#dashboard-route-map{min-height:310px;background:#171d20;z-index:0}.dashboard-runner{display:grid;place-items:center}.dashboard-runner span{display:block;width:14px;height:14px;border:3px solid #fff;border-radius:50%;background:#ff8659;box-shadow:0 0 0 7px #ff865944,0 2px 8px #0008}@media(max-width:680px){.dashboard-route{grid-template-columns:1fr}.dashboard-route-info{padding:20px}.dashboard-route-info h2{font-size:24px}#dashboard-route-map{min-height:280px}} +/* Public entry pages use a landing layout, independent of the training sidebar. */ +.public-shell main{margin-left:0;min-height:calc(100vh - 100px)}.landing-header{max-width:1280px;margin:auto;padding:28px 42px;display:flex;align-items:center;justify-content:space-between;gap:20px}.public-shell .page{max-width:1280px}.public-shell .auth-layout{grid-template-columns:minmax(0,1.2fr) minmax(340px,440px);gap:70px;align-items:center;min-height:calc(100vh - 245px)}.public-shell .auth-intro{padding:20px 0}.public-shell .auth-intro h1{font-size:clamp(48px,6vw,80px);line-height:1.05;letter-spacing:-3px}.public-shell .auth-intro>p{display:block;font-size:17px;line-height:1.8;margin-top:25px}.landing-features{padding:0;margin:34px 0 0;list-style:none;display:flex;flex-direction:column;gap:17px;color:var(--muted);font-size:13px}.landing-features li{display:flex;gap:12px;align-items:center}.landing-features .icon{color:var(--green)}@media(max-width:950px){.public-shell .auth-layout{grid-template-columns:1fr;max-width:560px;gap:28px;min-height:0}.public-shell .auth-intro h1{font-size:56px}.landing-header{padding:22px 25px}.landing-features{flex-direction:row;flex-wrap:wrap;gap:18px;font-size:11px;margin-top:22px}}@media(max-width:680px){.landing-header{padding:20px 18px}.landing-header .button{padding:10px 12px}.public-shell .auth-intro h1{font-size:48px}.public-shell .auth-intro>p{font-size:15px;margin-top:18px}.public-shell .auth-layout{gap:22px}} + +.public-shell .landing-map{display:block;max-width:none;margin-top:26px}.public-shell .landing-map .running-map{height:210px}.public-shell .landing-features{flex-direction:row;flex-wrap:wrap;gap:16px;margin-top:22px;font-size:11px}@media(max-width:680px){.public-shell .landing-map .running-map{height:175px}} +/* Keep account controls at the bottom without a decorative sidebar map. */ +.sidebar .sidebar-bottom{margin-top:auto} diff --git a/app/sync.py b/app/sync.py new file mode 100644 index 0000000..1dd8847 --- /dev/null +++ b/app/sync.py @@ -0,0 +1,170 @@ +"""Durable sync requests; shared filesystem locks serialize each user's session.""" + +import json +import logging +import time +from datetime import UTC, datetime, timedelta +from pathlib import Path + +import click +from cryptography.fernet import Fernet +from filelock import FileLock, Timeout +from flask import current_app +from garminconnect import Garmin, GarminConnectAuthenticationError + +from garmin_sync.service import SyncError, account_key, private_directory, sync_activities + +from .extensions import db +from .models import GarminConnection, User +from .services import import_cache + + +def cipher(): + key = current_app.config.get("TOKEN_ENCRYPTION_KEY") + if not key: + raise SyncError("Garmin connections are not configured on this server.") + return Fernet(key) + + +def session_lock(user_id): + directory = Path(current_app.instance_path) / "sync-locks" + private_directory(directory) + return FileLock(str(directory / f"{user_id}.lock"), timeout=0) + + +def connect(user, payload): + if len(payload) > 65536: + raise SyncError("Token file must be smaller than 64 KB.") + try: + data = json.loads(payload) + keys = ("di_token", "di_refresh_token", "di_client_id") + if not isinstance(data, dict) or not all( + isinstance(data.get(k), str) and data[k] for k in keys + ): + raise ValueError() + except (ValueError, TypeError): + raise SyncError("Export a session with garminconnect 0.3.17.") from None + encryption = cipher() + with session_lock(user.id): + api = Garmin() + api.login(json.dumps({k: data[k] for k in keys})) + key = account_key(api, "garmin.com") + other = db.session.scalar(db.select(User).filter_by(local_account_key=key)) + if other and other.id != user.id: + raise SyncError("This Garmin account is already connected.") + connection = db.session.scalar(db.select(GarminConnection).filter_by(user_id=user.id)) + if connection and connection.account_key != key: + raise SyncError("Disconnect the existing Garmin account before replacing it.") + if not connection: + connection = GarminConnection(user_id=user.id, account_key=key) + db.session.add(connection) + connection.tokens = encryption.encrypt(api.client.dumps().encode()).decode() + connection.pending = True + connection.next_sync = datetime.now(UTC) + connection.failures = 0 + connection.message = "Session connected. Sync queued." + user.local_account_key = key + db.session.commit() + + +def run_connection(connection_id): + connection = db.session.get(GarminConnection, connection_id) + if not connection: + return False + try: + with session_lock(connection.user_id): + db.session.refresh(connection) + now = datetime.now(UTC) + due = connection.next_sync.replace(tzinfo=UTC) <= now + if not connection.pending and not (connection.scheduled and due): + return False + api = None + try: + api = Garmin() + api.login(cipher().decrypt(connection.tokens.encode()).decode()) + if account_key(api, "garmin.com") != connection.account_key: + raise GarminConnectAuthenticationError("Account mismatch") + # Persist rotated sessions even if the subsequent download fails. + connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() + db.session.commit() + try: + result = sync_activities( + api, + Path(current_app.config["DATA_ROOT"]) / connection.account_key, + 100, + None, + ) + finally: + connection.tokens = cipher().encrypt(api.client.dumps().encode()).decode() + db.session.commit() + user = db.session.get(User, connection.user_id) + count = import_cache(user, current_app.config["DATA_ROOT"]) + if result["failed"]: + raise SyncError("Some activities failed; sync will retry.") + connection.last_success = now + connection.failures = 0 + connection.message = f"Sync complete. {count} new activities imported." + connection.next_sync = now + timedelta(hours=1) + except Exception as error: # noqa: BLE001 -- do not expose provider credentials + db.session.rollback() + connection = db.session.get(GarminConnection, connection_id) + connection.failures += 1 + connection.pending = False + if isinstance(error, GarminConnectAuthenticationError): + connection.scheduled = False + connection.message = ( + "Session expired. Upload a fresh Garmin session to reconnect." + ) + else: + connection.message = ( + "Sync failed. Retry later or reconnect your Garmin session." + ) + connection.next_sync = now + timedelta( + minutes=min(360, 5 * 2 ** min(connection.failures, 6)) + ) + connection.pending = False + db.session.commit() + return True + except Timeout: + db.session.rollback() + return False + + +def run_due(): + ids = list( + db.session.scalars( + db.select(GarminConnection.id).where( + db.or_( + GarminConnection.pending.is_(True), + db.and_( + GarminConnection.scheduled.is_(True), + GarminConnection.next_sync <= datetime.now(UTC), + ), + ) + ) + ) + ) + for connection_id in ids: + run_connection(connection_id) + return len(ids) + + +def register_cli(app): + @app.cli.command("sync-worker") + @click.option("--once", is_flag=True, help="Process currently due syncs and exit.") + def worker(once): + logging.getLogger("garminconnect").disabled = True + while True: + try: + count = run_due() + click.echo(f"Sync worker heartbeat: {count} due connections.") + except Exception: # noqa: BLE001 -- suppress provider credentials at boundary + db.session.rollback() + click.echo("Sync worker iteration failed; retrying without logging credentials.") + if once: + raise click.ClickException("Sync worker failed.") from None + finally: + db.session.remove() + if once: + break + time.sleep(30) diff --git a/app/templates/_activities.html b/app/templates/_activities.html index 7774ef1..e39db33 100644 --- a/app/templates/_activities.html +++ b/app/templates/_activities.html @@ -1,5 +1,5 @@ {% if rows %}
-{% for row in rows %}{% endfor %} +{% for row in rows %}{% endfor %}
ACTIVITYDISTANCETIMEAVG. PACE{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}Open
↗{{ row.title }}{{ row.started_at|localdate(row.owner.timezone) }} · {{ row.sport|replace('_',' ')|title }}{{ '%.2f'|format(row.distance_km) }} km{{ row.duration_s|duration }}{{ row.pace }} /km{% if personal|default(false) %}{{ row.visibility|title }}{% else %}{{ row.owner.display_name }}{% endif %}↗
{{ icon('run') }}{{ row.title }}{{ row.started_at|localdate(row.owner.timezone) }} · {{ row.sport|replace('_',' ')|title }}{{ '%.2f'|format(row.distance_km) }} km{{ row.duration_s|duration }}{{ row.pace }} /km{% if personal|default(false) %}{{ row.visibility|title }}{% else %}{{ row.owner.display_name }}{% endif %}{{ icon('arrow') }}
-{% else %}
↗

Your next chapter awaits.

{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}

{% if personal|default(false) %}Import your first run ↗{% endif %}
{% endif %} +{% else %}
↗

No activities yet.

{{ 'Import a FIT file or load your Garmin activities to get started.' if personal|default(false) else 'No shared activities just yet. Check back after the next run.' }}

{% if personal|default(false) %}Import your first run {{ icon('arrow') }}{% endif %}
{% endif %} diff --git a/app/templates/_ui.html b/app/templates/_ui.html new file mode 100644 index 0000000..21d37b6 --- /dev/null +++ b/app/templates/_ui.html @@ -0,0 +1,22 @@ +{% macro icon(name) -%} + +{%- endmacro %} +{% macro running_map(compact=false) -%} +
+ + + + + + + +NORTH PARKPARK AVENUERIVER STREETRIVERSIDE + + + + +N + +
RUN THE CITY Illustrated route
+
+{%- endmacro %} diff --git a/app/templates/activity.html b/app/templates/activity.html index f4de249..1f2b8f3 100644 --- a/app/templates/activity.html +++ b/app/templates/activity.html @@ -6,8 +6,9 @@
{{ label }}{{ value }}{{ unit }}{% if note %}

{{ note }}

{% endif %}
{% endmacro %} {% block content %} -← {{ 'Your activities' if owner_view else activity.owner.display_name }} -
{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}

{{ activity.title }}

By {{ activity.owner.display_name }}·{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}·{{ '{:,}'.format(analysis.summary.samples) }} recorded samples

{{ activity.visibility|title }}
+{% if not public_view %}← {{ 'Your activities' if owner_view else activity.owner.display_name }}{% endif %} +
{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}

{{ activity.title }}

By {% if public_view %}{{ activity.owner.display_name }}{% else %}{{ activity.owner.display_name }}{% endif %}·{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}·{{ '{:,}'.format(analysis.summary.samples) }} recorded samples

{{ 'Shared via link' if public_view else activity.visibility|title }}{% if owner_view %}{% endif %}
+{% if owner_view %}{% endif %}
@@ -17,7 +18,7 @@
FOLLOW YOUR FOOTSTEPS

Your run, in colour.

-

Loading your route…

+

Loading your route…

Slower
FasterS Start · F Finish · Grey = no measurement

@@ -42,7 +43,7 @@
HILLS CHANGE THE EFFORT

Heart rate by terrain

Measured HR · not adjusted

See how your recorded heart rate and pace vary uphill, downhill and on the flat. Heart rate responds with a delay, so this is context rather than a correction.

{% for t in analysis.terrain %}
{{ t.label }}{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}bpm
{{ '%.2f'|format(t.distance/1000) }} km · {{ pace_text(t.pace) }}/km
{% endfor %}

Laps & splits

{{ activity.laps|length }} recorded laps
{% for lap in analysis.laps %}{% else %}{% endfor %}
LAPDISTANCETIMEPACEGAP EST.HR
{{ loop.index }}{{ '%.2f'|format(lap.distance / 1000) }} km{{ lap.duration|duration }}{{ pace_text(lap.pace) }}{{ pace_text(lap.gap) }}{{ lap.heart_rate|int if lap.heart_rate else '—' }}
No laps recorded.
-{% if owner_view %}
YOUR RUN, YOUR CHOICE

Sharing settings

Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.

{% endif %}
+{% if owner_view %}
YOUR RUN, YOUR CHOICE

Sharing settings

Accepted friends see the full route and measurements of shared activities. Public link access is separate: anyone with a copied link can view this run, including its full route and measurements, until you disable the link.

{% endif %}
About these numbers Recorded data vs. estimates +

GAP is an estimate, not a Garmin or Strava value. We use the Minetti running energy-cost model to estimate an equivalent flat pace from the recorded slope. Elevation uses a five-sample median and a roughly 60 m distance window. Grades outside ±30%, missing data, and recording gaps over 10 seconds are excluded. GAP is only calculated for running.

Average GAP divides time by equivalent flat distance over eligible moving samples. Coverage indicates how much recorded distance contributed; lap GAP uses the same method. Stops and missing data can make its sample coverage differ from timer pace. Wind, surface, heat, fatigue and individual running economy are not modelled.

Heart rate stays measured. A universal grade correction would imply precision we don't have. Terrain summaries show time-weighted recorded HR, without trying to remove the effect of hills. Cadence is converted from running cycles/min to steps/min. Missing measurements are shown as unavailable, never zero-filled.

Map colours use this activity’s 10th–90th percentile range, with extremes saturated. Direction arrows follow recording order; they are not navigation instructions. Chart traces are sampled for display; computations use all available records.

{% endblock %} {% block scripts %}{% endblock %} diff --git a/app/templates/auth.html b/app/templates/auth.html index afd92ac..9e69adb 100644 --- a/app/templates/auth.html +++ b/app/templates/auth.html @@ -3,7 +3,7 @@ {% block breadcrumb %}{{ 'WELCOME' if mode == 'setup' else 'ACCOUNT' }}{% endblock %} {% block content %}
-
THE NEXT CHAPTER STARTS HERE

Your miles.
Your story.
Your space.

A home for the runs, the quiet progress,
and everything you’re working towards.

+
MADE FOR THE LONG RUN

Run. Record.
Repeat.

Every route. Every split. Your progress.
A home for all the miles you put in.

  • {{ icon('route') }} Replay your routes
  • {{ icon('overview') }} See your progress
  • {{ icon('friends') }} Share your runs
{{ running_map() }}
{{ 'FIRST STEPS' if mode == 'setup' else 'WELCOME BACK' if mode == 'login' else 'FIND YOUR STRIDE' }}

{{ 'Make yourself at home.' if mode == 'setup' else 'Good to see you.' if mode == 'login' else 'Create your account.' }}

@@ -17,7 +17,7 @@ {% endif %} - +

{% if mode == 'login' %}New here? Create an account{% else %}Already have an account? Sign in{% endif %}

{% if mode != 'login' %}This is your Stride password, separate from your Garmin account.{% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 2c6d51b..8697d1d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1,3 +1,4 @@ +{% from '_ui.html' import icon, running_map %} @@ -7,36 +8,47 @@ {% block head %}{% endblock %} - + + {% if current_user.is_authenticated %} + {% else %} +
+ sstride. + +
+ {% endif %}
-
YOUR SPACE / {% block breadcrumb %}RUNNING{% endblock %} Your data. Your pace.
+ {% if current_user.is_authenticated %} +
YOUR SPACE / {% block breadcrumb %}RUNNING{% endblock %} Personal running log
+ {% endif %}
{% for category, message in get_flashed_messages(with_categories=true) %}
{{ message }}
{% endfor %} {% block content %}{% endblock %} -
STRIDE · EVERY KILOMETRE COUNTSBuilt for your own path.
+
STRIDE / RUNNING LOGDISTANCE · TIME · PROGRESS
{% block scripts %}{% endblock %} diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 8e6d8c1..76e81af 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,18 +1,25 @@ {% extends 'base.html' %} {% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %} {% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% endblock %} +{% block head %}{% endblock %} {% block content %} -
{{ 'THE BIG PICTURE' if personal else 'SHARED WITH FRIENDS' }}

{{ 'Find your stride.' if personal else profile.display_name }}

{{ 'A little perspective on every kilometre you’ve put in.' if personal else 'The runs, the progress, and the miles along the way.' }}

{% if personal %}+ Import activity{% else %}Friends only{% endif %}
+
{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}

{{ 'Overview' if personal else profile.display_name }}

{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}

{% if personal %}{{ icon('plus') }} Import activity{% else %}Friends only{% endif %}
+
+
LATEST RUN / ROUTE REPLAY

{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}

+ {% if mapped_run %}

{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km

View run {{ icon('arrow') }}{% else %}

Import a run with GPS to see your route in motion.

{% endif %} +

{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}

+
+
- -
ACTIVITIES ◫
{{ stats.count }}

One step, then another

-
TIME ON YOUR FEET ◷
{{ stats.duration|duration }}

Total recorded timer time

-
AVERAGE PACE ↝
{{ stats.pace }}/km

Weighted by distance

+ +
ACTIVITIES {{ icon('run') }}
{{ stats.count }}

Recorded sessions

+
TIME ON YOUR FEET {{ icon('clock') }}
{{ stats.duration|duration }}

Total recorded timer time

+
AVERAGE PACE {{ icon('route') }}
{{ stats.pace }}/km

Weighted by distance

-
CONSISTENCY ADDS UP

Your miles, over time.

8 weeks to latest activity
-
THE VIEW FROM UP HERE
{{ '{:,.0f}'.format(stats.elevation) }}m

Elevation earned.

Every climb is part of the story.

+
WEEKLY DISTANCE

Training volume

8 weeks to latest activity
+
TOTAL ASCENT
{{ '{:,.0f}'.format(stats.elevation) }}m

Elevation gain

Across {{ stats.count }} recorded activities

-
THE WORK YOU’VE PUT IN

{{ 'Your activities' if personal else 'Shared activities' }}

{{ rows|length }} activities
{% include '_activities.html' %}
+
SESSION HISTORY

{{ 'Your activities' if personal else 'Shared activities' }}

{{ rows|length }} activities
{% include '_activities.html' %}
{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/error.html b/app/templates/error.html index 05019a4..3202f5c 100644 --- a/app/templates/error.html +++ b/app/templates/error.html @@ -1,2 +1,2 @@ {% extends 'base.html' %} -{% block content %}
{{ code }}

{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}

{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}

Back to Stride ↗
{% endblock %} +{% block content %}
{{ code }}

{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}

{{ 'This page is private or doesn’t exist.' if code == 404 else 'Too many attempts. Please wait a little.' if code == 429 else 'That upload is too large. The limit is 16 MB.' if code == 413 else 'The request could not be completed. Refresh the page and try again.' }}

Back to Stride {{ icon('arrow') }}
{% endblock %} diff --git a/app/templates/friends.html b/app/templates/friends.html index 2662685..1a443f9 100644 --- a/app/templates/friends.html +++ b/app/templates/friends.html @@ -4,9 +4,9 @@ {% block content %}
YOUR PEOPLE, YOUR PACE

Better with friends.

Only accepted friends can see the activities you share.

-
INVITE SOMEONE YOU KNOW

Add a friend

They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.

+
INVITE SOMEONE YOU KNOW

Add a friend

They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.

YOUR CIRCLE

Connections

-{% for entry in people %}
{% if entry.link.status == 'accepted' %}{{ entry.person.display_name }} ↗{% else %}{{ entry.person.display_name }}{% endif %}{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}
+{% for entry in people %}
{% if entry.link.status == 'accepted' %}{{ entry.person.display_name }} {{ icon('arrow') }}{% else %}{{ entry.person.display_name }}{% endif %}{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}
{% endif %}
{% else %}

Your circle starts here. Add a friend using the email they registered with.

{% endfor %}
diff --git a/app/templates/settings.html b/app/templates/settings.html index 35f76c8..e90c077 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -2,8 +2,18 @@ {% block title %}Settings & imports · Stride{% endblock %} {% block breadcrumb %}SETTINGS & IMPORTS{% endblock %} {% block content %} -
MAKE IT YOUR OWN

Your space, your way.

Manage your profile and bring your activities home.

-
THE PERSON BEHIND THE MILES

Your profile

Your shared profile is visible only to accepted friends and only includes activities you share.

-
BRING YOUR RUNS WITH YOU

Import a FIT file

Upload an original activity exported from your watch or Garmin Connect.

-
GARMIN CONNECT

Your local sync

{% if current_user.local_account_key %}Your account is linked to the activities downloaded by your local Garmin sync command.{% else %}You can import FIT files now. Web-based Garmin token connections are coming in the next development stage.{% endif %}

{% if current_user.local_account_key %}
Local imports linked

After running the sync command, load any new downloads here. This button reads local files; it does not contact Garmin.

{% endif %}
+
ACCOUNT

Settings & imports

Manage your profile and bring your activities home.

+
PROFILE

Your profile

Your shared profile is visible only to accepted friends and only includes activities you share.

+
ACTIVITY IMPORT

Import a FIT file

Upload an original activity exported from your watch or Garmin Connect.

+
GARMIN CONNECT

Garmin sync

+{% if connection %} +

{{ connection.message }}

{% if connection.last_success %}Last successful sync: {{ connection.last_success|localdate(current_user.timezone, '%d %b %Y %H:%M') }}.{% endif %} {% if connection.pending %}A sync is queued.{% endif %} Automatic sync is {{ 'on (hourly)' if connection.scheduled else 'off' }}.

+
+
+
+{% endif %} +

Connect or reconnect by uploading the session JSON exported by the local Garmin login helper (garminconnect 0.3.17). Sessions are encrypted on the server. New imports are private. Sync checks your latest 100 activities.

+
+

Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.

+
{% endblock %} diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..939df9f --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,45 @@ +# Dokku deployment + +App: `stride`; database: `stride-db`; domain: `stride.peterstockings.com`. +The Dockerfile installs locked dependencies on Python 3.12. Dokku runs database +migrations in the predeploy step, then one web container and one sync worker. + +Mount `/var/lib/dokku/data/storage/stride:/storage` with ownership 1000:1000 and +mode 0700. Set APP_ENV=production, INSTANCE_PATH=/storage/instance, +DATA_ROOT=/storage/data, SECRET_KEY (random secret), TOKEN_ENCRYPTION_KEY +(Fernet key). Linking PostgreSQL supplies DATABASE_URL. Preserve the encryption +key separately from database backups; losing it requires Garmin reconnection. + +Push to Gitea, then deploy with `dokku git:sync --build stride +https://gitea.peterstockings.com/peterstockings/Stride.git main` on the server. +Enable HTTPS using `dokku letsencrypt:enable stride`. + +## Sync + +Upload a garminconnect 0.3.17 session JSON in Settings & imports. Credentials are +validated with Garmin, encrypted, and assigned to the authenticated owner. +Manual sync queues durable work; the worker polls every 30 seconds. Automatic +sync runs hourly and checks the latest 100 activities, reusing download manifests +and unique activity source keys to avoid duplicates. Older activities can be +imported with FIT uploads or the operator's local import command. + +Shared storage file locks prevent overlapping jobs and disconnect races. A crash +releases the lock; due or pending work is retried after restart. Refresh tokens +are persisted before downloading and after download failures. Transient failures +back off up to six hours; rejected authentication pauses scheduling until the +user reconnects and enables it again. Disconnect removes credentials and keeps +previously imported activities. Only garmin.com is supported by the web connector. + +Inspect `dokku logs stride -p worker`, the status in Settings, and `/health`. +Run `dokku run stride flask --app app sync-worker --once` for an operator check. +Do not run the local sync simultaneously using a copy of the same Garmin session. + +Back up PostgreSQL, `/storage`, and configuration securely. Rate limiting still +uses per-process memory; email verification and password recovery are not yet +implemented. For a larger public deployment configure shared rate-limit storage. + +## Validation + +Local automated suite: 39 passing tests, including manual/scheduled jobs, +encryption, retry/authentication failure, locking, CSRF, and access control. +Live deployment and Garmin results will be recorded after verification. diff --git a/migrations/versions/c72418a19e30_add_activity_share_links.py b/migrations/versions/c72418a19e30_add_activity_share_links.py new file mode 100644 index 0000000..93585a5 --- /dev/null +++ b/migrations/versions/c72418a19e30_add_activity_share_links.py @@ -0,0 +1,21 @@ +"""Add opt-in public activity share links.""" + +import sqlalchemy as sa +from alembic import op + +revision = "c72418a19e30" +down_revision = "bdbac89195a7" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("activity") as batch_op: + batch_op.add_column(sa.Column("share_token", sa.String(64), nullable=True)) + batch_op.create_unique_constraint("uq_activity_share_token", ["share_token"]) + + +def downgrade(): + with op.batch_alter_table("activity") as batch_op: + batch_op.drop_constraint("uq_activity_share_token", type_="unique") + batch_op.drop_column("share_token") diff --git a/migrations/versions/d81b97ae5401_garmin_connections.py b/migrations/versions/d81b97ae5401_garmin_connections.py new file mode 100644 index 0000000..3ad3071 --- /dev/null +++ b/migrations/versions/d81b97ae5401_garmin_connections.py @@ -0,0 +1,29 @@ +"""Persistent encrypted Garmin connections and sync requests.""" + +import sqlalchemy as sa +from alembic import op + +revision = "d81b97ae5401" +down_revision = "c72418a19e30" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "garmin_connection", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("user.id"), nullable=False, unique=True), + sa.Column("account_key", sa.String(24), nullable=False, unique=True), + sa.Column("tokens", sa.Text(), nullable=False), + sa.Column("pending", sa.Boolean(), nullable=False), + sa.Column("scheduled", sa.Boolean(), nullable=False), + sa.Column("next_sync", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_success", sa.DateTime(timezone=True)), + sa.Column("message", sa.String(200), nullable=False), + sa.Column("failures", sa.Integer(), nullable=False), + ) + + +def downgrade(): + op.drop_table("garmin_connection") diff --git a/pyproject.toml b/pyproject.toml index 62692a7..74a8859 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "psycopg[binary]>=3.3.6", "waitress>=3.0.2", "gunicorn>=23,<26; sys_platform != 'win32'", + "cryptography>=46,<49", ] [dependency-groups] diff --git a/tests/test_app.py b/tests/test_app.py index d28f4e5..d75a801 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -86,6 +86,27 @@ def test_owner_dashboard_activity_json_and_settings_render(app): assert "point" not in data["chart"][0] +def test_dashboard_replays_latest_accessible_gps_run(app): + client = app.test_client() + login(client) + page = client.get("/dashboard").data + assert b'id="dashboard-route-map" data-url="/activities/1/data"' in page + assert b'id="toggle-route-replay"' in page + assert b'vendor/leaflet.js' in page + assert b'class="dashboard-map"' not in page + client.post("/logout") + login(client, "other") + page = client.get("/dashboard").data + assert b'No GPS running route available yet.' in page + assert b'/activities/1/data' not in page + with app.app_context(): + db.session.get(Activity, 1).samples = [] + db.session.commit() + client.post("/logout") + login(client) + assert b'No GPS running route available yet.' in client.get("/dashboard").data + + def test_friend_access_requires_acceptance_and_revokes_immediately(app): owner = app.test_client() friend = app.test_client() @@ -132,6 +153,66 @@ def test_full_route_includes_start_and_finish(): assert activity_data(row)["segments"] == [points] +def test_public_run_link_copy_view_revoke_and_recreate(app): + owner = app.test_client() + anonymous = app.test_client() + login(owner) + assert b'id="copy-run-link"' in owner.get("/activities/1").data + response = owner.post("/activities/1/share") + assert response.status_code == 200 + url = response.json["url"] + assert url.startswith("http://localhost/shared/runs/") + assert len(url.rsplit("/", 1)[1]) >= 40 + assert owner.post("/activities/1/share").json["url"] == url + page = anonymous.get(url) + assert page.status_code == 200 + assert b"A quiet morning run" in page.data + assert b"Shared via link" in page.data + assert b'id="copy-run-link"' not in page.data + assert b"/activities/1/edit" not in page.data + assert b"/u/runner" not in page.data + assert (url.removeprefix("http://localhost") + "/data").encode() in page.data + assert page.headers["X-Robots-Tag"] == "noindex, nofollow" + assert page.headers["Referrer-Policy"] == "no-referrer" + data = anonymous.get(url + "/data") + assert data.status_code == 200 + assert data.json["segments"] == [[[-33, 151]]] + assert "no-store" in data.headers["Cache-Control"] + assert anonymous.get("/activities/1").status_code == 404 + assert anonymous.get("/activities/1/data").status_code == 404 + with app.app_context(): + assert db.session.get(Activity, 1).visibility == "private" + assert owner.post("/activities/1/share/revoke").status_code == 302 + assert anonymous.get(url).status_code == 404 + assert anonymous.get(url + "/data").status_code == 404 + assert owner.post("/activities/1/share").json["url"] != url + + +def test_only_owner_can_create_or_revoke_public_run_links(app): + client = app.test_client() + for suffix in ("share", "share/revoke"): + assert client.post(f"/activities/1/{suffix}").status_code == 302 + login(client, "other") + with app.app_context(): + db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted")) + db.session.get(Activity, 1).visibility = "friends" + db.session.commit() + assert client.get("/activities/1").status_code == 200 + assert b'id="copy-run-link"' not in client.get("/activities/1").data + for suffix in ("share", "share/revoke"): + assert client.post(f"/activities/1/{suffix}").status_code == 404 + for suffix in ("", "/data"): + assert client.get(f"/shared/runs/invalid-token{suffix}").status_code == 404 + + +def test_public_run_link_mutations_require_csrf(app): + client = app.test_client() + login(client) + app.config["WTF_CSRF_ENABLED"] = True + for suffix in ("share", "share/revoke"): + assert client.post(f"/activities/1/{suffix}").status_code == 400 + + def test_register_creates_separate_user_with_no_local_account(app): client = app.test_client() response = client.post( diff --git a/tests/test_web_sync.py b/tests/test_web_sync.py new file mode 100644 index 0000000..1483e69 --- /dev/null +++ b/tests/test_web_sync.py @@ -0,0 +1,116 @@ +import io +import json +from datetime import UTC, datetime, timedelta +from unittest.mock import Mock + +import pytest +from cryptography.fernet import Fernet +from garminconnect import GarminConnectAuthenticationError + +from app import create_app +from app.extensions import db +from app.models import GarminConnection, User +from app.sync import run_due, session_lock + + +@pytest.fixture +def connected(tmp_path, monkeypatch): + app = create_app( + { + "TESTING": True, + "SQLALCHEMY_DATABASE_URI": "sqlite://", + "SECRET_KEY": "testing", + "WTF_CSRF_ENABLED": False, + "RATELIMIT_ENABLED": False, + "LOCAL_SETUP": False, + "TOKEN_ENCRYPTION_KEY": Fernet.generate_key().decode(), + "DATA_ROOT": str(tmp_path / "data"), + } + ) + app.instance_path = str(tmp_path / "instance") + api = Mock(profile_id=123) + api.client.dumps.return_value = '{"rotated":"private-session"}' + monkeypatch.setattr("app.sync.Garmin", lambda: api) + sync = Mock(return_value={"failed": 0}) + monkeypatch.setattr("app.sync.sync_activities", sync) + with app.app_context(): + db.create_all() + user = User(username="owner", email="owner@example.test", display_name="Owner") + user.set_password("long-test-password") + db.session.add(user) + db.session.commit() + client = app.test_client() + client.post("/login", data={"email": "owner@example.test", "password": "long-test-password"}) + payload = json.dumps( + {k: "secret-value" for k in ("di_token", "di_refresh_token", "di_client_id")} + ) + response = client.post( + "/garmin/connect", data={"tokens": (io.BytesIO(payload.encode()), "session.json")} + ) + assert response.status_code == 302 + return app, client, api, sync + + +def test_manual_scheduled_encryption_and_disconnect(connected): + app, client, _api, sync = connected + with app.app_context(): + connection = db.session.scalar(db.select(GarminConnection)) + assert "private-session" not in connection.tokens + assert run_due() == 1 + assert connection.last_success + assert not connection.pending + assert run_due() == 0 + connection.next_sync = datetime.now(UTC) - timedelta(minutes=1) + db.session.commit() + assert run_due() == 1 + assert sync.call_count == 2 + client.post("/garmin/schedule", data={"enabled": "no"}) + client.post("/garmin/sync") + with app.app_context(): + assert run_due() == 1 + assert sync.call_count == 3 + client.post("/garmin/disconnect") + with app.app_context(): + assert run_due() == 0 + assert db.session.scalar(db.select(GarminConnection)) is None + assert db.session.get(User, 1).local_account_key is None + + +def test_retry_and_rotated_tokens_survive_failure(connected): + app, client, _api, sync = connected + api = _api + sync.side_effect = RuntimeError("provider secret must not appear") + with app.app_context(): + assert run_due() == 1 + connection = db.session.scalar(db.select(GarminConnection)) + assert connection.failures == 1 + assert "provider secret" not in connection.message + assert ( + Fernet(app.config["TOKEN_ENCRYPTION_KEY"]).decrypt(connection.tokens.encode()) + == api.client.dumps.return_value.encode() + ) + assert run_due() == 0 + client.post("/garmin/sync") + api.login.side_effect = GarminConnectAuthenticationError("private error") + with app.app_context(): + run_due() + connection = db.session.scalar(db.select(GarminConnection)) + assert not connection.scheduled + assert "expired" in connection.message + + +def test_lock_prevents_overlapping_sync_and_disconnect(connected): + app, client, _api, sync = connected + with app.app_context(), session_lock(1): + run_due() + sync.assert_not_called() + response = client.post("/garmin/disconnect", follow_redirects=True) + assert b"sync is running" in response.data + assert db.session.scalar(db.select(GarminConnection)) is not None + + +def test_sync_requires_login_and_csrf(connected): + app, client, _api, _sync = connected + assert app.test_client().post("/garmin/sync").status_code == 302 + app.config["WTF_CSRF_ENABLED"] = True + assert client.post("/garmin/sync").status_code == 400 diff --git a/uv.lock b/uv.lock index 75d732f..0dd422b 100644 --- a/uv.lock +++ b/uv.lock @@ -116,6 +116,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "48.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bc/ee4137cbbe105652c0ee4252792b78fc8e7afa4b8e61d9d5dc05a7f45731/cryptography-48.0.1-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3e4a1a3232eef2e6c732827d5722db29a0cc8b27af2a4d865b094cf954be9ca1", size = 8008324, upload-time = "2026-06-09T22:31:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" }, + { url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" }, + { url = "https://files.pythonhosted.org/packages/89/c6/24266ac10c47f6cd2a865f4446062b466da1d1f10b27189eac00e61bf0c9/cryptography-48.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b024e784ad6c077ee0147b35ea9cbfc1e34e1fd4c1dcca214c2794d73a12df08", size = 5300085, upload-time = "2026-06-09T22:31:58.703Z" }, + { url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/b06844f303873493c963caf581c04df31c7035e0c1b0f02c4814d319ec80/cryptography-48.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:3fd2ca57062b241c856670b073487d2e86c4637937ca5601e48f97bf8e11fc8f", size = 5258461, upload-time = "2026-06-09T22:31:04.187Z" }, + { url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" }, + { url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a0/8f50cae9c74e718ed769d63ed5c74bd0ea830c9550a74629cebd1b9c7bc7/cryptography-48.0.1-cp311-abi3-win32.whl", hash = "sha256:b9a32b876490d66c8bcc9963ef220199569748434ab01a9d6aaeabf88e7f5158", size = 3304154, upload-time = "2026-06-09T22:32:16.845Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/0572c77dbace6fef72f33755bd52ea399c71367250d366237f8691826b9e/cryptography-48.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:39489bfca54c7a1f6b297efcd8bc608ab92d16c4ca631b0cad4da46724588b24", size = 3817138, upload-time = "2026-06-09T22:32:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/ca/6c/00fa2a95997164c8b2072ce327c23d4ab20809ccc323ea5fab91e53a4bba/cryptography-48.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:4fdc69f8e4316bcf0c8c8ec1f26f285d12e8142d88d96c876a59a03be3f6ae67", size = 7987408, upload-time = "2026-06-09T22:32:20.777Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" }, + { url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0b/aa68b221dde92d09cb29a024ede17550ee21e77a404e59fc093c82bb51e1/cryptography-48.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9621de99d2da096006b629979efd8ae7eb2d8b822488d0c89ee4000c306c59b1", size = 5289970, upload-time = "2026-06-09T22:31:20.368Z" }, + { url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6f/5cd12f951165ea73ef85266775d97e4c763b2474ccfd816dd69d3a18d6f8/cryptography-48.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:b7a2d1a937a738a881737cec135a38bb61470589b17515b9f73f571d0ae10401", size = 5245252, upload-time = "2026-06-09T22:32:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" }, + { url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" }, + { url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" }, + { url = "https://files.pythonhosted.org/packages/36/bf/ed70785c496e89d7e73b7cda2d21f2447fd6d4e821714b8d04ff217fed92/cryptography-48.0.1-cp39-abi3-win32.whl", hash = "sha256:6b2c0c3e6ccf3ade7750f836ef3ee36eea250cc467d45c256895573ac08cc6f1", size = 3282307, upload-time = "2026-06-09T22:30:53.162Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ff/371ea7d252656ee1eb6d83eeeef3d1d0c6baf1d6497687d081ea03814670/cryptography-48.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:9a49ca6c81417f6a5edb50375a60cccdd70fa0a91a5211829dbea74eba94d2ac", size = 3793408, upload-time = "2026-06-09T22:32:15.191Z" }, +] + [[package]] name = "curl-cffi" version = "0.16.3" @@ -507,6 +546,7 @@ name = "running-dashboard" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "cryptography" }, { name = "curl-cffi" }, { name = "filelock" }, { name = "fitdecode" }, @@ -530,6 +570,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=46,<49" }, { name = "curl-cffi", specifier = ">=0.13,<1" }, { name = "filelock", specifier = ">=3.20,<4" }, { name = "fitdecode", specifier = "==0.11.0" },