feat: build Stride local app with Garmin imports and friends-only sharing

This commit is contained in:
Peter Stockings
2026-10-01 14:55:59 +10:00
parent 16e71a69d5
commit e37569ad09
45 changed files with 3735 additions and 40 deletions
+8
View File
@@ -0,0 +1,8 @@
* text=auto
*.py text eol=lf
*.md text eol=lf
*.html text eol=lf
*.css text eol=lf
*.js text eol=lf
*.toml text eol=lf
*.lock text eol=lf
+1
View File
@@ -0,0 +1 @@
3.12
+35 -35
View File
@@ -1,15 +1,18 @@
# Multi-user running dashboard: implementation plan
Status: planning only; application not yet implemented.
Date: 2026-09-30
Status: live local sync verified; first Flask app implemented with accounts, imports, charts, maps and friends-only sharing. See docs/APP_PROGRESS.md.
Updated: 2026-10-01
## Product and agreed decisions
Build a self-hosted running platform with user accounts, public profiles,
activity pages, and eventually social features similar to Strava. Initial device
Build Stride, a self-hosted running platform with user accounts, friends-only
profiles, activity pages, and social features similar to Strava. Initial device
is a Garmin Forerunner 265, but the data model must support other sources.
- Python, Flask, Jinja templates, PostgreSQL, SQLAlchemy and Flask-Migrate.
- Name/email/password signup; automatically generated internal profile identifiers.
- Sharing requires an accepted friend relationship. No Explore or public activity pages.
- Friends receive full recorded routes, with no start/end masking.
- Leaflet for routes; Chart.js for graphs; Gunicorn for production HTTP.
- `python-garminconnect` for unofficial Garmin integration; `fitdecode` for FIT parsing.
- Users authenticate to Garmin on their own computer with a helper script and
@@ -26,7 +29,7 @@ is a Garmin Forerunner 265, but the data model must support other sources.
Browser -> Flask web process -> PostgreSQL and private activity file storage.
Local Garmin helper -> user uploads token bundle -> connection validation job.
Background worker -> Garmin -> shared FIT import pipeline -> owned activities.
Public pages -> visibility checks -> sanitized public activity data.
Friends pages -> ownership/friendship checks -> full activity data for authorized viewers.
Start with a PostgreSQL-backed job table to avoid adding Redis for the initial
deployment. The worker claims jobs transactionally, leases them, retries with
@@ -67,11 +70,11 @@ pyproject.toml
## Phase 0: prove the token workflow
- [ ] Select and pin a compatible Python and `python-garminconnect` version for
- [x] Select and pin a compatible Python and `python-garminconnect` version for
both helper and server; lock dependencies. Do not assume old garth exports work.
- [ ] Write a local helper with hidden password input and interactive MFA.
- [x] Write a local helper with hidden password input and interactive MFA.
Authenticate directly with Garmin and export the supported session JSON.
- [ ] Wrap exports with an explicit bundle format and library version, or accept
- [x] Wrap exports with an explicit bundle format and library version, or accept
the pinned library's raw token JSON with a documented compatibility check.
- [ ] Test with the owner's account: local login, export, load in a Linux
container, identify Garmin account, and download one recorded run.
@@ -79,9 +82,9 @@ pyproject.toml
work from that host; record region/network limitations rather than assuming portability.
- [ ] Confirm refreshed credentials are captured and persisted; exercise expired
or revoked sessions and the reconnect state.
- [ ] Define whether the exported session remains in local use. Avoid simultaneous
- [x] Define whether the exported session remains in local use. Avoid simultaneous
helper/server refreshes of a shared credential set.
- [ ] Document that session tokens grant ongoing Garmin account access and are
- [x] Document that session tokens grant ongoing Garmin account access and are
not an app-specific read-only permission grant.
Exit: a documented end-to-end local-to-host import succeeds without sending a
@@ -114,7 +117,6 @@ identity and ownership; recovery and verification work with test email delivery.
- ActivityLap and ActivitySample: splits and timestamped measurements.
- ActivityFile: private storage key, checksum, size, import provenance.
- ImportJob: type, owner/connection, status, attempts, lease, idempotency key.
- PrivacyZone: owner and private geographic masking settings.
- [ ] Implement bounded FIT uploads and parsing through one shared service.
Validate contents, use generated storage paths, and bound resource consumption.
- [ ] Extract GPS, elapsed/timer time, distance, heart rate, cadence, elevation,
@@ -156,29 +158,27 @@ repeated imports do not create duplicates; failed imports do not leave partial a
Exit: two accounts sync without crossing credentials or data; restarts preserve
progress; expired sessions request reconnect; Garmin downtime leaves the site usable.
## Phase 4: public dashboard and privacy
## Phase 4: dashboard and friend access
- [ ] Build responsive profile, activity list, run detail, and personal dashboard pages.
- [ ] Add weekly/monthly mileage, pace, elevation, splits, and heart-rate charts.
- [ ] Use Leaflet routes with a chosen map tile provider, attribution, and usage policy.
- [ ] Add owner editing, publish/unpublish, delete, and configurable auto-publishing.
- [ ] Support private and public visibility first; add followers-only with Phase 5.
- [ ] Apply authorization to HTML, JSON, chart data, downloads, feeds, and caches.
- [ ] Mask private route zones on the server before public serialization; do not
leak original coordinates through chart payloads or connect lines across masked gaps.
- [ ] Make originals downloadable only by their owner initially.
- [ ] Invalidate public caches when visibility or masking settings change.
- [ ] Define aggregates so private runs cannot leak through public totals or feeds.
- [x] Build responsive profile, activity list, run detail, and personal dashboard pages.
- [x] Add weekly mileage, pace, splits, and heart-rate charts.
- [x] Use Leaflet routes with attribution; review tile provider for hosted deployment.
- [x] Add owner title editing and private/friends visibility controls.
- [x] Enforce ownership and friendship on profiles, HTML and JSON routes.
- [x] Show the full route to authorized viewers without geographic masking.
- [x] Exclude private activities from friends' totals and feeds.
- [ ] Add configurable automatic sharing, deletion and owner data export.
Exit: public visitors see only published, sanitized data; another signed-in user
cannot access private activity details or files by guessing IDs.
Exit: anonymous visitors and non-friends cannot see activity data. Accepted friends
see only friends-shared activities, including the full recorded route.
## Phase 5: social MVP
- [ ] Add following/unfollowing with private follow requests where applicable.
- [ ] Add a paginated feed filtered by current visibility and relationship state.
- [ ] Implement followers-only activities and test unfollow/block effects.
- [ ] Add account blocking, reporting, and basic site moderation before broad signup.
- [x] Add requests using an existing account's email and explicit recipient acceptance.
- [x] Add friends-only feed and profiles; remove public Explore functionality.
- [x] Add decline/cancel/remove; removal immediately revokes data access.
- [ ] Paginate the feed beyond its initial recent-50 limit.
- [ ] Add account blocking, reporting, and basic moderation before broad signup.
- [ ] Add kudos and comments after the core feed, with ownership checks and abuse limits.
Deferred: challenges, segments, leaderboards, live tracking, mobile apps, coaching,
@@ -204,11 +204,11 @@ additional wearable integrations, and a separate frontend application.
## Verification strategy
- Unit tests: FIT metric calculation, malformed files, duplicate detection,
privacy masking including repeated zone crossings, and credential serialization.
friendship authorization and full-route preservation, and credential serialization.
- Integration tests against PostgreSQL: per-user authorization, refresh races,
leases/retries, disconnect races, migrations, and transactional imports.
- Browser tests: register -> upload FIT -> inspect private run -> publish ->
public view -> unpublish; local-token connection with mocked Garmin responses.
- Browser tests: register -> upload FIT -> inspect private run -> share with friends ->
accept friendship -> view -> remove friendship -> denied; local-token connection with mocked Garmin responses.
- Use synthetic or explicitly consented and sanitized FIT fixtures. Never commit
real tokens, passwords, home coordinates, or personal raw recordings.
- Keep live Garmin tests opt-in; run the Phase 0 smoke test with the owner's account.
@@ -219,17 +219,17 @@ additional wearable integrations, and a separate frontend application.
1. Token portability proven and integration constraints recorded.
2. Accounts plus manual FIT upload and private activity view.
3. Public profiles, publishing controls, and server-side route privacy.
3. Friends-only profiles, sharing controls and full-route authorized views.
4. Per-user token upload, automatic sync, and reconnect flow.
5. Following/feed and operational readiness for invited users.
6. Broader signup after privacy, recovery, moderation, and restore checks pass.
## Decisions to resolve during implementation
- Product name and public domain; repository folder is a working name.
- Hosted domain for Stride; repository folder is a working name.
- Initial signup mode, email provider, and map tile provider.
- History backfill limit and file/account quotas.
- Public aggregate semantics and handling of activity updates/deletions upstream.
- Friends-only aggregate semantics and handling of activity updates/deletions upstream.
- Remote Git hosting/provider, visibility, and repository name if remote hosting is desired.
## References
+151 -5
View File
@@ -1,7 +1,65 @@
# Running dashboard
# Stride
A planned multi-user, self-hosted running dashboard built with Flask and deployed
as one Dokku application.
A multi-user running dashboard built with Flask, Jinja, SQLAlchemy, Leaflet and
Chart.js. The local version supports accounts, activity imports, dashboards,
full route maps, charts, and sharing with accepted friends.
## Run the app
```powershell
.\scripts\start-app.ps1
```
Or run the steps individually:
```powershell
uv sync --locked
uv run --locked flask --app app db upgrade
uv run --locked python run.py
```
Open **http://127.0.0.1:5000**. On first launch, create your account with your name,
email, and a new Stride password. No username is required. When exactly one local
Garmin account cache exists, setup attaches its downloaded activities to this
first account, privately. Setup is only enabled for local requests before an
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.
### Accounts and sharing
- Sign up with name, email and password; sign in with email and password.
- A generated internal profile identifier replaces a user-chosen username.
- Activity visibility is **Only me** or **Accepted friends**. Imports default to Only me.
- Add a friend by their registered email under Friends. The recipient must accept
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.
- Routes are not masked or trimmed. Access control, rather than geographic masking,
determines who can see the full route, including start and finish.
### Bringing in more activities
Run the local sync below, then choose **Settings & imports -> Load new downloads**.
This button imports the cache assigned to your account, without contacting Garmin.
Any account can also upload an original FIT file through Settings & imports.
Original files remain private and are not exposed by web download routes.
If multiple Garmin caches existed at setup, an operator can explicitly assign one:
```powershell
uv run --locked flask --app app import-local --email owner@example.com --account-key ACCOUNT_KEY
```
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.
The current server binds only to loopback and runs without debug mode.
Users will upload FIT files or connect Garmin by authenticating locally with
`python-garminconnect` and uploading their exported session tokens. The server
@@ -10,5 +68,93 @@ will import activities and provide profiles, charts, maps, and publishing contro
See [IMPLEMENTATION_PLAN.md](IMPLEMENTATION_PLAN.md) for the architecture,
phased checklist, acceptance criteria, and deployment plan.
Current status: planning only. No application or deployment has been created.
Do not add Garmin credentials, token exports, or personal activity files to Git.
## Run the local sync
Requires [uv](https://docs.astral.sh/uv/). Python 3.12 and dependencies are selected
by the project configuration and lockfile.
```powershell
uv sync --locked
uv run --locked python -m garmin_sync login
uv run --locked python -m garmin_sync sync --limit 5
```
Run `login` yourself in an interactive terminal. It prompts for your Garmin email,
hidden password, and any MFA code. Never paste those into chat or command-line flags.
The login goes directly to Garmin; no web service is running or receiving your password.
On Windows, `scripts/connect-garmin.ps1` combines login and a five-activity sync.
After login, commands resume the saved session without your password:
```powershell
uv run --locked python -m garmin_sync verify
uv run --locked python -m garmin_sync sync --limit 20 --type running
```
These are completed activities, not planned training workouts. The default
includes all activity types so the first test can find your most recent recording.
The limit is how many recent matching activities to examine, including already
downloaded ones; it is not the number of new downloads to find.
## Files and sessions
- Session: `instance/garmin/default/garmin_tokens.json` (private, ignored by Git).
- Activities: `data/garmin/<account-key>/<activity-id>/` (private, ignored by Git).
- `original.zip` or `original.fit`: unchanged original download.
- `activity.json`: activity-list metadata returned by Garmin.
- `activity-N.fit`: FIT members, assigned safe generated filenames.
- `activity-N.json`: decoded sessions, laps, record samples, counts, and units.
- `manifest.json`: completion marker and file checksums for safe retries.
- `last-sync.json`: summary of the last completed sync command.
Decoded FIT values retain their reported units (including GPS semicircles), listed
in the JSON `units` map. Datetimes are serialized as UTC. These outputs contain full routes and private raw data. The web app only
serves activity data after ownership or accepted-friend authorization.
To load an exported token file from another machine:
```powershell
uv run --locked python -m garmin_sync --profile imported import-tokens 'C:\path\garmin_tokens.json'
uv run --locked python -m garmin_sync --profile imported sync --limit 5
```
Exports must use the pinned `garminconnect==0.3.17` session format. Old garth
`oauth1_token.json`/`oauth2_token.json` files are unsupported. The importer validates
the session with Garmin before saving it. Use `--domain garmin.cn` before the
subcommand for a China account, consistently for login/import and subsequent syncs.
Named profiles isolate local token files; provider account identity partitions
downloads. The same account should have only one actively used session copy:
avoid concurrently syncing a copied export on multiple machines/profiles.
Tokens refresh when supported by Garmin and are saved after sync. Run `login`
again when a session expires or is rejected. Tokens grant ongoing account access;
they are not app-specific, read-only permissions. This tool only invokes read
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.
No credentials or real activity files should be committed to Git.
## Verification
```powershell
uv run --locked pytest -q
uv run --locked ruff check garmin_sync tests
uv run --locked ruff format --check garmin_sync tests
```
The offline tests generate a synthetic FIT file; they never authenticate to Garmin.
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.
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.
+113
View File
@@ -0,0 +1,113 @@
import os
import secrets
from datetime import UTC, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
import click
from flask import Flask, render_template
from .extensions import csrf, db, limiter, login_manager, migrate
ROOT = Path(__file__).resolve().parents[1]
def create_app(config=None):
app = Flask(__name__, 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"),
SECRET_KEY=os.environ.get("SECRET_KEY"),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=production,
PERMANENT_SESSION_LIFETIME=timedelta(days=7),
MAX_CONTENT_LENGTH=16 * 1024 * 1024,
LOCAL_SETUP=not production,
DATA_ROOT=str(ROOT / "data" / "garmin"),
RATELIMIT_STORAGE_URI=os.environ.get("RATELIMIT_STORAGE_URI", "memory://"),
)
app.config.update(config or {})
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"]:
secret_path = Path(app.instance_path) / ".app-secret"
secret_path.parent.mkdir(parents=True, exist_ok=True)
try:
fd = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w") as secret:
secret.write(secrets.token_hex(32))
except FileExistsError:
pass
app.config["SECRET_KEY"] = secret_path.read_text()
uri = app.config["SQLALCHEMY_DATABASE_URI"]
if uri.startswith("postgres://"):
app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace(
"postgres://", "postgresql+psycopg://", 1
)
elif uri.startswith("postgresql://"):
app.config["SQLALCHEMY_DATABASE_URI"] = uri.replace(
"postgresql://", "postgresql+psycopg://", 1
)
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
login_manager.login_view = "web.login"
csrf.init_app(app)
limiter.init_app(app)
from .models import User
from .routes import web
app.register_blueprint(web)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id)) if user_id.isdigit() else None
@app.template_filter("duration")
def duration(value):
value = round(value or 0)
return (
f"{value // 3600}:{value % 3600 // 60:02d}:{value % 60:02d}"
if value >= 3600
else f"{value // 60}:{value % 60:02d}"
)
@app.template_filter("localdate")
def localdate(value, timezone="Australia/Sydney", fmt="%d %b %Y"):
return value.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).strftime(fmt)
@app.after_request
def security_headers(response):
if not response.content_type.startswith(("text/css", "application/javascript", "image/")):
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
return response
for code in (400, 403, 404, 413, 429):
app.register_error_handler(
code, lambda error: (render_template("error.html", code=error.code), error.code)
)
@app.cli.command("import-local")
@click.option("--email", required=True)
@click.option("--account-key", required=True)
def import_local(email, account_key):
"""Operator-only command to assign an existing local Garmin export to a user."""
import re
from .services import import_cache
if not re.fullmatch(r"[a-f0-9]{24}", account_key):
raise click.ClickException("Invalid account key")
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
if not user:
raise click.ClickException("User not found")
user.local_account_key = account_key
count = import_cache(user, app.config["DATA_ROOT"])
db.session.commit()
click.echo(f"Imported {count} activities.")
return app
+12
View File
@@ -0,0 +1,12 @@
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import LoginManager
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from flask_wtf.csrf import CSRFProtect
db = SQLAlchemy()
login_manager = LoginManager()
csrf = CSRFProtect()
migrate = Migrate()
limiter = Limiter(key_func=get_remote_address, default_limits=[])
+68
View File
@@ -0,0 +1,68 @@
from datetime import UTC, datetime
from flask_login import UserMixin
from werkzeug.security import check_password_hash, generate_password_hash
from .extensions import db
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(40), unique=True, nullable=False)
email = db.Column(db.String(254), unique=True, nullable=False)
display_name = db.Column(db.String(80), nullable=False)
password_hash = db.Column(db.String(256), nullable=False)
timezone = db.Column(db.String(64), nullable=False, default="Australia/Sydney")
# Assigned only by local setup / operator CLI. Never accepted from registration input.
local_account_key = db.Column(db.String(64), unique=True)
created_at = db.Column(db.DateTime(timezone=True), default=lambda: datetime.now(UTC))
activities = db.relationship(
"Activity", backref="owner", lazy="select", cascade="all, delete-orphan"
)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
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)
source_key = db.Column(db.String(180), nullable=False)
title = db.Column(db.String(160), nullable=False)
sport = db.Column(db.String(40), nullable=False, default="running")
started_at = db.Column(db.DateTime(timezone=True), nullable=False, index=True)
distance_m = db.Column(db.Float, nullable=False, default=0)
duration_s = db.Column(db.Float, nullable=False, default=0)
elevation_m = db.Column(db.Float, nullable=False, default=0)
avg_hr = db.Column(db.Float)
visibility = db.Column(db.String(12), nullable=False, default="private")
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))
__table_args__ = (db.UniqueConstraint("owner_id", "source_key"),)
@property
def distance_km(self):
return self.distance_m / 1000
@property
def pace(self):
if self.distance_m <= 0:
return "—"
seconds = round(self.duration_s / self.distance_km)
return f"{seconds // 60}:{seconds % 60:02d}"
class Friendship(db.Model):
id = db.Column(db.Integer, primary_key=True)
low_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
high_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
requested_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
status = db.Column(db.String(12), nullable=False, default="pending")
__table_args__ = (
db.UniqueConstraint("low_id", "high_id"),
db.CheckConstraint("low_id < high_id"),
)
+375
View File
@@ -0,0 +1,375 @@
import hashlib
import re
import secrets
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo, available_timezones
from flask import (
Blueprint,
abort,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy.exc import IntegrityError
from werkzeug.security import check_password_hash, generate_password_hash
from garmin_sync.service import SyncError, atomic_write
from .extensions import db, limiter
from .models import Activity, Friendship, User
from .services import activity_data, import_cache, import_upload
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
def setup_available():
return (
current_app.config["LOCAL_SETUP"]
and request.remote_addr in {"127.0.0.1", "::1"}
and db.session.scalar(db.select(db.func.count(User.id))) == 0
)
def bootstrap_directory():
directories = [
p
for p in Path(current_app.config["DATA_ROOT"]).glob("*")
if p.is_dir() and re.fullmatch(r"[a-f0-9]{24}", p.name)
]
return directories[0] if len(directories) == 1 else None
def get_activity(activity_id, require_owner=False):
row = db.get_or_404(Activity, activity_id)
owner = current_user.is_authenticated and row.owner_id == current_user.id
allowed = (
current_user.is_authenticated
and row.visibility == "friends"
and are_friends(current_user.id, row.owner_id)
)
if (require_owner and not owner) or (not owner and not allowed):
abort(404)
return row, owner
def are_friends(first, second):
low, high = sorted((first, second))
return (
db.session.scalar(
db.select(Friendship.id).filter_by(low_id=low, high_id=high, status="accepted")
)
is not 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)
latest = max((r.started_at for r in rows), default=datetime.now(UTC))
latest_day = latest.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
monday = latest_day - timedelta(days=latest_day.weekday())
weeks = [monday - timedelta(weeks=n) for n in reversed(range(8))]
distances = defaultdict(float)
for row in rows:
day = row.started_at.replace(tzinfo=UTC).astimezone(ZoneInfo(timezone)).date()
distances[day - timedelta(days=day.weekday())] += row.distance_km
seconds = round(total_time / (total_distance / 1000)) if total_distance else None
return {
"distance": total_distance / 1000,
"duration": total_time,
"count": len(rows),
"elevation": sum(r.elevation_m for r in rows),
"pace": f"{seconds // 60}:{seconds % 60:02d}" if seconds is not None else "—",
"weeks": [w.strftime("%d %b") for w in weeks],
"weekly_km": [round(distances[w], 2) for w in weeks],
}
@web.get("/health")
def health():
db.session.execute(db.text("SELECT 1"))
return {"status": "ok"}
@web.get("/")
def index():
if setup_available():
return redirect(url_for("web.setup"))
return redirect(url_for("web.dashboard" if current_user.is_authenticated else "web.login"))
def register_user(is_setup=False):
username = secrets.token_hex(10)
email = request.form.get("email", "").strip().lower()
display_name = request.form.get("display_name", "").strip()
password = request.form.get("password", "")
if not re.fullmatch(r"[^\s@]+@[^\s@]+\.[^\s@]+", email) or len(email) > 254:
return "Enter a valid email address."
if not 1 <= len(display_name) <= 80:
return "Enter a display name of up to 80 characters."
if not 12 <= len(password) <= 128:
return "Use a password between 12 and 128 characters."
user = User(username=username, email=email, display_name=display_name)
user.set_password(password)
directory = bootstrap_directory() if is_setup else None
if directory:
user.local_account_key = directory.name
try:
db.session.add(user)
db.session.flush()
count = import_cache(user, current_app.config["DATA_ROOT"]) if directory else 0
db.session.commit()
except IntegrityError:
db.session.rollback()
return "That email is already registered."
session.clear()
login_user(user)
flash(
f"Welcome to Stride. {count} activities imported privately."
if count
else "Welcome to Stride."
)
return None
@web.route("/setup", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def setup():
if not setup_available():
abort(404)
directory = bootstrap_directory()
count = len(list(directory.glob("*/manifest.json"))) if directory else 0
error = register_user(True) if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="setup", error=error, import_count=count)
@web.route("/register", methods=["GET", "POST"])
@limiter.limit("10 per hour", methods=["POST"])
def register():
if setup_available():
return redirect(url_for("web.setup"))
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = register_user() if request.method == "POST" else None
if request.method == "POST" and not error:
return redirect(url_for("web.dashboard"))
return render_template("auth.html", mode="register", error=error)
@web.route("/login", methods=["GET", "POST"])
@limiter.limit("5 per minute", methods=["POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("web.dashboard"))
error = None
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
password = request.form.get("password", "")
user = db.session.scalar(db.select(User).filter_by(email=email))
valid = check_password_hash(user.password_hash if user else DUMMY_HASH, password[:129])
if user and valid and len(password) <= 128:
session.clear()
login_user(user)
return redirect(url_for("web.dashboard"))
error = "Email or password was not recognised."
return render_template("auth.html", mode="login", error=error)
@web.post("/logout")
@login_required
def logout():
logout_user()
session.clear()
return redirect(url_for("web.login"))
@web.get("/dashboard")
@login_required
def dashboard():
rows = db.session.scalars(
db.select(Activity).filter_by(owner_id=current_user.id).order_by(Activity.started_at.desc())
).all()
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, current_user.timezone),
profile=current_user,
personal=True,
)
@web.route("/friends", methods=["GET", "POST"])
@login_required
@limiter.limit("20 per hour", methods=["POST"])
def friends():
if request.method == "POST":
email = request.form.get("email", "").strip().lower()
other = db.session.scalar(db.select(User).filter_by(email=email))
if other and other.id != current_user.id:
low, high = sorted((current_user.id, other.id))
existing = db.session.scalar(db.select(Friendship).filter_by(low_id=low, high_id=high))
if not existing:
db.session.add(Friendship(low_id=low, high_id=high, requested_by=current_user.id))
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
flash(
"If that person has a Stride account, your request is available in their Friends page."
)
return redirect(url_for("web.friends"))
links = db.session.scalars(
db.select(Friendship).where(
db.or_(Friendship.low_id == current_user.id, Friendship.high_id == current_user.id)
)
).all()
people = [
{
"link": link,
"person": db.session.get(
User, link.high_id if link.low_id == current_user.id else link.low_id
),
}
for link in links
]
accepted_ids = [p["person"].id for p in people if p["link"].status == "accepted"]
rows = db.session.scalars(
db.select(Activity)
.where(Activity.owner_id.in_(accepted_ids), Activity.visibility == "friends")
.order_by(Activity.started_at.desc())
.limit(50)
).all()
return render_template("friends.html", people=people, rows=rows)
@web.post("/friends/<int:friendship_id>/<action>")
@login_required
def friendship_action(friendship_id, action):
link = db.get_or_404(Friendship, friendship_id)
if current_user.id not in (link.low_id, link.high_id):
abort(404)
if action == "accept" and link.status == "pending" and link.requested_by != current_user.id:
link.status = "accepted"
flash("You’re now friends. Activities shared with friends are visible to both of you.")
elif action == "remove":
db.session.delete(link)
flash("Connection removed. Shared activities are no longer accessible between you.")
else:
abort(400)
db.session.commit()
return redirect(url_for("web.friends"))
@web.get("/u/<username>")
@login_required
def profile(username):
user = db.first_or_404(db.select(User).filter_by(username=username))
if user.id != current_user.id and not are_friends(current_user.id, user.id):
abort(404)
rows = db.session.scalars(
db.select(Activity)
.filter_by(owner_id=user.id, visibility="friends")
.order_by(Activity.started_at.desc())
).all()
return render_template(
"dashboard.html",
rows=rows,
stats=overview(rows, user.timezone),
profile=user,
personal=False,
)
@web.get("/activities/<int:activity_id>")
def activity(activity_id):
row, owner = get_activity(activity_id)
return render_template("activity.html", activity=row, owner_view=owner)
@web.get("/activities/<int:activity_id>/data")
def data(activity_id):
row, owner = get_activity(activity_id)
return jsonify(activity_data(row, owner))
@web.post("/activities/<int:activity_id>/edit")
@login_required
def edit_activity(activity_id):
row, _ = get_activity(activity_id, True)
title = request.form.get("title", "").strip()
visibility = request.form.get("visibility", "private")
if not title or len(title) > 160 or visibility not in {"friends", "private"}:
abort(400)
row.title, row.visibility = title, visibility
db.session.commit()
flash("Activity updated.")
return redirect(url_for("web.activity", activity_id=row.id))
@web.route("/settings", methods=["GET", "POST"])
@login_required
def settings():
if request.method == "POST":
name = request.form.get("display_name", "").strip()
timezone = request.form.get("timezone", "")
if not 1 <= len(name) <= 80 or timezone not in available_timezones():
abort(400)
current_user.display_name, current_user.timezone = name, timezone
db.session.commit()
flash("Profile saved.")
return redirect(url_for("web.settings"))
return render_template("settings.html")
@web.post("/imports/cache")
@login_required
def import_local():
if not current_user.local_account_key:
abort(404)
try:
count = import_cache(current_user, current_app.config["DATA_ROOT"])
db.session.commit()
flash(f"Imported {count} new activities from your local Garmin sync.")
except (SyncError, ValueError, OSError):
db.session.rollback()
flash("An import could not be read. Check the local sync output and try again.", "error")
return redirect(url_for("web.dashboard"))
@web.post("/imports/fit")
@login_required
def upload():
file = request.files.get("file")
if not file or not file.filename.lower().endswith(".fit"):
flash("Choose a FIT activity file.", "error")
return redirect(url_for("web.settings"))
payload = file.read(16 * 1024 * 1024 + 1)
if len(payload) > 16 * 1024 * 1024:
abort(413)
try:
row = import_upload(current_user, payload)
if row:
destination = Path(current_app.instance_path) / "uploads" / str(current_user.id)
atomic_write(destination / f"{hashlib.sha256(payload).hexdigest()}.fit", payload)
db.session.commit()
flash("Activity imported privately.")
return redirect(url_for("web.activity", activity_id=row.id))
flash("You have already imported that file.")
except (SyncError, ValueError, TypeError, OSError):
db.session.rollback()
flash(
"The FIT file could not be imported. Check it contains a completed activity.", "error"
)
return redirect(url_for("web.settings"))
+130
View File
@@ -0,0 +1,130 @@
import hashlib
import json
import math
from datetime import UTC, datetime
from pathlib import Path
from garmin_sync.service import SyncError, complete, decode_fit
from .extensions import db
from .models import Activity
def number(value, default=0):
if isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value):
return float(value)
return default
def instant(value):
if isinstance(value, datetime):
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
parsed = datetime.fromisoformat(str(value))
return parsed.replace(tzinfo=UTC) if parsed.tzinfo is None else parsed.astimezone(UTC)
def activity_from_fit(owner_id, source_key, documents, metadata=None):
metadata = metadata or {}
if db.session.scalar(db.select(Activity).filter_by(owner_id=owner_id, source_key=source_key)):
return None
sessions = [s for doc in documents for s in doc.get("session", [])]
if not sessions:
raise SyncError("This FIT file has no completed activity session.")
first = sessions[0]
sport = str(first.get("sport") or "activity")
started = instant(first.get("start_time") or metadata.get("startTimeGMT"))
records = [r for doc in documents for r in doc.get("record", [])]
samples = []
for record in records:
lat, lng = record.get("position_lat"), record.get("position_long")
point = None
if lat is not None and lng is not None:
lat = number(lat, None)
lng = number(lng, None)
if lat is not None and lng is not None:
lat, lng = lat * 180 / 2**31, lng * 180 / 2**31
if -90 <= lat <= 90 and -180 <= lng <= 180:
point = [lat, lng]
samples.append(
{
"point": point,
"distance": number(record.get("distance"), None),
"heart_rate": number(record.get("heart_rate"), None),
"speed": number(record.get("enhanced_speed", record.get("speed")), None),
"elevation": number(record.get("enhanced_altitude", record.get("altitude")), None),
}
)
laps = []
for document in documents:
for lap in document.get("lap", []):
distance = number(lap.get("total_distance"))
duration = number(lap.get("total_timer_time"))
laps.append(
{
"distance": distance,
"duration": duration,
"heart_rate": number(lap.get("avg_heart_rate"), None),
"pace": duration * 1000 / distance if distance > 0 else None,
}
)
row = Activity(
owner_id=owner_id,
source_key=source_key,
title=str(metadata.get("activityName") or f"{started:%A} {sport}")[:160],
sport=sport,
started_at=started,
distance_m=sum(number(s.get("total_distance")) for s in sessions),
duration_s=sum(number(s.get("total_timer_time")) for s in sessions),
elevation_m=sum(number(s.get("total_ascent")) for s in sessions),
avg_hr=number(metadata.get("averageHR", first.get("avg_heart_rate")), None),
samples=samples,
laps=laps,
)
db.session.add(row)
return row
def import_cache(user, data_root):
if not user.local_account_key:
return 0
directory = Path(data_root) / user.local_account_key
count = 0
for manifest in sorted(directory.glob("*/manifest.json")):
if not complete(manifest.parent):
continue
metadata = json.loads((manifest.parent / "activity.json").read_text())
docs = [json.loads(p.read_text()) for p in sorted(manifest.parent.glob("activity-*.json"))]
source_key = f"garmin:{user.local_account_key}:{manifest.parent.name}"
if activity_from_fit(user.id, source_key, docs, metadata):
count += 1
return count
def import_upload(user, payload):
digest = hashlib.sha256(payload).hexdigest()
document = decode_fit(payload)
# Normalize datetime values before SQLAlchemy's JSON serializer.
from garmin_sync.service import json_default
document = json.loads(json.dumps(document, default=json_default))
return activity_from_fit(user.id, f"file:{digest}", [document])
def activity_data(activity, owner_view=False):
"""Call only after access checks. Owners and accepted friends see the full route."""
segments, segment = [], []
for sample in activity.samples:
point = sample.get("point")
if point:
segment.append(point)
elif segment:
segments.append(segment)
segment = []
if segment:
segments.append(segment)
step = max(1, math.ceil(len(activity.samples) / 750))
chart = [
{k: sample.get(k) for k in ("distance", "heart_rate", "speed", "elevation")}
for sample in activity.samples[::step]
]
return {"segments": segments, "chart": chart}
+32
View File
@@ -0,0 +1,32 @@
"use strict";
async function showActivity() {
const container = document.getElementById("route-map");
const status = document.getElementById("map-status");
try {
const response = await fetch(container.dataset.url);
if (!response.ok) throw new Error("Activity unavailable");
const data = await response.json();
if (data.segments.length && window.L) {
container.replaceChildren();
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(data.segments, {color: "#345e40", weight: 4, opacity: .95}).addTo(map);
map.fitBounds(route.getBounds(), {padding: [35, 35], maxZoom: 16});
status.textContent = "Scroll to explore your charts. Drag the map to explore the route.";
} else {
container.querySelector("p").textContent = "No route to display. This activity may have been recorded indoors.";
status.textContent = "Activity statistics are available below.";
}
if (!window.Chart) return;
const config = (id, key, color, formatter, reverse = false) => {
const points = data.chart.filter(s => s.distance !== null).map(s => ({x: s.distance / 1000, y: formatter(s[key])}));
new Chart(document.getElementById(id), {type: "line", data: {datasets: [{data: points, borderColor: color, backgroundColor: `${color}12`, fill: true, borderWidth: 1.7, pointRadius: 0, pointHitRadius: 12, tension: .2, spanGaps: false}]}, options: {responsive: true, maintainAspectRatio: false, parsing: false, plugins: {legend: {display: false}}, scales: {x: {type: "linear", grid: {display: false}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b", callback: v => `${v} km`}}, y: {reverse, grid: {color: "#f0f2eb"}, border: {display: false}, ticks: {font: {size: 10}, color: "#949c8b"}}}}});
};
config("pace-chart", "speed", "#668b47", v => v > .5 ? Math.min(30, 1000 / v / 60) : null, true);
config("hr-chart", "heart_rate", "#bd8063", v => v > 0 ? v : null);
} catch {
container.querySelector("p").textContent = "We couldn’t load this activity. Please refresh and try again.";
status.textContent = "The activity may no longer be available.";
}
}
showActivity();
+10
View File
@@ -0,0 +1,10 @@
"use strict";
const volume = document.getElementById("volume-data");
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`}}}}
});
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><rect width="40" height="40" rx="12" fill="#233d31"/><path d="M27 12H18c-7 0-8 8-1 8h6c7 0 6 8-1 8H12" fill="none" stroke="#d8ef8c" stroke-width="4"/></svg>

After

Width:  |  Height:  |  Size: 217 B

File diff suppressed because one or more lines are too long
+9
View File
@@ -0,0 +1,9 @@
The MIT License (MIT)
Copyright (c) 2014-2024 Chart.js Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
BSD 2-Clause License
Copyright (c) 2010-2023, Volodymyr Agafonkin
Copyright (c) 2010-2011, CloudMade
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+14
View File
File diff suppressed because one or more lines are too long
+661
View File
@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
+6
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -0,0 +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 %}
</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 %}
+14
View File
@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}{{ activity.title }} · Stride{% endblock %}
{% block breadcrumb %}ACTIVITY / {{ activity.sport|upper }}{% endblock %}
{% block head %}<link rel="stylesheet" href="{{ url_for('static',filename='vendor/leaflet.css') }}">{% endblock %}
{% 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') }}</p></div><span class="pill {{ activity.visibility }}">{{ activity.visibility|title }}</span></div>
<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></article><article class="stat-card"><div class="stat-label">TIMER TIME</div><div class="stat-value time-value">{{ activity.duration_s|duration }}</div></article><article class="stat-card"><div class="stat-label">AVERAGE PACE</div><div class="stat-value">{{ activity.pace }}<small>/km</small></div></article><article class="stat-card"><div class="stat-label">AVERAGE HEART RATE</div><div class="stat-value">{{ activity.avg_hr|int if activity.avg_hr else '—' }}<small>bpm</small></div></article></div>
<section class="panel map-panel"><div class="section-head"><h2>The route.</h2><span class="subtle-label">{{ 'Full recorded route' }}</span></div><div id="route-map" data-url="{{ url_for('web.data',activity_id=activity.id) }}"><p class="map-placeholder">Loading activity…</p></div><p id="map-status" class="map-status"></p></section>
<div class="detail-charts"><section class="panel"><div class="section-head"><h2>Pace</h2><span class="subtle-label">min/km</span></div><div class="chart-wrap"><canvas id="pace-chart" role="img" aria-label="Pace over distance"></canvas></div></section><section class="panel"><div class="section-head"><h2>Heart rate</h2><span class="subtle-label">bpm</span></div><div class="chart-wrap"><canvas id="hr-chart" role="img" aria-label="Heart rate over distance"></canvas></div></section></div>
<div class="detail-bottom"><section class="panel"><div class="section-head"><h2>Laps & splits</h2><span class="subtle-label">{{ activity.laps|length }} recorded laps</span></div><table class="splits-table"><thead><tr><th>LAP</th><th>DISTANCE</th><th>TIME</th><th>PACE /KM</th><th>HR</th></tr></thead><tbody>{% for lap in activity.laps %}<tr><td>{{ loop.index }}</td><td>{{ '%.2f'|format(lap.distance / 1000) }} km</td><td>{{ lap.duration|duration }}</td><td>{{ lap.pace|duration if lap.pace else '—' }}</td><td>{{ lap.heart_rate|int if lap.heart_rate else '—' }}</td></tr>{% else %}<tr><td colspan="5">No laps recorded.</td></tr>{% endfor %}</tbody></table></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 can see the full route, date, statistics and charts of shared activities. Choose Only me to keep this activity private.</p><button class="button primary">Save changes ↗</button></form></section>{% endif %}</div>
{% 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 %}
+26
View File
@@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block title %}{{ 'Welcome' if mode == 'setup' else 'Sign in' if mode == 'login' else 'Join' }} · Stride{% endblock %}
{% 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="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>
<p class="muted">{% if mode == 'setup' %}Create your Stride account. Your {{ import_count }} downloaded activities will be imported privately.{% elif mode == 'login' %}Sign in to your running dashboard.{% else %}Track your progress and share your runs with friends.{% endif %}</p>
{% if error %}<p role="alert" class="form-error">{{ error }}</p>{% endif %}
<form method="post" class="stack">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if mode != 'login' %}
<label>Your name<input name="display_name" required maxlength="80" autocomplete="name" placeholder="How should we call you?" value="{{ request.form.get('display_name','') }}"></label>
{% 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>
</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 %}
</section>
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Stride · Your running story{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="icon" href="{{ url_for('static', filename='mark.svg') }}" type="image/svg+xml">
{% block head %}{% endblock %}
</head>
<body>
<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>
<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>
{% 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.settings' }}" href="{{ url_for('web.settings') }}"><span>⚙</span> Settings & imports</a>
{% else %}
<a class="nav-item {{ 'active' if request.endpoint == 'web.login' }}" href="{{ url_for('web.login') }}"><span>↪</span> 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>
{% else %}<span class="muted">Made for the long run.</span>{% endif %}
</div>
</aside>
<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>
<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>
</div>
</main>
{% block scripts %}{% endblock %}
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}{{ 'Overview' if personal else profile.display_name }} · Stride{% endblock %}
{% block breadcrumb %}{{ 'OVERVIEW' if personal else 'SHARED PROFILE' }}{% 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="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>
</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>
</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>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static',filename='vendor/chart.umd.js') }}"></script><script src="{{ url_for('static',filename='dashboard.js') }}"></script>{% endblock %}
+2
View File
@@ -0,0 +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 %}
+14
View File
@@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% block title %}Friends · Stride{% endblock %}
{% block breadcrumb %}FRIENDS{% endblock %}
{% 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">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">
{% 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>
<section class="panel activities-panel"><div class="section-head"><div><span class="eyebrow">MILES FROM YOUR CIRCLE</span><h2>Friends’ activities</h2></div><span class="subtle-label">Shared with you</span></div>{% include '_activities.html' %}</section>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% 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>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
# Stride local application
Updated: 2026-10-01
## Delivered
- Flask application factory and Jinja UI; local Waitress server on loopback port 5000.
- SQLite for local development, PostgreSQL driver/configuration, Alembic migrations.
- Name/email/password signup, generated profile identifier, session login/logout.
- CSRF checks, password hashing, login rate limits and no-store responses.
- Local first-account setup imports the existing Garmin cache privately.
- FIT upload, owner-specific cached imports, checksummed sync files and deduplication
by source identity. Cross-source file-versus-Garmin reconciliation remains a follow-up.
- Overview with distance/time/pace totals and weekly volume; activity pages with
route maps, pace/heart-rate charts and recorded laps.
- Profile editing and time-zone selection.
- Activity sharing choices: private or accepted friends. No public activity access.
- Friend requests by exact email, recipient acceptance, decline/cancel/remove,
friends feed and friendship-gated profiles. Full routes shown without masking.
- Local JavaScript assets with upstream licenses; OpenStreetMap tiles are fetched
by the browser with attribution. Select an appropriate tile service for deployment.
## Validation
- Offline sync and application tests cover FIT decoding, original downloads,
retries, credential redaction, ownership, private/friend visibility, CSRF,
friend acceptance/removal and preservation of full start/finish coordinates.
- Real local Garmin files imported into a temporary in-memory database successfully.
- Schema migrations applied to local SQLite.
- Browser checks use an isolated synthetic fixture: overview chart and route map
render; the real application remains ready for the user to create their account.
## Next increments
1. Web token upload with encrypted per-user sessions and connection validation.
2. Background imports, retries, refresh locking and scheduled Garmin polling.
3. Email verification and account recovery before broader hosted signup.
4. PostgreSQL integration checks and Dokku Docker deployment/backup process.
5. Deletion/export controls, pagination at larger activity counts and moderation.
Current local development choices are not a claim of production readiness.
+30
View File
@@ -0,0 +1,30 @@
# Local sync validation
Date: 2026-09-30
## Implemented and verified offline
- Python 3.12.12 virtual environment created locally with uv.
- Garmin client pinned to 0.3.17; complete dependency resolution in uv.lock.
- Interactive login/MFA, token import, password-free session verification, and sync CLI.
- Bounded recent activity pagination; all activities or running filter.
- Original download preservation, CRC-checked FIT decoding, sessions/laps/records JSON.
- Account-partitioned storage, profile locks, atomic files, checksums and repeat-run skipping.
- Errors suppress raw provider responses and credential-bearing exception text.
- Automated tests cover synthetic FIT decoding/CRC, archives, pagination, retries,
account separation, credential persistence, and secret redaction.
## Live account checks
- [x] Owner completes local login and MFA where required.
- [x] Saved session verifies in a new process without a password.
- [x] At least one completed activity is downloaded from Garmin.
- [x] FIT decoding reports recorded samples (or explains a valid activity without samples).
- [ ] Repeating the same sync skips completed activities.
- [ ] Exported session is validated on a separate Linux/Dokku runtime.
- [ ] A real refresh/reconnect lifecycle is observed.
User-confirmed live result: 5 found, 5 downloaded, 0 failed, 10,599 recorded
samples. Login and sync ran as separate CLI processes. Local Flask importer also
successfully read these files into a temporary database, with repeat imports
producing no duplicates. Linux/Dokku portability and live refresh remain unverified.
+1
View File
@@ -0,0 +1 @@
"""Local, read-only Garmin sync; reusable independently of the eventual web UI."""
+145
View File
@@ -0,0 +1,145 @@
"""Run with `uv run python -m garmin_sync --help`. Never accepts passwords as flags."""
import argparse
import getpass
import json
import logging
import os
import re
import sys
import warnings
from pathlib import Path
from filelock import FileLock, Timeout
from garminconnect import (
Garmin,
GarminConnectAuthenticationError,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
)
from .service import (
SyncError,
account_key,
atomic_write,
private_directory,
sync_activities,
write_json,
)
ROOT = Path(__file__).resolve().parents[1]
def parser():
cli = argparse.ArgumentParser(description="Read-only Garmin completed-activity sync.")
cli.add_argument("--profile", default="default", help="Local account slot (default: default)")
cli.add_argument("--domain", choices=["garmin.com", "garmin.cn"], default="garmin.com")
commands = cli.add_subparsers(dest="command", required=True)
commands.add_parser("login", help="Interactive local Garmin login and MFA; save session")
commands.add_parser("verify", help="Validate the saved session without credentials")
imported = commands.add_parser("import-tokens", help="Validate and copy a local token export")
imported.add_argument("file", type=Path)
sync = commands.add_parser("sync", help="Download and decode recent completed activities")
sync.add_argument("--limit", type=int, default=5, help="Recent activities to examine (1–1000)")
sync.add_argument("--type", choices=["all", "running"], default="all")
return cli
def run(args):
if not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", args.profile):
raise SyncError("Profile must be 1–64 letters, numbers, underscores or hyphens.")
if args.command == "sync" and not 1 <= args.limit <= 1000:
raise SyncError("Limit must be between 1 and 1000.")
# An inherited global token store must never select another account.
os.environ.pop("GARMINTOKENS", None)
store = ROOT / "instance" / "garmin" / args.profile
private_directory(store)
token_path = store / "garmin_tokens.json"
with FileLock(str(store / ".session.lock"), timeout=0):
api = Garmin(is_cn=args.domain == "garmin.cn")
if args.command == "login":
if not sys.stdin.isatty():
raise SyncError(
"Run login in your own interactive terminal; do not paste credentials into chat."
)
email = input("Garmin email: ").strip()
with warnings.catch_warnings():
warnings.simplefilter("error", getpass.GetPassWarning)
password = getpass.getpass("Garmin password (hidden): ")
api = Garmin(
email=email,
password=password,
is_cn=args.domain == "garmin.cn",
prompt_mfa=lambda: getpass.getpass("Garmin MFA code (hidden): "),
)
password = None
print("Authenticating with Garmin...", flush=True)
api.login() # Deliberately do not reuse a previous account's token file.
elif args.command == "import-tokens":
if args.file.stat().st_size > 65536:
raise SyncError("Token file is too large.")
payload = json.loads(args.file.read_text(encoding="utf-8-sig"))
if not isinstance(payload, dict) or not all(
isinstance(payload.get(key), str) and payload[key]
for key in ("di_token", "di_refresh_token", "di_client_id")
):
raise SyncError("Unsupported token format; export with garminconnect 0.3.17.")
api.login(
json.dumps(
{key: payload[key] for key in ("di_token", "di_refresh_token", "di_client_id")}
)
)
else:
if not token_path.is_file():
raise SyncError("No saved session. Run login or import-tokens first.")
api.login(str(token_path))
key = account_key(api, args.domain)
# Writes refreshed state for inline imports/login, plus every successful resume.
atomic_write(token_path, api.client.dumps().encode())
print(f"Garmin session verified. Local account: {key}", flush=True)
if args.command != "sync":
print(f"Session saved privately in instance/garmin/{args.profile}/garmin_tokens.json")
return 0
destination = ROOT / "data" / "garmin" / key
try:
result = sync_activities(
api, destination, args.limit, None if args.type == "all" else args.type
)
finally:
# Preserve rotations even if a later download fails.
atomic_write(token_path, api.client.dumps().encode())
write_json(destination / "last-sync.json", result)
print(json.dumps(result, indent=2))
print(f"Private activity files: {destination}")
return 1 if result["failed"] else 0
def main():
# Third-party exception/log strings can contain credentials and request URLs.
logging.disable(logging.CRITICAL)
args = parser().parse_args()
try:
return run(args)
except SyncError as error:
print(f"Error: {error}", file=sys.stderr)
except GarminConnectTooManyRequestsError:
print("Garmin rate limit reached. Wait before retrying.", file=sys.stderr)
except GarminConnectAuthenticationError:
print("Garmin rejected the session/login. Run login locally to reconnect.", file=sys.stderr)
except GarminConnectConnectionError:
print("Garmin connection failed. Retry later; no credentials were logged.", file=sys.stderr)
except Timeout:
print("Another command is using this profile. Wait for it to finish.", file=sys.stderr)
except (KeyboardInterrupt, EOFError):
print("Cancelled.", file=sys.stderr)
except Exception as error: # noqa: BLE001 -- CLI boundary must not expose provider secrets.
print(
f"Operation failed ({type(error).__name__}); details suppressed to protect credentials.",
file=sys.stderr,
)
return 1
if __name__ == "__main__":
raise SystemExit(main())
+201
View File
@@ -0,0 +1,201 @@
"""Download completed activities, preserving originals and decoded FIT data."""
from __future__ import annotations
import hashlib
import io
import json
import os
import tempfile
import zipfile
from collections import Counter
from datetime import UTC, date, datetime
from pathlib import Path
import fitdecode
from garminconnect import Garmin
MAX_BYTES = 64 * 1024 * 1024
class SyncError(Exception):
"""An actionable error with no provider response or credentials attached."""
def private_directory(path: Path) -> None:
path.mkdir(mode=0o700, parents=True, exist_ok=True)
if os.name != "nt":
path.chmod(0o700)
def atomic_write(path: Path, content: bytes) -> None:
private_directory(path.parent)
fd, name = tempfile.mkstemp(prefix=".pending-", dir=path.parent)
temporary = Path(name)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
temporary.replace(path)
finally:
temporary.unlink(missing_ok=True)
def json_default(value):
if isinstance(value, datetime):
# FIT timestamps are UTC; fitdecode returns naive datetime objects.
return value.replace(tzinfo=UTC).isoformat() if value.tzinfo is None else value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, bytes):
return value.hex()
raise TypeError(f"Unsupported JSON type: {type(value).__name__}")
def write_json(path: Path, value) -> None:
atomic_write(path, json.dumps(value, indent=2, default=json_default).encode("utf-8"))
def fit_payloads(payload: bytes) -> list[bytes]:
"""Read bounded FIT members without extracting archive paths to disk."""
if not payload or len(payload) > MAX_BYTES:
raise SyncError("Download was empty or exceeded the 64 MiB limit.")
if len(payload) >= 12 and payload[8:12] == b".FIT":
return [payload]
try:
with zipfile.ZipFile(io.BytesIO(payload)) as archive:
members = [m for m in archive.infolist() if m.filename.lower().endswith(".fit")]
if not members:
raise SyncError(
"Original download contains no FIT file (possibly a manual activity)."
)
if len(members) > 16 or sum(m.file_size for m in members) > MAX_BYTES:
raise SyncError("FIT archive exceeds the extraction limit.")
result = []
for member in members:
with archive.open(member) as stream:
content = stream.read(MAX_BYTES + 1)
if len(content) > MAX_BYTES:
raise SyncError("FIT archive exceeds the extraction limit.")
result.append(content)
return result
except (zipfile.BadZipFile, RuntimeError, NotImplementedError):
raise SyncError("Original download is not a readable FIT or ZIP file.") from None
def decode_fit(content: bytes) -> dict:
counts = Counter()
messages = {"session": [], "lap": [], "record": []}
units = {}
try:
with fitdecode.FitReader(io.BytesIO(content), check_crc=fitdecode.CrcCheck.RAISE) as reader:
for frame in reader:
if not isinstance(frame, fitdecode.FitDataMessage):
continue
counts[frame.name] += 1
if frame.name in messages:
values = {}
for field in frame.fields:
values[field.name] = field.value
if field.units:
units[f"{frame.name}.{field.name}"] = field.units
messages[frame.name].append(values)
except (fitdecode.FitError, ValueError, TypeError, KeyError, OverflowError):
raise SyncError("FIT decoding or CRC validation failed.") from None
if not counts:
raise SyncError("FIT file contains no data messages.")
return {"message_counts": dict(counts), "units": units, **messages}
def account_key(api: Garmin, domain: str) -> str:
# Loaded by Garmin.login from the provider, never taken from an uploaded label.
profile_id = getattr(api, "profile_id", None)
identity = f"id:{profile_id}" if isinstance(profile_id, int) else api.display_name
if not isinstance(identity, str) or not identity:
raise SyncError("Garmin did not return an account identity.")
return hashlib.sha256(f"{domain}:{identity}".encode()).hexdigest()[:24]
def complete(directory: Path) -> bool:
try:
manifest = json.loads((directory / "manifest.json").read_text(encoding="utf-8"))
files = manifest["files"]
if manifest.get("version") != 1 or not files:
return False
for name, digest in files.items():
if Path(name).name != name or "/" in name or "\\" in name:
return False
if hashlib.sha256((directory / name).read_bytes()).hexdigest() != digest:
return False
return True
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return False
def save_activity(api: Garmin, metadata: dict, root: Path) -> tuple[str, int]:
raw_id = metadata.get("activityId")
if isinstance(raw_id, bool) or not str(raw_id).isascii() or not str(raw_id).isdigit():
raise SyncError("Garmin returned an invalid activity ID.")
activity_id = str(int(raw_id))
if int(activity_id) <= 0:
raise SyncError("Garmin returned an invalid activity ID.")
directory = root / activity_id
if complete(directory):
return "skipped", 0
payload = api.download_activity(activity_id, dl_fmt=Garmin.ActivityDownloadFormat.ORIGINAL)
fits = fit_payloads(payload)
decoded = [decode_fit(content) for content in fits]
# Parse first; write a manifest last so incomplete imports are retried.
originals_name = "original.fit" if payload[8:12] == b".FIT" else "original.zip"
files = {originals_name: payload}
files["activity.json"] = json.dumps(metadata, indent=2, default=json_default).encode()
for index, (content, parsed) in enumerate(zip(fits, decoded, strict=True), start=1):
files[f"activity-{index}.fit"] = content
files[f"activity-{index}.json"] = json.dumps(
parsed, indent=2, default=json_default
).encode()
for name, content in files.items():
atomic_write(directory / name, content)
records = sum(item["message_counts"].get("record", 0) for item in decoded)
write_json(
directory / "manifest.json",
{
"version": 1,
"activity_id": activity_id,
"imported_at": datetime.now(UTC),
"fit_files": len(fits),
"record_count": records,
"files": {name: hashlib.sha256(content).hexdigest() for name, content in files.items()},
},
)
return "downloaded", records
def sync_activities(api: Garmin, root: Path, limit: int, activity_type: str | None) -> dict:
if not 1 <= limit <= 1000:
raise SyncError("Limit must be between 1 and 1000.")
result = {"found": 0, "downloaded": 0, "skipped": 0, "failed": 0, "records": 0, "errors": []}
offset = 0
while offset < limit:
page_size = min(50, limit - offset)
page = api.get_activities(start=offset, limit=page_size, activitytype=activity_type)
if not isinstance(page, list):
raise SyncError("Garmin returned an unexpected activity list.")
if not page:
break
for metadata in page[:page_size]:
result["found"] += 1
try:
if not isinstance(metadata, dict):
raise SyncError("Garmin returned invalid activity metadata.")
state, records = save_activity(api, metadata, root)
result[state] += 1
result["records"] += records
except SyncError as error:
result["failed"] += 1
result["errors"].append(str(error))
offset += len(page)
if len(page) < page_size:
break
return result
+1
View File
@@ -0,0 +1 @@
Single-database configuration for Flask.
+50
View File
@@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+105
View File
@@ -0,0 +1,105 @@
import logging
from logging.config import fileConfig
from alembic import context
from flask import current_app
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions["migrate"].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions["migrate"].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace("%", "%%")
except AttributeError:
return str(get_engine().url).replace("%", "%%")
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option("sqlalchemy.url", get_engine_url())
target_db = current_app.extensions["migrate"].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, "metadatas"):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=get_metadata(), literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
conf_args = current_app.extensions["migrate"].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=get_metadata(), **conf_args)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,61 @@
"""Restrict sharing to accepted friends
Revision ID: 342a45f4132a
Revises: 3a2896986e0e
Create Date: 2026-10-01 14:50:51.207109
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "342a45f4132a"
down_revision = "3a2896986e0e"
branch_labels = None
depends_on = None
def upgrade():
op.execute("UPDATE activity SET visibility = 'friends' WHERE visibility = 'public'")
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"friendship",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("low_id", sa.Integer(), nullable=False),
sa.Column("high_id", sa.Integer(), nullable=False),
sa.Column("requested_by", sa.Integer(), nullable=False),
sa.Column("status", sa.String(length=12), nullable=False),
sa.CheckConstraint("low_id < high_id"),
sa.ForeignKeyConstraint(
["high_id"],
["user.id"],
),
sa.ForeignKeyConstraint(
["low_id"],
["user.id"],
),
sa.ForeignKeyConstraint(
["requested_by"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("low_id", "high_id"),
)
with op.batch_alter_table("activity", schema=None) as batch_op:
batch_op.drop_column("share_route")
# ### end Alembic commands ###
def downgrade():
# Never restore public visibility as an incidental effect of a rollback.
op.execute("UPDATE activity SET visibility = 'private' WHERE visibility = 'friends'")
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("activity", schema=None) as batch_op:
batch_op.add_column(
sa.Column("share_route", sa.BOOLEAN(), nullable=False, server_default=sa.false())
)
op.drop_table("friendship")
# ### end Alembic commands ###
@@ -0,0 +1,75 @@
"""Create users and activities
Revision ID: 3a2896986e0e
Revises:
Create Date: 2026-09-30 21:11:01.589912
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "3a2896986e0e"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("username", sa.String(length=40), nullable=False),
sa.Column("email", sa.String(length=254), nullable=False),
sa.Column("display_name", sa.String(length=80), nullable=False),
sa.Column("password_hash", sa.String(length=256), nullable=False),
sa.Column("timezone", sa.String(length=64), nullable=False),
sa.Column("local_account_key", sa.String(length=64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
sa.UniqueConstraint("local_account_key"),
sa.UniqueConstraint("username"),
)
op.create_table(
"activity",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("source_key", sa.String(length=180), nullable=False),
sa.Column("title", sa.String(length=160), nullable=False),
sa.Column("sport", sa.String(length=40), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("distance_m", sa.Float(), nullable=False),
sa.Column("duration_s", sa.Float(), nullable=False),
sa.Column("elevation_m", sa.Float(), nullable=False),
sa.Column("avg_hr", sa.Float(), nullable=True),
sa.Column("visibility", sa.String(length=12), nullable=False),
sa.Column("share_route", sa.Boolean(), nullable=False),
sa.Column("samples", sa.JSON(), nullable=False),
sa.Column("laps", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(
["owner_id"],
["user.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_id", "source_key"),
)
with op.batch_alter_table("activity", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_activity_owner_id"), ["owner_id"], unique=False)
batch_op.create_index(batch_op.f("ix_activity_started_at"), ["started_at"], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("activity", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_activity_started_at"))
batch_op.drop_index(batch_op.f("ix_activity_owner_id"))
op.drop_table("activity")
op.drop_table("user")
# ### end Alembic commands ###
+31
View File
@@ -0,0 +1,31 @@
[project]
name = "running-dashboard"
version = "0.1.0"
description = "Local Garmin activity sync for a self-hosted running dashboard"
requires-python = ">=3.12,<3.13"
dependencies = [
"garminconnect==0.3.17",
"curl-cffi>=0.13,<1",
"fitdecode==0.11.0",
"filelock>=3.20,<4",
"flask>=3.1.3",
"flask-sqlalchemy>=3.1.1",
"flask-migrate>=4.1.0",
"flask-login>=0.6.3",
"flask-wtf>=1.3.0",
"flask-limiter>=4.1.1",
"psycopg[binary]>=3.3.6",
"waitress>=3.0.2",
"gunicorn>=23,<26; sys_platform != 'win32'",
]
[dependency-groups]
dev = ["pytest>=8,<10", "ruff>=0.14,<1"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
[tool.ruff]
target-version = "py312"
line-length = 100
+9
View File
@@ -0,0 +1,9 @@
"""Local server: uv run python run.py (no debug mode; loopback only)."""
from waitress import serve
from app import create_app
if __name__ == "__main__":
print("Stride is ready at http://127.0.0.1:5000", flush=True)
serve(create_app(), host="127.0.0.1", port=5000)
+6
View File
@@ -0,0 +1,6 @@
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)
& uv run --locked python -m garmin_sync login
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& uv run --locked python -m garmin_sync sync --limit 5
exit $LASTEXITCODE
+79
View File
@@ -0,0 +1,79 @@
"""Disposable UI fixture on port 5001. Synthetic friend-shared data; never reads Garmin files."""
import math
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from waitress import serve
from app import create_app
from app.extensions import db
from app.models import Activity, User
app = create_app(
{
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "disposable-ui-fixture",
"LOCAL_SETUP": False,
"RATELIMIT_ENABLED": False,
}
)
with app.app_context():
db.create_all()
user = User(
username="preview",
display_name="Preview Runner",
email="preview@example.test",
password_hash="disabled",
)
db.session.add(user)
db.session.flush()
for i in range(5):
samples = [
{
"point": [51.507 + 0.007 * math.sin(n / 70), -0.14 + n / 25000],
"distance": n * 20,
"speed": 2.8 + 0.3 * math.sin(n / 20),
"heart_rate": 140 + 12 * math.sin(n / 30),
"elevation": 10 + n / 30,
}
for n in range(300)
]
db.session.add(
Activity(
owner_id=user.id,
source_key=f"fixture:{i}",
title=[
"Morning miles",
"A little further",
"Easy Sunday",
"Tempo session",
"Evening loop",
][i],
started_at=datetime.now(UTC) - timedelta(days=i * 5),
distance_m=6000 + i * 700,
duration_s=2100 + i * 200,
elevation_m=38 + i * 7,
avg_hr=145,
sport="running",
visibility="friends",
samples=samples,
laps=[{"distance": 1000, "duration": 350, "pace": 350, "heart_rate": 145}] * 6,
)
)
db.session.commit()
# Test-only auto-login, isolated to this ephemeral process/database.
from flask_login import login_user
@app.before_request
def preview_login():
login_user(db.session.get(User, 1))
if __name__ == "__main__":
serve(app, host="127.0.0.1", port=5001)
+8
View File
@@ -0,0 +1,8 @@
$ErrorActionPreference = 'Stop'
Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)
& uv sync --locked
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& uv run --locked flask --app app db upgrade
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
& uv run --locked python run.py
exit $LASTEXITCODE
+225
View File
@@ -0,0 +1,225 @@
from datetime import UTC, datetime
from pathlib import Path
import pytest
from app import create_app
from app.extensions import db
from app.models import Activity, Friendship, User
from app.services import activity_data, import_cache
@pytest.fixture
def app(tmp_path):
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "test-only",
"WTF_CSRF_ENABLED": False,
"RATELIMIT_ENABLED": False,
"LOCAL_SETUP": False,
"DATA_ROOT": str(tmp_path),
}
)
with app.app_context():
db.create_all()
for name in ("runner", "other"):
user = User(username=name, email=f"{name}@example.test", display_name=name.title())
user.set_password("a-long-test-password")
db.session.add(user)
db.session.commit()
db.session.add(
Activity(
owner_id=1,
source_key="test:1",
title="A quiet morning run",
sport="running",
started_at=datetime(2026, 9, 30, tzinfo=UTC),
distance_m=5000,
duration_s=1800,
elevation_m=40,
avg_hr=140,
samples=[
{
"point": [-33, 151],
"distance": 0,
"heart_rate": 120,
"speed": 3,
"elevation": 10,
}
],
laps=[{"distance": 1000, "duration": 360, "heart_rate": 140, "pace": 360}],
)
)
db.session.commit()
yield app
def login(client, name="runner"):
return client.post(
"/login", data={"email": f"{name}@example.test", "password": "a-long-test-password"}
)
def test_private_activity_authorization_for_pages_json_and_mutations(app):
client = app.test_client()
assert client.get("/activities/1").status_code == 404
assert client.get("/activities/1/data").status_code == 404
login(client, "other")
assert client.get("/activities/1").status_code == 404
assert client.get("/activities/1/data").status_code == 404
assert client.post("/activities/1/edit", data={"title": "stolen"}).status_code == 404
assert client.post("/imports/cache").status_code == 404
assert b"A quiet morning run" not in client.get("/dashboard").data
def test_owner_dashboard_activity_json_and_settings_render(app):
client = app.test_client()
login(client)
for path in ("/dashboard", "/activities/1", "/settings"):
response = client.get(path)
assert response.status_code == 200
assert b"Stride" in response.data
data = client.get("/activities/1/data").json
assert data["segments"] == [[[-33, 151]]]
assert "point" not in data["chart"][0]
def test_friend_access_requires_acceptance_and_revokes_immediately(app):
owner = app.test_client()
friend = app.test_client()
anonymous = app.test_client()
login(owner)
login(friend, "other")
owner.post("/activities/1/edit", data={"title": "Shared morning run", "visibility": "friends"})
assert anonymous.get("/activities/1").status_code == 404
assert anonymous.get("/activities/1/data").status_code == 404
assert anonymous.get("/explore").status_code == 404
assert friend.get("/activities/1").status_code == 404
assert friend.get("/u/runner").status_code == 404
owner.post("/friends", data={"email": "other@example.test"})
assert friend.get("/activities/1/data").status_code == 404
assert owner.post("/friends/1/accept").status_code == 400
assert friend.post("/friends/1/accept").status_code == 302
assert b"Shared morning run" in friend.get("/friends").data
assert b"Shared morning run" in friend.get("/u/runner").data
data = friend.get("/activities/1/data")
assert data.status_code == 200
assert data.json["segments"] == [[[-33, 151]]]
assert "no-store" in data.headers["Cache-Control"]
assert friend.post("/activities/1/edit", data={"title": "changed"}).status_code == 404
friend.post("/friends/1/remove")
assert friend.get("/activities/1/data").status_code == 404
assert friend.get("/u/runner").status_code == 404
assert b"Shared morning run" not in friend.get("/friends").data
def test_private_activity_is_hidden_even_from_accepted_friend(app):
with app.app_context():
db.session.add(Friendship(low_id=1, high_id=2, requested_by=1, status="accepted"))
db.session.commit()
friend = app.test_client()
login(friend, "other")
assert friend.get("/activities/1").status_code == 404
assert friend.get("/activities/1/data").status_code == 404
assert b"A quiet morning run" not in friend.get("/u/runner").data
def test_full_route_includes_start_and_finish():
points = [[0, 0], [0, 0.001], [0, 0.01], [0, 0.02]]
row = Activity(samples=[{"point": point} for point in points])
assert activity_data(row)["segments"] == [points]
def test_register_creates_separate_user_with_no_local_account(app):
client = app.test_client()
response = client.post(
"/register",
data={
"username": "newrunner",
"email": "new@example.test",
"display_name": "New runner",
"password": "a-long-safe-password",
"local_account_key": "forged",
},
)
assert response.status_code == 302
with app.app_context():
user = db.session.scalar(db.select(User).filter_by(email="new@example.test"))
assert user.local_account_key is None
assert user.password_hash != "a-long-safe-password"
assert b"A quiet morning run" not in client.get("/dashboard").data
def test_csrf_rejects_state_changes(app):
app.config["WTF_CSRF_ENABLED"] = True
client = app.test_client()
assert client.post("/login", data={"email": "runner@example.test"}).status_code == 400
assert client.post("/imports/fit").status_code == 400
def test_setup_only_on_loopback_and_before_accounts(tmp_path):
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "test",
"WTF_CSRF_ENABLED": False,
"LOCAL_SETUP": True,
"RATELIMIT_ENABLED": False,
"DATA_ROOT": str(tmp_path),
}
)
with app.app_context():
db.create_all()
client = app.test_client()
assert client.get("/setup", environ_base={"REMOTE_ADDR": "192.168.1.5"}).status_code == 404
assert client.get("/setup").status_code == 200
assert (
client.post(
"/setup",
data={
"username": "owner",
"display_name": "Owner",
"email": "owner@example.test",
"password": "a-long-safe-password",
},
).status_code
== 302
)
assert client.get("/setup").status_code == 404
def test_real_cache_import_compatibility_without_persisting_user_data(tmp_path):
# Optional local check; never embeds the real data in the repository or test output.
source = Path(__file__).resolve().parents[1] / "data" / "garmin"
dirs = [p for p in source.glob("*") if p.is_dir()]
if not dirs:
pytest.skip("No local Garmin cache")
app = create_app(
{
"TESTING": True,
"SQLALCHEMY_DATABASE_URI": "sqlite://",
"SECRET_KEY": "test",
"RATELIMIT_ENABLED": False,
}
)
with app.app_context():
db.create_all()
user = User(
username="test",
display_name="Test",
email="test@example.test",
password_hash="not-a-login",
local_account_key=dirs[0].name,
)
db.session.add(user)
db.session.flush()
count = import_cache(user, source)
db.session.commit()
assert count > 0
assert import_cache(user, source) == 0
for row in db.session.scalars(db.select(Activity)):
assert row.distance_m >= 0 and row.duration_s >= 0
assert row.visibility == "private"
+189
View File
@@ -0,0 +1,189 @@
import io
import json
import struct
import zipfile
from types import SimpleNamespace
import pytest
from fitdecode.utils import compute_crc
from garmin_sync import __main__ as cli
from garmin_sync.service import (
SyncError,
account_key,
complete,
decode_fit,
fit_payloads,
sync_activities,
)
def synthetic_fit():
"""Tiny FIT with one timestamped heart-rate/distance record; no real user data."""
# Definition: local message 0, little endian, global record message 20.
definition = (
bytes([0x40, 0, 0])
+ struct.pack("<H", 20)
+ bytes(
[
3,
253,
4,
0x86,
3,
1,
0x02,
5,
4,
0x86,
]
)
)
record = bytes([0]) + struct.pack("<IBI", 1_000_000_000, 150, 123456)
data = definition + record
header = struct.pack("<BBHI4s", 14, 0x20, 2100, len(data), b".FIT")
header += struct.pack("<H", compute_crc(header))
content = header + data
return content + struct.pack("<H", compute_crc(content))
def zipped(content, name="activity.fit"):
stream = io.BytesIO()
with zipfile.ZipFile(stream, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr(name, content)
return stream.getvalue()
class FakeGarmin:
display_name = "synthetic-account"
def __init__(self, count=1, payload=None):
self.activities = [{"activityId": n + 1, "distance": 1234.56} for n in range(count)]
self.payload = zipped(synthetic_fit()) if payload is None else payload
self.downloads = 0
self.requests = []
def get_activities(self, start, limit, activitytype):
self.requests.append((start, limit, activitytype))
return self.activities[start : start + limit]
def download_activity(self, activity_id, dl_fmt):
self.downloads += 1
return self.payload
def test_decode_real_binary_fit_and_scaled_measurements():
result = decode_fit(synthetic_fit())
assert result["message_counts"] == {"record": 1}
assert result["record"][0]["heart_rate"] == 150
assert result["record"][0]["distance"] == 1234.56
assert result["units"]["record.distance"] == "m"
def test_bad_crc_rejected():
damaged = bytearray(synthetic_fit())
damaged[-1] ^= 1
with pytest.raises(SyncError, match="CRC"):
decode_fit(bytes(damaged))
def test_archive_paths_are_never_extracted(tmp_path):
content = synthetic_fit()
assert fit_payloads(zipped(content, "../../outside.fit")) == [content]
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize(
"payload", [b"", b"<html>Unauthorized</html>", zipped(b"text", "notes.txt")]
)
def test_bad_download_rejected(payload):
with pytest.raises(SyncError):
fit_payloads(payload)
def test_download_decode_deduplicate_and_repair(tmp_path):
api = FakeGarmin()
first = sync_activities(api, tmp_path, 5, None)
assert first["downloaded"] == 1
assert first["records"] == 1
assert complete(tmp_path / "1")
parsed = json.loads((tmp_path / "1" / "activity-1.json").read_text())
assert parsed["record"][0]["timestamp"].endswith("+00:00")
assert sync_activities(api, tmp_path, 5, None)["skipped"] == 1
assert api.downloads == 1
(tmp_path / "1" / "activity-1.fit").write_bytes(b"corrupt")
assert sync_activities(api, tmp_path, 5, None)["downloaded"] == 1
assert api.downloads == 2
def test_pagination_and_filter(tmp_path):
api = FakeGarmin(count=53)
result = sync_activities(api, tmp_path, 52, "running")
assert result["downloaded"] == 52
assert api.requests == [(0, 50, "running"), (50, 2, "running")]
def test_partial_failure_has_no_completed_manifest(tmp_path):
result = sync_activities(FakeGarmin(payload=b"bad"), tmp_path, 5, None)
assert result["failed"] == 1
assert not complete(tmp_path / "1")
def test_invalid_activity_id_cannot_escape_directory(tmp_path):
api = FakeGarmin()
api.activities = [{"activityId": "../escape"}]
assert sync_activities(api, tmp_path, 5, None)["failed"] == 1
assert api.downloads == 0
def test_account_isolation_and_stable_provider_id():
a = SimpleNamespace(display_name="one", profile_id=1)
b = SimpleNamespace(display_name="two", profile_id=2)
assert account_key(a, "garmin.com") != account_key(b, "garmin.com")
a.display_name = "renamed"
assert account_key(a, "garmin.com") == account_key(
SimpleNamespace(display_name="original", profile_id=1), "garmin.com"
)
def test_missing_session_requires_local_login(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "ROOT", tmp_path)
with pytest.raises(SyncError, match="No saved session"):
cli.run(cli.parser().parse_args(["sync"]))
def test_profile_cannot_escape(tmp_path, monkeypatch):
monkeypatch.setattr(cli, "ROOT", tmp_path)
with pytest.raises(SyncError, match="Profile"):
cli.run(cli.parser().parse_args(["--profile", "../outside", "verify"]))
def test_cli_never_prints_raw_provider_exception(monkeypatch, capsys):
def fail(_args):
raise RuntimeError("SECRET_TOKEN_AND_PASSWORD")
monkeypatch.setattr(cli, "run", fail)
monkeypatch.setattr("sys.argv", ["garmin_sync", "verify"])
assert cli.main() == 1
captured = capsys.readouterr()
assert "SECRET_TOKEN" not in captured.err + captured.out
def test_saved_session_sync_persists_rotated_credentials_even_on_failure(tmp_path, monkeypatch):
store = tmp_path / "instance" / "garmin" / "default"
store.mkdir(parents=True)
token = store / "garmin_tokens.json"
token.write_text('{"di_token":"old"}')
api = FakeGarmin()
api.client = SimpleNamespace(dumps=lambda: '{"di_token":"new"}')
api.login = lambda path: None
monkeypatch.setattr(cli, "ROOT", tmp_path)
monkeypatch.setattr(cli, "Garmin", lambda **kwargs: api)
def fail(*args):
raise SyncError("download failed")
monkeypatch.setattr(cli, "sync_activities", fail)
with pytest.raises(SyncError, match="download failed"):
cli.run(cli.parser().parse_args(["sync"]))
assert json.loads(token.read_text())["di_token"] == "new"
Generated
+663
View File
@@ -0,0 +1,663 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "alembic"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
]
[[package]]
name = "blinker"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.5.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" },
{ url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" },
{ url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" },
{ url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" },
{ url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" },
{ url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" },
{ url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" },
{ url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" },
{ url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" },
{ url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" },
{ url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" },
{ url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" },
{ url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" },
{ url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" },
{ url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" },
{ url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" },
{ url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" },
{ url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" },
{ url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" },
{ url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" },
{ url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" },
{ url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" },
{ url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" },
{ url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" },
{ url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" },
{ url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" },
{ url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
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 = "curl-cffi"
version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "cffi" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/e1/730125c43e3e331d98e17af3cb310ba526b3f1101b7635ca23d976ebfcf5/curl_cffi-0.16.3.tar.gz", hash = "sha256:d15d0c2a35f2d75bec430c28946c2a833f421c85773bdb0795182cc5c515665b", size = 239020, upload-time = "2026-09-02T11:58:23.266Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7a/ec08ef0665c4ef4ea76b47042eb1c043e4afb374d8b9218e00272c9e73a2/curl_cffi-0.16.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0f1f6878863fba393801e4d59b2f2766d1983b5c9d9dfa11d4becfd6a74cc937", size = 3025646, upload-time = "2026-09-02T11:57:39.326Z" },
{ url = "https://files.pythonhosted.org/packages/4c/86/e21b8ed384db26401a4438f20f01c7bcd9c3a6f8ceede458344e2d62775c/curl_cffi-0.16.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f3b63da797912bc82911e34dfe449725514e4281527fb516931fc457087cfb44", size = 2784023, upload-time = "2026-09-02T11:57:40.986Z" },
{ url = "https://files.pythonhosted.org/packages/97/2d/25b106e64178829be1ce171b6cd45ba354ab7a2a5169001866b38d4c440f/curl_cffi-0.16.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5a4103f2baa1fcf619ec3101b419827d367044ba106b206228137cc71a5a9c5", size = 12834219, upload-time = "2026-09-02T11:57:42.711Z" },
{ url = "https://files.pythonhosted.org/packages/e7/dd/db27a521777d0cf00f9a1554453ae730539dd134bca108d8df256a85c91e/curl_cffi-0.16.3-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f2795f0ef2e8cc0e6d702e52367af6600f5bcf10e44d683e256254adc7e3589f", size = 12655304, upload-time = "2026-09-02T11:57:45.334Z" },
{ url = "https://files.pythonhosted.org/packages/72/01/2bbf141baa0fc3921d31a90de5465b7a94188845a8fe84dee86bf7bd90f1/curl_cffi-0.16.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a875a661e2f9a949be29454880bbb9553307a487c4c08819738298cf5c1622e2", size = 13484311, upload-time = "2026-09-02T11:57:47.58Z" },
{ url = "https://files.pythonhosted.org/packages/bb/d4/745ca299a2a223ee18574ec7cff75de620a92ce69b3cb09490db8fba614b/curl_cffi-0.16.3-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0851e710608122a2716bdee35788bbd7e9d4a0fd42899b2bca9181277095af8e", size = 12840616, upload-time = "2026-09-02T11:57:50.016Z" },
{ url = "https://files.pythonhosted.org/packages/5e/bf/98d72d7a081cc155a71ab66bde6a18640d4ac5d4f6766f729a92cb4257c0/curl_cffi-0.16.3-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7e553442cefec100dfd1fca4ae7035ab6c094457244bf60a38670b8ac8185d", size = 12612602, upload-time = "2026-09-02T11:57:52.584Z" },
{ url = "https://files.pythonhosted.org/packages/36/cf/2fdaff71fd6f39c5994495af8378e6e26bca8e447d94d2f75a76337908c8/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:60621b3f561346046dd62be33abfb50c8b88a8007699d6b11d39ad4755312c4a", size = 12588697, upload-time = "2026-09-02T11:57:55.138Z" },
{ url = "https://files.pythonhosted.org/packages/74/55/68c399019bc24ea6ac783c98139a2555f88589631f3d127fe6b9073f019b/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:20a7b1b473371cfaf2118958034977e457c6fa279fbd11543c9e0ab58be9eedd", size = 13253555, upload-time = "2026-09-02T11:57:57.524Z" },
{ url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" },
]
[[package]]
name = "deprecated"
version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/9c/16649913bf14c73e0a9453782e148362ff2657067deff6aa9c7ebcddcc31/deprecated-3.0.0.tar.gz", hash = "sha256:16850204d3a1e6bb0acd06bff48d96e8b0a0d25d1c52f71705405a0f4894192d", size = 166912, upload-time = "2026-09-26T13:58:10.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/ae/676feae8e4644a6d7169951a97f61c56f416c73f67bf1761f2461d75cc81/deprecated-3.0.0-py3-none-any.whl", hash = "sha256:58204cf4a7f6270d547af5c278ee7a6bb56045a4b3d8441a1cd11660f41b7939", size = 21912, upload-time = "2026-09-26T13:58:09.458Z" },
]
[[package]]
name = "filelock"
version = "3.32.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/59/e19834834cb01a32febfbb0f8a23a9088088f5d45991824ff2bc3b5e8acb/filelock-3.32.7.tar.gz", hash = "sha256:37b8a3d9811b0f9aef7e5ec5c71bb320de52df51e6ca9bcd6f5ad81187660da7", size = 225154, upload-time = "2026-09-16T00:24:20.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/df/31098c5aeb4d966b553641472bd55fcf5fdfac953549894b8a765ba44e91/filelock-3.32.7-py3-none-any.whl", hash = "sha256:65ff0d0190ea42038b32bda4b77834fb05be2cad4c5b9b01aa4dfb3614536e52", size = 100157, upload-time = "2026-09-16T00:24:19.543Z" },
]
[[package]]
name = "fitdecode"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/87/19/1bde056f443d8ce890dbc0f0bd8a216cc9ebfda4221619767f80cf1835e2/fitdecode-0.11.0.tar.gz", hash = "sha256:52d920e50eaa76eb065b20bfd4e42f72195e894a079098dacc1cafa908cd4b83", size = 107149, upload-time = "2025-08-06T08:08:42.105Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/7b/75829f9b77b2546f955ec265de10de993939bf3218b5d957ca79803a2d28/fitdecode-0.11.0-py3-none-any.whl", hash = "sha256:a1bdb9b4d1e9ebac0001fc58c22fe07a3f9967ca4e13258c6ef99cdb8d697a78", size = 109961, upload-time = "2025-08-06T08:08:39.033Z" },
]
[[package]]
name = "flask"
version = "3.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "blinker" },
{ name = "click" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "markupsafe" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
]
[[package]]
name = "flask-limiter"
version = "4.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "limits" },
{ name = "ordered-set" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/98/71780be5d1afb941219c4b48d241d4e246e3062b017caa4e79c4dc71314c/flask_limiter-4.1.1.tar.gz", hash = "sha256:ca11608fc7eec43dcea606964ca07c3bd4ec1ae89043a0f67f717899a4f48106", size = 403198, upload-time = "2025-12-06T17:39:00.575Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/7c/9fe9ffc83be199011bb0c6deb82cdcbc5a355601e380581de9dbc30490dd/flask_limiter-4.1.1-py3-none-any.whl", hash = "sha256:e1ae13e06e6b3e39a4902e7d240b901586b25932c2add7bd5f5eeb4bdc11111b", size = 30554, upload-time = "2025-12-06T17:38:59.162Z" },
]
[[package]]
name = "flask-login"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
]
[[package]]
name = "flask-migrate"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "alembic" },
{ name = "flask" },
{ name = "flask-sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/47c7b3c93855ceffc2eabfa271782332942443321a07de193e4198f920cf/flask_migrate-4.1.0.tar.gz", hash = "sha256:1a336b06eb2c3ace005f5f2ded8641d534c18798d64061f6ff11f79e1434126d", size = 21965, upload-time = "2025-01-10T18:51:11.848Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/c4/3f329b23d769fe7628a5fc57ad36956f1fb7132cf8837be6da762b197327/Flask_Migrate-4.1.0-py3-none-any.whl", hash = "sha256:24d8051af161782e0743af1b04a152d007bad9772b2bca67b7ec1e8ceeb3910d", size = 21237, upload-time = "2025-01-10T18:51:09.527Z" },
]
[[package]]
name = "flask-sqlalchemy"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899, upload-time = "2023-09-11T21:42:36.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125, upload-time = "2023-09-11T21:42:34.514Z" },
]
[[package]]
name = "flask-wtf"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "itsdangerous" },
{ name = "wtforms" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/f1/605a56d4ea217b307f3e6f4d663e0351253d85d841edc93ba559f0648e19/flask_wtf-1.3.0.tar.gz", hash = "sha256:61d5dabc50c3df885c297dcbd80810443a5d632106c8a69cab8ce740f0cdd7cc", size = 50414, upload-time = "2026-04-23T07:41:55.096Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/d2/97adf2ec7af95522573e6dd5493ee84792d0fbfb2def010c4a581b8d6e5e/flask_wtf-1.3.0-py3-none-any.whl", hash = "sha256:dc5e3a4ce97f75c47bf6c1c72ad2c3b7bdf579a2ed13aebcc5d3d81fe2571160", size = 13959, upload-time = "2026-04-23T07:41:53.828Z" },
]
[[package]]
name = "garminconnect"
version = "0.3.17"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "curl-cffi" },
{ name = "requests" },
{ name = "ua-generator" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/4e/2758ce40ee00487a36154df4a3b1bfa6b7f3038954d49de3747718d853b4/garminconnect-0.3.17.tar.gz", hash = "sha256:f6b6dd282c950c394478cb5bcca506e1432e82037e27625d1456ef49c8f17ffe", size = 109120, upload-time = "2026-09-29T07:19:53.616Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/a5/cf958eab4eaae4bb94852a3819f367074a4a19e489938ae3cb1efc32bd87/garminconnect-0.3.17-py3-none-any.whl", hash = "sha256:624d8c2468d865d286c9fb7ae8133633bb1d52a2b019c8beb5ff9ae10dd43264", size = 102734, upload-time = "2026-09-29T07:19:52.182Z" },
]
[[package]]
name = "gunicorn"
version = "25.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "packaging" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "limits"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "deprecated" },
{ name = "packaging" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/69/826a5d1f45426c68d8f6539f8d275c0e4fcaa57f0c017ec3100986558a41/limits-5.8.0.tar.gz", hash = "sha256:c9e0d74aed837e8f6f50d1fcebcf5fd8130957287206bc3799adaee5092655da", size = 226104, upload-time = "2026-02-05T07:17:35.859Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/98/cb5ca20618d205a09d5bec7591fbc4130369c7e6308d9a676a28ff3ab22c/limits-5.8.0-py3-none-any.whl", hash = "sha256:ae1b008a43eb43073c3c579398bd4eb4c795de60952532dc24720ab45e1ac6b8", size = 60954, upload-time = "2026-02-05T07:17:34.425Z" },
]
[[package]]
name = "mako"
version = "1.4.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
]
[[package]]
name = "ordered-set"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "psycopg"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" },
]
[package.optional-dependencies]
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
[[package]]
name = "psycopg-binary"
version = "3.3.6"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" },
{ url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" },
{ url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" },
{ url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" },
{ url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" },
{ url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" },
{ url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" },
{ url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" },
{ url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" },
{ url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" },
{ url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.16.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" },
{ url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" },
{ url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" },
{ url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" },
{ url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" },
{ url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" },
{ url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" },
{ url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" },
{ url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" },
{ url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" },
{ url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" },
{ url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" },
{ url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" },
{ url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" },
{ url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" },
]
[[package]]
name = "running-dashboard"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "curl-cffi" },
{ name = "filelock" },
{ name = "fitdecode" },
{ name = "flask" },
{ name = "flask-limiter" },
{ name = "flask-login" },
{ name = "flask-migrate" },
{ name = "flask-sqlalchemy" },
{ name = "flask-wtf" },
{ name = "garminconnect" },
{ name = "gunicorn", marker = "sys_platform != 'win32'" },
{ name = "psycopg", extra = ["binary"] },
{ name = "waitress" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "curl-cffi", specifier = ">=0.13,<1" },
{ name = "filelock", specifier = ">=3.20,<4" },
{ name = "fitdecode", specifier = "==0.11.0" },
{ name = "flask", specifier = ">=3.1.3" },
{ name = "flask-limiter", specifier = ">=4.1.1" },
{ name = "flask-login", specifier = ">=0.6.3" },
{ name = "flask-migrate", specifier = ">=4.1.0" },
{ name = "flask-sqlalchemy", specifier = ">=3.1.1" },
{ name = "flask-wtf", specifier = ">=1.3.0" },
{ name = "garminconnect", specifier = "==0.3.17" },
{ name = "gunicorn", marker = "sys_platform != 'win32'", specifier = ">=23,<26" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.3.6" },
{ name = "waitress", specifier = ">=3.0.2" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest", specifier = ">=8,<10" },
{ name = "ruff", specifier = ">=0.14,<1" },
]
[[package]]
name = "sqlalchemy"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/cb/7c68da82239ffae9378eca076afe14c310e17a4f01e3d315112f266e26c7/sqlalchemy-2.1.1.tar.gz", hash = "sha256:fcf3cbb33bb23bad75d2150157c21804618745731e9931ab27e61879a9f6123b", size = 10503203, upload-time = "2026-09-25T14:59:00.117Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/44/950ac8e093784fdd5fd4fc385d110e0cf1d1d2bae781dc7be659bf3e7af3/sqlalchemy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abf1acecd4d0fc34d4ab7e9cda9e849ce95b647e1a5f76db7d2a5a0bea4e1e5", size = 2458475, upload-time = "2026-09-25T15:17:05.018Z" },
{ url = "https://files.pythonhosted.org/packages/20/13/ade5c1da6221e865a57ee45132a3c61528bb244d7b22ddbbddd589989754/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61333b2f806f64a08a1edf0b0e4271905f73c1f7e525c23da7a491f74c109835", size = 4653012, upload-time = "2026-09-25T15:25:47.467Z" },
{ url = "https://files.pythonhosted.org/packages/b2/95/37b6846549a76c69beca71974905446130d78bddd88a4414b5b88c88af16/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fad3dc37851b096ef72538b18152a5c1ae9c8b2013cd89f51eca83fc6eba36b", size = 4701803, upload-time = "2026-09-25T15:25:55.644Z" },
{ url = "https://files.pythonhosted.org/packages/57/88/abdc1ce5daa0af77c5d27183625ab7bb1155de41067fd77db4fb1cdc9616/sqlalchemy-2.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a3126c1203cdfc0f00884f9e8dbe95307b813f883ee9d9691983a563ebc96f8", size = 4351745, upload-time = "2026-09-25T15:31:43.337Z" },
{ url = "https://files.pythonhosted.org/packages/7b/28/4375244fc658c95033593429bc6dfc61df96beb9b64c8bf57821fdebf85d/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f90e517b2eb9c4c40fcc978c8c242789fa6cb0064c501fd8c1515145ed5fe23d", size = 4576898, upload-time = "2026-09-25T15:25:49.478Z" },
{ url = "https://files.pythonhosted.org/packages/4e/ce/0aa3c294150a7a018e61af42462cf299022ad71b4b94384be36fff6ec567/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:054a2c2b6cbe3ca7cb88aba9c5e2ecdbfc43656bea22326a41d09e5398c86ec4", size = 4346156, upload-time = "2026-09-25T15:31:45.624Z" },
{ url = "https://files.pythonhosted.org/packages/bd/17/cf00fdbcdd8f1933aa4b33d499218a8d470ec9fd935a2f33ec55dcef4fdc/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea049f3e2702967414bc79f58a484dab191359267726607e6c1579a09ef93788", size = 4650545, upload-time = "2026-09-25T15:25:57.819Z" },
{ url = "https://files.pythonhosted.org/packages/48/9b/8b4a17884e2b286d5f89e3b93bc2be928c6d86d7bfeadf9ae4459d64c244/sqlalchemy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:842a5c00ca9719cc26fba6f401f70e4bac19dc8cc4dc36350803a03954d6aea2", size = 2369166, upload-time = "2026-09-25T15:36:05.081Z" },
{ url = "https://files.pythonhosted.org/packages/ed/5a/c91c3cf7f0e65ae7ad9ebc87ec4cc68f6d163fddaa36736621a00dac5905/sqlalchemy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0220e0d1d1ad2391a03cd6713ca5d795419033cc40baa1233f9ad454dffa25e6", size = 2421474, upload-time = "2026-09-25T15:36:07.069Z" },
{ url = "https://files.pythonhosted.org/packages/e3/65/fc72cd55397792553b72a8c43e14531baa436d08f832486d2cff9e806f36/sqlalchemy-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af189bd861bd31a84f0ba244d6db0c8aeae9e56448c9001bf02ee6a93758b39e", size = 2381060, upload-time = "2026-09-25T15:11:45.018Z" },
{ url = "https://files.pythonhosted.org/packages/c5/7a/7d1879d78c086737acee5f3250cbff9d285e5bd7cb6bc2c730f24e8d400e/sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee", size = 2047471, upload-time = "2026-09-25T15:20:37.797Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "tzdata"
version = "2026.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
]
[[package]]
name = "ua-generator"
version = "2.1.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/48/ce6900f0e88e2158d137272820e47cf312fc691a9c26c9b447d79c3b31d3/ua_generator-2.1.6.tar.gz", hash = "sha256:532b8769f3dbb914b5ace2846a47cdfa2b3a2d17a9c82cfef53d19280045b18e", size = 29805, upload-time = "2026-09-20T14:40:42.639Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/03/d6ac97d7010451c43679c3efc46ff6b9e149cb3293ff93cf65f30fa58a54/ua_generator-2.1.6-py3-none-any.whl", hash = "sha256:877285e6adab3d6b0f9b295d954d121fc21a0305be8526038218e8f08be76df6", size = 32891, upload-time = "2026-09-20T14:40:41.391Z" },
]
[[package]]
name = "urllib3"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
]
[[package]]
name = "waitress"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/cb/04ddb054f45faa306a230769e868c28b8065ea196891f09004ebace5b184/waitress-3.0.2.tar.gz", hash = "sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f", size = 179901, upload-time = "2024-11-16T20:02:35.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" },
]
[[package]]
name = "werkzeug"
version = "3.1.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188, upload-time = "2026-09-27T18:33:41.637Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700, upload-time = "2026-09-27T18:33:39.685Z" },
]
[[package]]
name = "wrapt"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" },
{ url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" },
{ url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" },
{ url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" },
{ url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" },
{ url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" },
{ url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" },
{ url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" },
{ url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" },
{ url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" },
{ url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" },
]
[[package]]
name = "wtforms"
version = "3.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/91/ed9b517da898e3fb747566aa3c12a734bd64ea7449a0d25ec74ce8f8b8eb/wtforms-3.2.2.tar.gz", hash = "sha256:7b00c73f8670f35d4edb0293dcd81b980528bee72fd662b182aaba27ae570b93", size = 139583, upload-time = "2026-05-03T05:53:44.147Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/76/bb225c8300f3a0ba28e01df51419c6c9574a297c43d71b29048e03b65deb/wtforms-3.2.2-py3-none-any.whl", hash = "sha256:72b90d5d921bd3119252069cf0301e9c13915f9e52792652bc91c5dda4b79e56", size = 158656, upload-time = "2026-05-03T05:53:46.072Z" },
]