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
+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}