Deploy Stride with encrypted manual and scheduled Garmin sync

This commit is contained in:
Peter Stockings
2026-10-01 20:42:28 +10:00
parent 24c7fc2c6a
commit 8555695c5e
28 changed files with 900 additions and 56 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
instance
data
tests
.env*
__pycache__
*.db
*.fit
*.json
!app.json
+15
View File
@@ -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()"]
+2
View File
@@ -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
+17 -7
View File
@@ -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).
+5
View File
@@ -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}]}
}
+14 -3
View File
@@ -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):
+14
View File
@@ -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))
+109 -2
View File
@@ -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/<int:activity_id>/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/<int:activity_id>/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/<token>")
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/<token>/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/<int:activity_id>/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/<action>")
@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")
+37 -4
View File
@@ -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")]);
+65 -2
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).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: '<span></span>', 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();
+19 -2
View File
File diff suppressed because one or more lines are too long
+170
View File
@@ -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)
+2 -2
View File
@@ -1,5 +1,5 @@
{% if rows %}
<div class="activity-table-wrap"><table class="activity-table"><thead><tr><th>ACTIVITY</th><th>DISTANCE</th><th>TIME</th><th>AVG. PACE</th><th>{{ 'VISIBILITY' if personal|default(false) else 'ATHLETE' }}</th><th><span class="sr-only">Open</span></th></tr></thead><tbody>
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">↗</span><span><strong>{{ row.title }}</strong><small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">↗</a></td></tr>{% endfor %}
{% for row in rows %}<tr><td><a class="activity-name" href="{{ url_for('web.activity',activity_id=row.id) }}"><span class="sport-icon">{{ icon('run') }}</span><span><strong>{{ row.title }}</strong><small>{{ row.started_at|localdate(row.owner.timezone) }} <span>·</span> {{ row.sport|replace('_',' ')|title }}</small></span></a></td><td><strong>{{ '%.2f'|format(row.distance_km) }}</strong><small> km</small></td><td>{{ row.duration_s|duration }}</td><td>{{ row.pace }}<small> /km</small></td><td>{% if personal|default(false) %}<span class="pill {{ row.visibility }}">{{ row.visibility|title }}</span>{% else %}<a class="athlete-link" href="{{ url_for('web.profile',username=row.owner.username) }}">{{ row.owner.display_name }}</a>{% endif %}</td><td><a class="row-arrow" aria-label="View {{ row.title }}" href="{{ url_for('web.activity',activity_id=row.id) }}">{{ icon('arrow') }}</a></td></tr>{% endfor %}
</tbody></table></div>
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>Your next chapter awaits.</h3><p>{{ '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.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run ↗</a>{% endif %}</div>{% endif %}
{% else %}<div class="empty-state"><span class="empty-symbol">↗</span><h3>No activities yet.</h3><p>{{ '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.' }}</p>{% if personal|default(false) %}<a class="button primary" href="{{ url_for('web.settings') }}">Import your first run {{ icon('arrow') }}</a>{% endif %}</div>{% endif %}
+22
View File
@@ -0,0 +1,22 @@
{% macro icon(name) -%}
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">{% if name == 'overview' %}<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>{% elif name == 'profile' %}<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>{% elif name == 'friends' %}<circle cx="9" cy="8" r="3"/><path d="M3 21v-3a6 6 0 0 1 12 0v3M16 5a3 3 0 0 1 0 6m2 4a5 5 0 0 1 3 5"/>{% elif name == 'settings' %}<path d="M4 7h16M4 17h16"/><circle cx="8" cy="7" r="3"/><circle cx="16" cy="17" r="3"/>{% elif name == 'clock' %}<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>{% elif name == 'route' %}<circle cx="5" cy="18" r="2"/><circle cx="19" cy="6" r="2"/><path d="M5 16V7a3 3 0 0 1 6 0v10a3 3 0 0 0 6 0V6"/>{% elif name == 'run' %}<circle cx="15" cy="4" r="2"/><path d="m5 9 5-2 4 4 5 1M12 9l-3 6 5 3v4M9 15l-3 5H2"/>{% elif name == 'plus' %}<path d="M12 5v14M5 12h14"/>{% elif name == 'upload' %}<path d="M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6"/>{% else %}<path d="M4 12h16m-6-6 6 6-6 6"/>{% endif %}</svg>
{%- endmacro %}
{% macro running_map(compact=false) -%}
<div class="running-map {{ 'compact-map' if compact }}">
<svg viewBox="0 0 760 320" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Illustrated running route through a city park">
<rect width="760" height="320" fill="#171d20"/>
<g fill="#202d29" stroke="#2b3b33" stroke-width="1"><path d="m260 45 170-20 62 88-36 93-173-11-52-63Z"/><path d="m545 202 140-22 57 120H548Z"/></g>
<path d="M-20 270C110 190 112 342 278 268S480 259 560 123 711 81 800 8" fill="none" stroke="#253943" stroke-width="35"/>
<g fill="none" stroke="#30383b" stroke-width="9"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305M290-20 290 30M430-20 430 24M332 210V340"/></g>
<g fill="none" stroke="#41484a" stroke-width="1"><path d="M-20 52 790 52M-20 155 200 155 239 215 790 215M45-20V340M166-20V340M539-20V340M680-20V340M-20 305 790 305"/></g>
<g fill="none" stroke="#2b3336" stroke-width="3"><path d="M95 0v320M0 103h215M0 255h165M590 0v320M730 0v320M495 105h265M540 265h220M215 0v100M377 228v92"/></g>
<g font-family="monospace" font-size="9" letter-spacing="2" fill="#788682"><text x="313" y="114">NORTH PARK</text><text x="567" y="44">PARK AVENUE</text><text x="72" y="205" transform="rotate(-90 72 205)">RIVER STREET</text><text x="565" y="290">RIVERSIDE</text></g>
<path class="map-route-glow" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
<path class="map-route" d="M166 215V52H430L475 112 449 175H290L250 128H166V215"/>
<circle cx="166" cy="215" r="6" fill="#171d20" stroke="#ff8659" stroke-width="3"/>
<g class="map-runner"><circle r="15" fill="#ff8659" opacity=".16"/><circle r="6" fill="#fff0e6" stroke="#ff8659" stroke-width="3"/></g>
<g transform="translate(713 28)" fill="none" stroke="#94a19e"><path d="M0 24V0m-5 8 5-8 5 8"/><text x="-3" y="-8" fill="#94a19e" stroke="none" font-size="9">N</text></g>
</svg>
<div class="map-caption"><span><i></i> RUN THE CITY <small>Illustrated route</small></span></div>
</div>
{%- endmacro %}
+5 -4
View File
@@ -6,8 +6,9 @@
<article class="metric-tile"><span>{{ label }}</span><strong>{{ value }}<small>{{ unit }}</small></strong>{% if note %}<p>{{ note }}</p>{% endif %}</article>
{% endmacro %}
{% block content %}
<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By <a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a><span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
{% if not public_view %}<a class="back-link" href="{{ url_for('web.dashboard') if owner_view else url_for('web.profile',username=activity.owner.username) }}">← {{ 'Your activities' if owner_view else activity.owner.display_name }}</a>{% endif %}
<div class="page-heading"><div><span class="eyebrow">{{ activity.sport|upper }} · {{ activity.started_at|localdate(activity.owner.timezone, '%A, %d %B %Y') }}</span><h1>{{ activity.title }}</h1><p>By {% if public_view %}{{ activity.owner.display_name }}{% else %}<a href="{{ url_for('web.profile',username=activity.owner.username) }}">{{ activity.owner.display_name }}</a>{% endif %}<span class="dot-separator">·</span>{{ activity.started_at|localdate(activity.owner.timezone, '%H:%M') }}<span class="dot-separator">·</span>{{ '{:,}'.format(analysis.summary.samples) }} recorded samples</p></div><div class="activity-actions"><span class="pill {{ 'public' if public_view else activity.visibility }}">{{ 'Shared via link' if public_view else activity.visibility|title }}</span>{% if owner_view %}<button type="button" class="button secondary copy-link-button" id="copy-run-link" aria-label="Copy public link to this run" title="Copy public link — anyone with the link can view this run" data-url="{{ url_for('web.share_activity', activity_id=activity.id) }}" data-csrf="{{ csrf_token() }}"><svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m10 13 4-4m-6 6-1 1a4 4 0 0 1-6-6l4-4a4 4 0 0 1 6 0m2 2 1-1a4 4 0 0 1 6 6l-4 4a4 4 0 0 1-6 0"/></svg></button>{% endif %}</div></div>
{% if owner_view %}<div class="share-feedback"><p id="share-status" role="status" aria-live="polite"></p><label id="share-fallback" hidden>Copy this public link<input id="share-link-value" readonly aria-label="Public run link"></label></div>{% endif %}
<nav class="activity-nav" aria-label="Activity sections"><a href="#route-section">Route & speed</a><a href="#performance">Performance</a><a href="#terrain">Terrain & effort</a><a href="#splits">Laps & splits</a><a href="#method">About the numbers</a></nav>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE</div><div class="stat-value">{{ '%.2f'|format(activity.distance_km) }}<small>km</small></div><p>Recorded by your watch</p></article>
@@ -17,7 +18,7 @@
</div>
<section class="panel map-panel run-map" id="route-section">
<div class="section-head"><div><span class="eyebrow">FOLLOW YOUR FOOTSTEPS</span><h2>Your run, in colour.</h2></div><div class="map-controls"><label>Colour by <select id="map-metric"><option value="speed">Speed</option><option value="heart_rate">Heart rate</option><option value="grade">Gradient</option></select></label><label class="directions-toggle"><input type="checkbox" id="show-directions" checked> Direction arrows</label><button type="button" class="button secondary" id="fit-route">Fit route</button></div></div>
<div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
<div id="route-map" data-url="{{ data_url or url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading your route…</p></div>
<div class="route-legend"><span id="legend-low">Slower</span><div class="legend-ramp" id="legend-ramp"></div><span id="legend-high">Faster</span><span class="legend-note">S Start · F Finish · Grey = no measurement</span></div>
<p id="map-status" class="map-status" aria-live="polite"></p>
</section>
@@ -42,7 +43,7 @@
</section>
<section class="panel terrain-panel" id="terrain"><div class="section-head"><div><span class="eyebrow">HILLS CHANGE THE EFFORT</span><h2>Heart rate by terrain</h2></div><span class="subtle-label">Measured HR · not adjusted</span></div><p class="section-description">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.</p><div class="terrain-grid">{% for t in analysis.terrain %}<article><span>{{ t.label }}</span><strong>{{ '%.0f'|format(t.heart_rate) if t.heart_rate else '—' }}<small>bpm</small></strong><div>{{ '%.2f'|format(t.distance/1000) }} km <span>·</span> {{ pace_text(t.pace) }}/km</div></article>{% endfor %}</div></section>
<div class="detail-bottom" id="splits"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><div class="activity-table-wrap"><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE</th><th>GAP EST.</th><th>HR</th></tr></thead><tbody>{% for lap in analysis.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ pace_text(lap.pace) }}</td><td>{{ pace_text(lap.gap) }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="6">No laps recorded.</td></tr>{% endfor %}</tbody></table></div></section>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">Accepted friends see the full route and measurements of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
{% if owner_view %}<section class="panel publishing-panel"><span class="eyebrow">YOUR RUN, YOUR CHOICE</span><h2>Sharing settings</h2><form method="post" action="{{ url_for('web.edit_activity',activity_id=activity.id) }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Activity title<input name="title" value="{{ activity.title }}" maxlength="160" required></label><label>Who can see this activity in Stride?<select name="visibility"><option value="private" {{ 'selected' if activity.visibility == 'private' }}>Only me</option><option value="friends" {{ 'selected' if activity.visibility == 'friends' }}>Accepted friends</option></select></label><p class="field-help">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.</p><button class="button primary">Save changes {{ icon('arrow') }}</button></form><form id="revoke-share" method="post" action="{{ url_for('web.revoke_activity_share',activity_id=activity.id) }}" {{ 'hidden' if not activity.share_token }}><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disable public link</button></form></section>{% endif %}</div>
<details class="panel methodology" id="method"><summary>About these numbers <span>Recorded data vs. estimates +</span></summary><div><p><strong>GAP is an estimate, not a Garmin or Strava value.</strong> We use the <a href="https://doi.org/10.1152/japplphysiol.01177.2001" target="_blank" rel="noopener noreferrer">Minetti running energy-cost model</a> 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.</p><p>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.</p><p><strong>Heart rate stays measured.</strong> 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.</p><p>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.</p></div></details>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='activity.js') }}"></script>{% endblock %}
+2 -2
View File
@@ -3,7 +3,7 @@
{% block breadcrumb %}{{ 'WELCOME' if mode == 'setup' else 'ACCOUNT' }}{% endblock %}
{% block content %}
<div class="auth-layout">
<section class="auth-intro"><span class="eyebrow">THE NEXT CHAPTER STARTS HERE</span><h1>Your miles.<br>Your story.<br><em>Your space.</em></h1><p>A home for the runs, the quiet progress,<br>and everything you’re working towards.</p><div class="auth-art" aria-hidden="true"><svg viewBox="0 0 460 230"><path d="M-20 185 C90 50 100 275 235 90 S365 170 490 20"/><path d="M-20 209 C90 74 100 299 235 114 S365 194 490 44"/><circle cx="235" cy="90" r="8"/></svg></div></section>
<section class="auth-intro"><span class="eyebrow">MADE FOR THE LONG RUN</span><h1>Run. Record.<br><em>Repeat.</em></h1><p>Every route. Every split. Your progress.<br>A home for all the miles you put in.</p><ul class="landing-features"><li>{{ icon('route') }} Replay your routes</li><li>{{ icon('overview') }} See your progress</li><li>{{ icon('friends') }} Share your runs</li></ul><div class="auth-art landing-map">{{ running_map() }}</div></section>
<section class="panel auth-card">
<span class="eyebrow">{{ 'FIRST STEPS' if mode == 'setup' else 'WELCOME BACK' if mode == 'login' else 'FIND YOUR STRIDE' }}</span>
<h2>{{ 'Make yourself at home.' if mode == 'setup' else 'Good to see you.' if mode == 'login' else 'Create your account.' }}</h2>
@@ -17,7 +17,7 @@
{% endif %}
<label>Email address<input type="email" name="email" required maxlength="254" autocomplete="email" placeholder="you@example.com" value="{{ request.form.get('email','') }}"></label>
<label>Password<input type="password" name="password" required minlength="{{ 1 if mode == 'login' else 12 }}" maxlength="128" autocomplete="{{ 'current-password' if mode == 'login' else 'new-password' }}" placeholder="{{ 'Your password' if mode == 'login' else 'At least 12 characters' }}"></label>
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} <span>↗</span></button>
<button class="button primary full">{{ 'Sign in' if mode == 'login' else 'Create account' }} {{ icon('arrow') }}</button>
</form>
<p class="auth-foot">{% if mode == 'login' %}New here? <a href="{{ url_for('web.register') }}">Create an account</a>{% else %}Already have an account? <a href="{{ url_for('web.login') }}">Sign in</a>{% endif %}</p>
{% if mode != 'login' %}<small class="muted">This is your Stride password, separate from your Garmin account.</small>{% endif %}
+24 -12
View File
@@ -1,3 +1,4 @@
{% from '_ui.html' import icon, running_map %}
<!doctype html>
<html lang="en">
<head>
@@ -7,36 +8,47 @@
<link rel="icon" href="{{ url_for('static', filename='mark.svg') }}" type="image/svg+xml">
{% block head %}{% endblock %}
</head>
<body>
<body class="{{ 'app-shell' if current_user.is_authenticated else 'public-shell' }}">
{% if current_user.is_authenticated %}
<aside class="sidebar">
<a class="brand" href="{{ url_for('web.index') }}"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<div class="sidebar-label">YOUR RUNNING STORY</div>
<div class="sidebar-label">TRAINING LOG</div>
<nav aria-label="Main navigation">
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}"><span>◫</span> Overview</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}"><span>◎</span> Shared profile</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.dashboard' }}" href="{{ url_for('web.dashboard') }}">{{ icon('overview') }} Overview</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.profile' }}" href="{{ url_for('web.profile', username=current_user.username) }}">{{ icon('profile') }} Shared profile</a>
{% endif %}
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}"><span>↗</span> Friends</a>{% endif %}
{% if current_user.is_authenticated %}<a class="nav-item {{ 'active' if request.endpoint == 'web.friends' }}" href="{{ url_for('web.friends') }}">{{ icon('friends') }} Friends</a>{% endif %}
{% if current_user.is_authenticated %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}"><span>⚙</span> Settings & imports</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.settings' }}" href="{{ url_for('web.settings') }}">{{ icon('settings') }} Settings & imports</a>
{% else %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}"><span>↪</span> Sign in</a>
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}">{{ icon('arrow') }} Sign in</a>
{% endif %}
</nav>
<div class="sidebar-note"><span class="tiny-label">A LITTLE FURTHER.</span><p>Every run adds<br>to your story.</p><div class="trail-art" aria-hidden="true"><svg viewBox="0 0 180 95"><path d="M-10 80 C30 30 70 110 90 55 S120 5 195 10"/><path d="M-10 94 C30 44 70 124 90 69 S120 19 195 24"/></svg></div></div>
<div class="sidebar-bottom">
{% if current_user.is_authenticated %}
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Your running space</small></div></div>
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out ↗</button></form>
<div class="account"><span class="avatar">{{ current_user.display_name[:1]|upper }}</span><div><strong>{{ current_user.display_name }}</strong><small>Runner</small></div></div>
<form method="post" action="{{ url_for('web.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button logout">Sign out {{ icon('arrow') }}</button></form>
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
</div>
</aside>
{% else %}
<header class="landing-header">
<a class="brand" href="{{ url_for('web.index') }}" aria-label="Stride home"><span class="brand-mark">s</span>stride<span class="brand-dot">.</span></a>
<nav aria-label="Account navigation">
{% if request.endpoint == 'web.login' %}<a class="button secondary" href="{{ url_for('web.register') }}">Create account {{ icon('arrow') }}</a>
{% else %}<a class="button secondary" href="{{ url_for('web.login') }}">Sign in {{ icon('arrow') }}</a>{% endif %}
</nav>
</header>
{% endif %}
<main>
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Your data. Your pace.</span></header>
{% if current_user.is_authenticated %}
<header class="topbar"><span class="breadcrumb">YOUR SPACE <span>/</span> {% block breadcrumb %}RUNNING{% endblock %}</span><span class="local-badge"><i></i> Personal running log</span></header>
{% endif %}
<div class="page">
{% for category, message in get_flashed_messages(with_categories=true) %}<div role="status" class="flash {{ category }}">{{ message }}</div>{% endfor %}
{% block content %}{% endblock %}
<footer class="footer"><span>STRIDE · EVERY KILOMETRE COUNTS</span><span>Built for your own path.</span></footer>
<footer class="footer"><span>STRIDE / RUNNING LOG</span><span>DISTANCE · TIME · PROGRESS</span></footer>
</div>
</main>
{% block scripts %}{% endblock %}
+16 -9
View File
@@ -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 %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% block content %}
<div class="page-heading"><div><span class="eyebrow">{{ 'THE BIG PICTURE' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Find your stride.' if personal else profile.display_name }}</h1><p>{{ 'A little perspective on every kilometre you’ve put in.' if personal else 'The runs, the progress, and the miles along the way.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">+ Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
<div class="page-heading"><div><span class="eyebrow">{{ 'TRAINING LOG' if personal else 'SHARED WITH FRIENDS' }}</span><h1>{{ 'Overview' if personal else profile.display_name }}</h1><p>{{ 'Your distance, effort, and recent sessions.' if personal else 'Shared sessions and training totals.' }}</p></div>{% if personal %}<a class="button primary" href="{{ url_for('web.settings') }}">{{ icon('plus') }} Import activity</a>{% else %}<span class="pill public">Friends only</span>{% endif %}</div>
<section class="dashboard-route panel" aria-label="Latest running route">
<div class="dashboard-route-info"><span class="eyebrow">LATEST RUN / ROUTE REPLAY</span><h2>{{ mapped_run.title if mapped_run else 'Your next run starts here.' }}</h2>
{% if mapped_run %}<p>{{ mapped_run.started_at|localdate(mapped_run.owner.timezone) }} · {{ '%.2f'|format(mapped_run.distance_km) }} km</p><a class="back-link" href="{{ url_for('web.activity',activity_id=mapped_run.id) }}">View run {{ icon('arrow') }}</a><button class="button secondary" id="toggle-route-replay" type="button" hidden>Pause replay</button>{% else %}<p>Import a run with GPS to see your route in motion.</p>{% endif %}
<p class="field-help" id="dashboard-route-status" role="status">{{ 'Loading recorded route…' if mapped_run else 'No GPS running route available yet.' }}</p></div>
<div id="dashboard-route-map" {% if mapped_run %}data-url="{{ url_for('web.data',activity_id=mapped_run.id) }}"{% endif %} aria-label="Map of the latest recorded running route"></div>
</section>
<div class="stats-grid">
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED <span>↗</span></div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
<article class="stat-card"><div class="stat-label">ACTIVITIES <span>◫</span></div><div class="stat-value">{{ stats.count }}</div><p>One step, then another</p></article>
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET <span>◷</span></div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE <span>↝</span></div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
<article class="stat-card featured"><div class="stat-label">DISTANCE LOGGED {{ icon('route') }}</div><div class="stat-value">{{ '%.1f'|format(stats.distance) }}<small>km</small></div><p>{{ 'Across all your activities' if personal else 'Across shared activities' }}</p></article>
<article class="stat-card"><div class="stat-label">ACTIVITIES {{ icon('run') }}</div><div class="stat-value">{{ stats.count }}</div><p>Recorded sessions</p></article>
<article class="stat-card"><div class="stat-label">TIME ON YOUR FEET {{ icon('clock') }}</div><div class="stat-value time-value">{{ stats.duration|duration }}</div><p>Total recorded timer time</p></article>
<article class="stat-card"><div class="stat-label">AVERAGE PACE {{ icon('route') }}</div><div class="stat-value">{{ stats.pace }}<small>/km</small></div><p>Weighted by distance</p></article>
</div>
<div class="overview-grid">
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">CONSISTENCY ADDS UP</span><h2>Your miles, over time.</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
<section class="milestone-card"><span class="eyebrow">THE VIEW FROM UP HERE</span><div class="mountain" aria-hidden="true"><svg viewBox="0 0 300 120"><path d="M-20 120 L85 36 L123 76 L177 9 L310 120"/><path d="M10 120 L90 65 L123 101 L177 38 L290 120"/><circle cx="239" cy="28" r="16"/></svg></div><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation earned.</h3><p>Every climb is part of the story.</p></section>
<section class="panel volume-panel"><div class="section-head"><div><span class="eyebrow">WEEKLY DISTANCE</span><h2>Training volume</h2></div><span class="subtle-label">8 weeks to latest activity</span></div><div class="chart-wrap"><canvas id="volume-chart" aria-label="Weekly distance in kilometres" role="img"></canvas></div><script id="volume-data" type="application/json">{{ {'labels': stats.weeks, 'values': stats.weekly_km}|tojson }}</script></section>
<section class="milestone-card"><span class="eyebrow">TOTAL ASCENT</span><div class="milestone-number">{{ '{:,.0f}'.format(stats.elevation) }}<small>m</small></div><h3>Elevation gain</h3><div class="elevation-profile" aria-hidden="true"><svg viewBox="0 0 300 70"><path d="m0 65 30-15 15 8 35-35 20 15 35-32 25 32 20-12 25 28 20-12 30 23 25-10"/></svg></div><p>Across {{ stats.count }} recorded activities</p></section>
</div>
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">THE WORK YOU’VE PUT IN</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">SESSION HISTORY</span><h2>{{ 'Your activities' if personal else 'Shared activities' }}</h2></div><span class="subtle-label">{{ rows|length }} activities</span></div>{% include '_activities.html' %}</section>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/leaflet.js') }}"></script><script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
+1 -1
View File
@@ -1,2 +1,2 @@
{% extends 'base.html' %}
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ '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.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride ↗</a></div>{% endblock %}
{% block content %}<div class="empty-state"><span class="eyebrow">{{ code }}</span><h1>{{ 'Nothing to see here.' if code == 404 else 'Let’s try that again.' }}</h1><p>{{ '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.' }}</p><a class="button primary" href="{{ url_for('web.index') }}">Back to Stride {{ icon('arrow') }}</a></div>{% endblock %}
+2 -2
View File
@@ -4,9 +4,9 @@
{% block content %}
<div class="page-heading"><div><span class="eyebrow">YOUR PEOPLE, YOUR PACE</span><h1>Better with friends.</h1><p>Only accepted friends can see the activities you share.</p></div></div>
<div class="settings-grid friends-top">
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request ↗</button></form></section>
<section class="panel settings-card"><span class="eyebrow">INVITE SOMEONE YOU KNOW</span><h2>Add a friend</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Their Stride email<input type="email" name="email" required maxlength="254" placeholder="friend@example.com"></label><p class="field-help">They’ll receive a request inside Stride. Both of you must agree before shared activities become visible. No email is sent.</p><button class="button primary">Send friend request {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card"><span class="eyebrow">YOUR CIRCLE</span><h2>Connections</h2>
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} ↗</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
{% for entry in people %}<div class="friend-row"><div><strong>{% if entry.link.status == 'accepted' %}<a href="{{ url_for('web.profile', username=entry.person.username) }}">{{ entry.person.display_name }} {{ icon('arrow') }}</a>{% else %}{{ entry.person.display_name }}{% endif %}</strong><small class="muted">{{ 'Friends' if entry.link.status == 'accepted' else 'Request sent' if entry.link.requested_by == current_user.id else 'Wants to be your friend' }}</small></div><div class="friend-actions">
{% if entry.link.status == 'pending' and entry.link.requested_by != current_user.id %}<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='accept') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Accept</button></form>{% endif %}
<form method="post" action="{{ url_for('web.friendship_action', friendship_id=entry.link.id, action='remove') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="text-button">{{ 'Remove' if entry.link.status == 'accepted' else 'Cancel' if entry.link.requested_by == current_user.id else 'Decline' }}</button></form></div></div>{% else %}<p class="muted">Your circle starts here. Add a friend using the email they registered with.</p>{% endfor %}
</section></div>
+14 -4
View File
@@ -2,8 +2,18 @@
{% block title %}Settings & imports · Stride{% endblock %}
{% block breadcrumb %}SETTINGS & IMPORTS{% endblock %}
{% block content %}
<div class="page-heading"><div><span class="eyebrow">MAKE IT YOUR OWN</span><h1>Your space, your way.</h1><p>Manage your profile and bring your activities home.</p></div></div>
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">THE PERSON BEHIND THE MILES</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile ↗</button></form></section>
<section class="panel settings-card"><span class="eyebrow">BRING YOUR RUNS WITH YOU</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">↑</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity ↗</button></form></section>
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Your local sync</h2><p class="muted">{% 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 %}</p></div>{% if current_user.local_account_key %}<div class="connection-actions"><span class="pill public">Local imports linked</span><p class="field-help">After running the sync command, load any new downloads here. This button reads local files; it does not contact Garmin.</p><form method="post" action="{{ url_for('web.import_local') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Load new downloads ↻</button></form></div>{% endif %}</section></div>
<div class="page-heading"><div><span class="eyebrow">ACCOUNT</span><h1>Settings & imports</h1><p>Manage your profile and bring your activities home.</p></div></div>
<div class="settings-grid"><section class="panel settings-card"><span class="eyebrow">PROFILE</span><h2>Your profile</h2><form method="post" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Display name<input name="display_name" value="{{ current_user.display_name }}" required maxlength="80"></label><label>Time zone<input name="timezone" value="{{ current_user.timezone }}" list="timezones" required><datalist id="timezones"><option>Australia/Sydney</option><option>Australia/Melbourne</option><option>Australia/Brisbane</option><option>UTC</option><option>Europe/London</option><option>America/New_York</option></datalist></label><p class="field-help">Your <a href="{{ url_for('web.profile',username=current_user.username) }}">shared profile</a> is visible only to accepted friends and only includes activities you share.</p><button class="button primary">Save profile {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card"><span class="eyebrow">ACTIVITY IMPORT</span><h2>Import a FIT file</h2><p class="muted">Upload an original activity exported from your watch or Garmin Connect.</p><form action="{{ url_for('web.upload') }}" method="post" enctype="multipart/form-data" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label class="upload-zone"><span class="upload-icon">{{ icon('upload') }}</span><strong>Choose an activity file</strong><small>FIT format · up to 16 MB · imported privately</small><input type="file" name="file" accept=".fit" required></label><button class="button primary">Import activity {{ icon('arrow') }}</button></form></section>
<section class="panel settings-card connection-card"><div><span class="eyebrow">GARMIN CONNECT</span><h2>Garmin sync</h2>
{% if connection %}
<p>{{ connection.message }}</p><p class="field-help">{% 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' }}.</p>
<form method="post" action="{{ url_for('web.garmin_action', action='sync') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button primary">Sync now</button></form>
<form method="post" action="{{ url_for('web.garmin_action', action='schedule') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="enabled" value="{{ 'no' if connection.scheduled else 'yes' }}"><button class="button secondary">{{ 'Pause' if connection.scheduled else 'Enable' }} hourly sync</button></form>
<form method="post" action="{{ url_for('web.garmin_action', action='disconnect') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="button secondary">Disconnect Garmin</button></form>
{% endif %}
<p class="muted">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.</p>
<form method="post" enctype="multipart/form-data" action="{{ url_for('web.garmin_action', action='connect') }}" class="stack"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><label>Garmin session<input type="file" name="tokens" accept=".json" required></label><button class="button primary">Connect Garmin</button></form>
<p class="field-help">Disconnect deletes the server session and stops scheduled sync. It keeps imported activities and does not revoke other session copies.</p>
</div></section></div>
{% endblock %}
+45
View File
@@ -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.
@@ -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")
@@ -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")
+1
View File
@@ -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]
+81
View File
@@ -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(
+116
View File
@@ -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
Generated
+41
View File
@@ -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" },