All tweaks added
Build and push images / build-backend (push) Successful in 33s
Build and push images / build-worker (push) Successful in 32s
Build and push images / build-frontend (push) Failing after 6s

This commit is contained in:
2026-06-06 18:10:35 +01:00
parent 043b3b7269
commit ec5a01d12a
92 changed files with 7517 additions and 784 deletions
+147 -134
View File
@@ -82,9 +82,24 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
if existing:
return {"activity_id": existing.id, "status": "duplicate"}
# Get user's configured max HR for accurate zone calculation
# Falls back to: user-set value → 220-age → activity max → 190
from app.models.user import User as UserModel
user_obj = db.execute(select(UserModel).where(UserModel.id == user_id)).scalar_one_or_none()
user_max_hr = None
if user_obj:
user_max_hr = user_obj.max_heart_rate
if not user_max_hr and user_obj.birth_year:
from datetime import date as _date
age = _date.today().year - user_obj.birth_year
user_max_hr = 220 - age
if not user_max_hr:
# Last resort: use activity max but warn this may shift zones
user_max_hr = parsed.get("max_heart_rate") or 190
hr_zones = calculate_hr_zones(
parsed.get("data_points", []),
parsed.get("max_heart_rate") or 190
user_max_hr
)
start_time = datetime.fromisoformat(parsed["start_time"])
@@ -169,6 +184,9 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
activity_id = activity.id
compute_personal_records.delay(activity_id, user_id, parsed)
# Auto route detection for running and cycling
if parsed.get("sport_type") in ("running", "cycling", "hiking", "walking"):
detect_route.delay(activity_id, user_id)
return {"activity_id": activity_id, "status": "ok"}
@@ -176,161 +194,156 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str):
def parse_wellness_fit(file_path: str, user_id: int):
"""
Parse a Garmin wellness/metrics FIT file and upsert into health_metrics.
These files contain resting HR, HRV, sleep, stress, SpO2 etc.
Uses wellness_parser which handles standard FIT + Garmin proprietary messages.
"""
import fitparse
from app.services.wellness_parser import parse_wellness_fit as _parse
from app.core.database import SyncSessionLocal
from app.models.user import HealthMetric
from datetime import datetime, timezone
from sqlalchemy import text
from datetime import datetime, timezone, date
try:
fit = fitparse.FitFile(file_path)
except Exception as e:
return {"status": "error", "error": str(e)}
result = _parse(file_path)
if result.get("error"):
return {"status": "error", "error": result["error"], "file": file_path}
# Collect all monitoring/daily summary records keyed by date
daily = {} # date -> dict of fields
def get_or_create_day(d: date) -> dict:
if d not in daily:
daily[d] = {}
return daily[d]
for record in fit.get_messages():
name = record.name
fields = {f.name: f.value for f in record if f.value is not None}
if name == "monitoring_info":
ts = fields.get("timestamp") or fields.get("local_timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
day.setdefault("resting_hr", fields.get("resting_heart_rate"))
elif name == "monitoring":
ts = fields.get("timestamp") or fields.get("local_timestamp")
if not ts:
continue
d = ts.date() if hasattr(ts, "date") else None
if not d:
continue
day = get_or_create_day(d)
# Accumulate steps (they're stored as increments)
if "steps" in fields:
day["steps"] = day.get("steps", 0) + int(fields["steps"])
if "heart_rate" in fields:
hrs = day.setdefault("heart_rates", [])
hrs.append(int(fields["heart_rate"]))
if "stress_level_value" in fields:
stresses = day.setdefault("stress_values", [])
stresses.append(int(fields["stress_level_value"]))
elif name == "hrv_status_summary":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
day.setdefault("hrv_nightly_avg", fields.get("weekly_average"))
day.setdefault("hrv_5min_high", fields.get("last_night_5_min_high"))
day.setdefault("hrv_status", str(fields.get("hrv_status", "")))
elif name == "sleep_level":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
levels = day.setdefault("sleep_levels", [])
levels.append(fields.get("sleep_level"))
elif name == "stress":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
if "stress_level_value" in fields:
stresses = day.setdefault("stress_values", [])
stresses.append(int(fields["stress_level_value"]))
elif name == "spo2_data":
ts = fields.get("timestamp")
if ts:
d = ts.date() if hasattr(ts, "date") else None
if d:
day = get_or_create_day(d)
readings = day.setdefault("spo2_readings", [])
if "spo2_percent" in fields:
readings.append(fields["spo2_percent"])
if not daily:
days = result.get("days", {})
if not days:
return {"status": "no_data", "file": file_path}
# Upsert into health_metrics using ON CONFLICT to handle concurrent workers
with SyncSessionLocal() as db:
for day_date, data in daily.items():
hrs = data.pop("heart_rates", [])
stresses = data.pop("stress_values", [])
spo2s = data.pop("spo2_readings", [])
sleep_levels = data.pop("sleep_levels", [])
resting_hr = data.get("resting_hr")
avg_hr = (sum(hrs) / len(hrs)) if hrs else None
avg_stress = (sum(stresses) / len(stresses)) if stresses else None
spo2_avg = (sum(spo2s) / len(spo2s)) if spo2s else None
# Garmin sleep levels: 0=unmeasurable, 1=awake, 2=light, 3=deep, 4=rem
sleep_deep_s = sum(30 for l in sleep_levels if l == 3) if sleep_levels else None
sleep_light_s = sum(30 for l in sleep_levels if l == 2) if sleep_levels else None
sleep_rem_s = sum(30 for l in sleep_levels if l == 4) if sleep_levels else None
sleep_awake_s = sum(30 for l in sleep_levels if l == 1) if sleep_levels else None
sleep_duration_s = (
(sleep_deep_s or 0) + (sleep_light_s or 0) + (sleep_rem_s or 0)
) or None
for day_date, data in days.items():
date_dt = datetime(day_date.year, day_date.month, day_date.day, tzinfo=timezone.utc)
# ON CONFLICT upsert - race-condition safe, COALESCE preserves existing data
db.execute(text("""
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, avg_stress,
spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status, steps,
INSERT INTO health_metrics (user_id, date, resting_hr, avg_hr_day, max_hr_day,
avg_stress, spo2_avg, hrv_nightly_avg, hrv_5min_high, hrv_status,
steps, floors_climbed, active_calories, total_calories,
sleep_duration_s, sleep_deep_s, sleep_light_s, sleep_rem_s, sleep_awake_s)
VALUES (:user_id, :date, :resting_hr, :avg_hr, :avg_stress,
:spo2_avg, :hrv_avg, :hrv_high, :hrv_status, :steps,
VALUES (:user_id, :date, :resting_hr, :avg_hr, :max_hr,
:avg_stress, :spo2_avg, :hrv_avg, :hrv_high, :hrv_status,
:steps, :floors, :active_cal, :total_cal,
:sleep_dur, :sleep_deep, :sleep_light, :sleep_rem, :sleep_awake)
ON CONFLICT (user_id, date) DO UPDATE SET
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
resting_hr = COALESCE(EXCLUDED.resting_hr, health_metrics.resting_hr),
avg_hr_day = COALESCE(EXCLUDED.avg_hr_day, health_metrics.avg_hr_day),
max_hr_day = COALESCE(EXCLUDED.max_hr_day, health_metrics.max_hr_day),
avg_stress = COALESCE(EXCLUDED.avg_stress, health_metrics.avg_stress),
spo2_avg = COALESCE(EXCLUDED.spo2_avg, health_metrics.spo2_avg),
hrv_nightly_avg = COALESCE(EXCLUDED.hrv_nightly_avg, health_metrics.hrv_nightly_avg),
hrv_5min_high = COALESCE(EXCLUDED.hrv_5min_high, health_metrics.hrv_5min_high),
hrv_status = COALESCE(EXCLUDED.hrv_status, health_metrics.hrv_status),
steps = COALESCE(EXCLUDED.steps, health_metrics.steps),
floors_climbed = COALESCE(EXCLUDED.floors_climbed, health_metrics.floors_climbed),
active_calories = COALESCE(EXCLUDED.active_calories, health_metrics.active_calories),
total_calories = COALESCE(EXCLUDED.total_calories, health_metrics.total_calories),
sleep_duration_s = COALESCE(EXCLUDED.sleep_duration_s, health_metrics.sleep_duration_s),
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
sleep_deep_s = COALESCE(EXCLUDED.sleep_deep_s, health_metrics.sleep_deep_s),
sleep_light_s = COALESCE(EXCLUDED.sleep_light_s, health_metrics.sleep_light_s),
sleep_rem_s = COALESCE(EXCLUDED.sleep_rem_s, health_metrics.sleep_rem_s),
sleep_awake_s = COALESCE(EXCLUDED.sleep_awake_s, health_metrics.sleep_awake_s)
"""), {
"user_id": user_id, "date": date_dt,
"resting_hr": resting_hr, "avg_hr": avg_hr,
"avg_stress": avg_stress, "spo2_avg": spo2_avg,
"resting_hr": data.get("resting_hr"),
"avg_hr": data.get("avg_hr_day"),
"max_hr": data.get("max_hr_day"),
"avg_stress": data.get("avg_stress"),
"spo2_avg": data.get("spo2_avg"),
"hrv_avg": data.get("hrv_nightly_avg"),
"hrv_high": data.get("hrv_5min_high"),
"hrv_status": data.get("hrv_status"),
"steps": data.get("steps"),
"sleep_dur": sleep_duration_s, "sleep_deep": sleep_deep_s,
"sleep_light": sleep_light_s, "sleep_rem": sleep_rem_s,
"sleep_awake": sleep_awake_s,
"floors": data.get("floors_climbed"),
"active_cal": data.get("active_calories"),
"total_cal": data.get("total_calories"),
"sleep_dur": data.get("sleep_duration_s"),
"sleep_deep": data.get("sleep_deep_s"),
"sleep_light": data.get("sleep_light_s"),
"sleep_rem": data.get("sleep_rem_s"),
"sleep_awake": data.get("sleep_awake_s"),
})
db.commit()
return {"status": "ok", "days_processed": len(daily), "file": file_path}
return {"status": "ok", "days_processed": len(days), "file": file_path}
@celery_app.task(name="detect_route")
def detect_route(activity_id: int, user_id: int):
"""
After importing an activity, check if it matches any existing named routes.
If two+ unassigned activities match each other, auto-create a named route.
"""
from app.services.route_matcher import routes_are_similar
from app.core.database import SyncSessionLocal
from app.models.user import Activity, NamedRoute
from sqlalchemy import select
with SyncSessionLocal() as db:
# Get the new activity
new_act = db.execute(
select(Activity).where(Activity.id == activity_id)
).scalar_one_or_none()
if not new_act or not new_act.polyline:
return {"status": "no_polyline"}
# Already assigned to a route?
if new_act.named_route_id:
return {"status": "already_assigned"}
# Check against existing named routes first
routes = db.execute(
select(NamedRoute).where(
NamedRoute.user_id == user_id,
NamedRoute.sport_type == new_act.sport_type,
)
).scalars().all()
for route in routes:
if route.reference_polyline and routes_are_similar(
new_act.polyline, route.reference_polyline,
new_act.bounding_box, route.bounding_box,
):
new_act.named_route_id = route.id
db.commit()
return {"status": "matched_existing", "route_id": route.id}
# No existing route matched - check unassigned activities for a match
candidates = db.execute(
select(Activity).where(
Activity.user_id == user_id,
Activity.sport_type == new_act.sport_type,
Activity.named_route_id == None,
Activity.id != activity_id,
Activity.polyline != None,
# Within 20% distance
Activity.distance_m >= (new_act.distance_m or 0) * 0.8,
Activity.distance_m <= (new_act.distance_m or 0) * 1.2,
)
).scalars().all()
for candidate in candidates:
if routes_are_similar(
new_act.polyline, candidate.polyline,
new_act.bounding_box, candidate.bounding_box,
):
# Auto-create a route from the older activity
older = candidate if candidate.start_time < new_act.start_time else new_act
newer = new_act if candidate.start_time < new_act.start_time else candidate
route_name = f"{older.sport_type.title()} route {older.start_time.strftime('%d %b %Y')}"
new_route = NamedRoute(
user_id=user_id,
name=route_name,
sport_type=older.sport_type,
reference_polyline=older.polyline,
bounding_box=older.bounding_box,
distance_m=older.distance_m,
auto_detected=True,
)
db.add(new_route)
db.flush()
older.named_route_id = new_route.id
newer.named_route_id = new_route.id
db.commit()
return {"status": "auto_created", "route_id": new_route.id}
return {"status": "no_match"}
@celery_app.task(name="compute_personal_records")
@@ -435,4 +448,4 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
"spo2": data.get("avgSpo2"),
})
db.commit()
db.commit()