feat: build Stride local app with Garmin imports and friends-only sharing
This commit is contained in:
+113
@@ -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
|
||||
@@ -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=[])
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
@@ -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: '© <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();
|
||||
@@ -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`}}}}
|
||||
});
|
||||
}
|
||||
@@ -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
Vendored
+9
@@ -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.
|
||||
Vendored
+26
@@ -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.
|
||||
Vendored
+14
File diff suppressed because one or more lines are too long
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
@@ -0,0 +1,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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user