Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- api/auth-service/alembic/versions/0008_create_run_sensor_records.py +73 -0
- api/auth-service/app/api/v1/endpoints/running_coaching.py +123 -0
- api/auth-service/app/api/v1/endpoints/runs.py +132 -1
- api/auth-service/app/api/v1/endpoints/watch_run.py +67 -4
- api/auth-service/app/api/v1/router.py +11 -1
- api/auth-service/app/schemas/run.py +112 -1
- api/auth-service/app/services/running_coaching.py +531 -0
- api/auth-service/tests/test_running_coaching.py +53 -0
- main/app/_components/TopBar.tsx +1 -0
- main/app/_utils/runningCoaching.ts +156 -0
- main/app/run/[id]/finish/page.tsx +67 -0
- main/app/run/[id]/page.tsx +65 -0
- main/app/running-profile/page.tsx +149 -0
api/auth-service/alembic/versions/0008_create_run_sensor_records.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""create persisted wearable running sensor records
|
| 2 |
+
|
| 3 |
+
Revision ID: 0008_create_run_sensor_records
|
| 4 |
+
Revises: 0007_create_community_tables
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from alembic import op
|
| 8 |
+
import sqlalchemy as sa
|
| 9 |
+
from sqlalchemy.dialects import postgresql
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
revision = "0008_create_run_sensor_records"
|
| 13 |
+
down_revision = "0007_create_community_tables"
|
| 14 |
+
branch_labels = None
|
| 15 |
+
depends_on = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def upgrade() -> None:
|
| 19 |
+
bind = op.get_bind()
|
| 20 |
+
inspector = sa.inspect(bind)
|
| 21 |
+
if inspector.has_table("run_sensor_records"):
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
op.create_table(
|
| 25 |
+
"run_sensor_records",
|
| 26 |
+
sa.Column("run_session_id", sa.BigInteger(), primary_key=True),
|
| 27 |
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
| 28 |
+
sa.Column(
|
| 29 |
+
"sensor_summary",
|
| 30 |
+
postgresql.JSONB(astext_type=sa.Text()),
|
| 31 |
+
nullable=False,
|
| 32 |
+
server_default=sa.text("'{}'::jsonb"),
|
| 33 |
+
),
|
| 34 |
+
sa.Column(
|
| 35 |
+
"sensor_samples",
|
| 36 |
+
postgresql.JSONB(astext_type=sa.Text()),
|
| 37 |
+
nullable=False,
|
| 38 |
+
server_default=sa.text("'[]'::jsonb"),
|
| 39 |
+
),
|
| 40 |
+
sa.Column(
|
| 41 |
+
"analysis",
|
| 42 |
+
postgresql.JSONB(astext_type=sa.Text()),
|
| 43 |
+
nullable=False,
|
| 44 |
+
server_default=sa.text("'{}'::jsonb"),
|
| 45 |
+
),
|
| 46 |
+
sa.Column(
|
| 47 |
+
"created_at",
|
| 48 |
+
sa.DateTime(timezone=True),
|
| 49 |
+
nullable=False,
|
| 50 |
+
server_default=sa.text("now()"),
|
| 51 |
+
),
|
| 52 |
+
sa.Column(
|
| 53 |
+
"updated_at",
|
| 54 |
+
sa.DateTime(timezone=True),
|
| 55 |
+
nullable=False,
|
| 56 |
+
server_default=sa.text("now()"),
|
| 57 |
+
),
|
| 58 |
+
sa.ForeignKeyConstraint(["run_session_id"], ["run_sessions.id"], ondelete="CASCADE"),
|
| 59 |
+
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
| 60 |
+
)
|
| 61 |
+
op.create_index(
|
| 62 |
+
"idx_run_sensor_records_user_updated",
|
| 63 |
+
"run_sensor_records",
|
| 64 |
+
["user_id", "updated_at"],
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def downgrade() -> None:
|
| 69 |
+
bind = op.get_bind()
|
| 70 |
+
inspector = sa.inspect(bind)
|
| 71 |
+
if inspector.has_table("run_sensor_records"):
|
| 72 |
+
op.drop_index("idx_run_sensor_records_user_updated", table_name="run_sensor_records")
|
| 73 |
+
op.drop_table("run_sensor_records")
|
api/auth-service/app/api/v1/endpoints/running_coaching.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 7 |
+
from sqlalchemy import text
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from app.api.deps import get_current_user, get_db
|
| 11 |
+
from app.models.user import User
|
| 12 |
+
from app.services.running_coaching import (
|
| 13 |
+
build_run_analysis,
|
| 14 |
+
build_running_profile,
|
| 15 |
+
ensure_running_coaching_table,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
router = APIRouter(prefix="/auth/running-coaching", tags=["running-coaching"])
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _json_object(value: Any) -> dict[str, Any]:
|
| 23 |
+
if isinstance(value, dict):
|
| 24 |
+
return value
|
| 25 |
+
if isinstance(value, str):
|
| 26 |
+
try:
|
| 27 |
+
parsed = json.loads(value)
|
| 28 |
+
except json.JSONDecodeError:
|
| 29 |
+
return {}
|
| 30 |
+
return parsed if isinstance(parsed, dict) else {}
|
| 31 |
+
return {}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/profile")
|
| 35 |
+
def get_running_profile(
|
| 36 |
+
limit: int = Query(default=12, ge=3, le=30),
|
| 37 |
+
db: Session = Depends(get_db),
|
| 38 |
+
current_user: User = Depends(get_current_user),
|
| 39 |
+
):
|
| 40 |
+
"""Return the user's explainable, sensor-only running profile."""
|
| 41 |
+
|
| 42 |
+
ensure_running_coaching_table(db)
|
| 43 |
+
rows = db.execute(
|
| 44 |
+
text(
|
| 45 |
+
"""
|
| 46 |
+
SELECT
|
| 47 |
+
rsr.sensor_summary,
|
| 48 |
+
rs.avg_bpm,
|
| 49 |
+
rs.distance_km,
|
| 50 |
+
rs.duration_sec,
|
| 51 |
+
rs.avg_pace_sec_per_km,
|
| 52 |
+
rs.finished_at
|
| 53 |
+
FROM run_sensor_records rsr
|
| 54 |
+
JOIN run_sessions rs ON rs.id = rsr.run_session_id
|
| 55 |
+
WHERE rsr.user_id = :user_id
|
| 56 |
+
ORDER BY COALESCE(rs.finished_at, rs.started_at) DESC, rsr.updated_at DESC
|
| 57 |
+
LIMIT :limit
|
| 58 |
+
"""
|
| 59 |
+
),
|
| 60 |
+
{"user_id": current_user.id, "limit": limit},
|
| 61 |
+
).mappings().all()
|
| 62 |
+
|
| 63 |
+
records = []
|
| 64 |
+
for row in rows:
|
| 65 |
+
records.append(
|
| 66 |
+
{
|
| 67 |
+
"sensor_summary": _json_object(row.get("sensor_summary")),
|
| 68 |
+
"avg_bpm": row.get("avg_bpm"),
|
| 69 |
+
"distance_km": row.get("distance_km"),
|
| 70 |
+
"duration_sec": row.get("duration_sec"),
|
| 71 |
+
"avg_pace_sec_per_km": row.get("avg_pace_sec_per_km"),
|
| 72 |
+
"finished_at": row.get("finished_at").isoformat()
|
| 73 |
+
if row.get("finished_at")
|
| 74 |
+
else None,
|
| 75 |
+
}
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
return {"profile": build_running_profile(records), "records_considered": len(records)}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@router.get("/runs/{run_session_id}")
|
| 82 |
+
def get_run_coaching(
|
| 83 |
+
run_session_id: int,
|
| 84 |
+
db: Session = Depends(get_db),
|
| 85 |
+
current_user: User = Depends(get_current_user),
|
| 86 |
+
):
|
| 87 |
+
ensure_running_coaching_table(db)
|
| 88 |
+
row = db.execute(
|
| 89 |
+
text(
|
| 90 |
+
"""
|
| 91 |
+
SELECT
|
| 92 |
+
rs.id,
|
| 93 |
+
rs.distance_km,
|
| 94 |
+
rs.duration_sec,
|
| 95 |
+
rs.avg_pace_sec_per_km,
|
| 96 |
+
rs.avg_bpm,
|
| 97 |
+
rsr.sensor_summary,
|
| 98 |
+
rsr.analysis
|
| 99 |
+
FROM run_sessions rs
|
| 100 |
+
LEFT JOIN run_sensor_records rsr ON rsr.run_session_id = rs.id
|
| 101 |
+
WHERE rs.id = :run_session_id
|
| 102 |
+
AND rs.user_id = :user_id
|
| 103 |
+
LIMIT 1
|
| 104 |
+
"""
|
| 105 |
+
),
|
| 106 |
+
{"run_session_id": run_session_id, "user_id": current_user.id},
|
| 107 |
+
).mappings().first()
|
| 108 |
+
if not row:
|
| 109 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Run session not found.")
|
| 110 |
+
|
| 111 |
+
stored_analysis = _json_object(row.get("analysis"))
|
| 112 |
+
summary = _json_object(row.get("sensor_summary"))
|
| 113 |
+
analysis = stored_analysis or build_run_analysis(
|
| 114 |
+
summary,
|
| 115 |
+
distance_km=float(row.get("distance_km") or 0),
|
| 116 |
+
duration_sec=int(row.get("duration_sec") or 0),
|
| 117 |
+
avg_pace_sec_per_km=row.get("avg_pace_sec_per_km"),
|
| 118 |
+
)
|
| 119 |
+
return {
|
| 120 |
+
"run_session_id": int(row["id"]),
|
| 121 |
+
"sensor_summary": summary,
|
| 122 |
+
"analysis": analysis,
|
| 123 |
+
}
|
api/auth-service/app/api/v1/endpoints/runs.py
CHANGED
|
@@ -19,6 +19,11 @@ from app.schemas.run import (
|
|
| 19 |
RunSessionCreateResponse,
|
| 20 |
RunSessionRatingUpdate,
|
| 21 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
router = APIRouter(prefix="/auth", tags=["runs"])
|
| 24 |
|
|
@@ -107,6 +112,68 @@ def _parse_polyline(value: object) -> list[list[float]]:
|
|
| 107 |
return points
|
| 108 |
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
@router.post("/runs", response_model=RunSessionCreateResponse, status_code=status.HTTP_201_CREATED)
|
| 111 |
def create_run_session(
|
| 112 |
payload: RunSessionCreate,
|
|
@@ -129,8 +196,32 @@ def create_run_session(
|
|
| 129 |
route_id: int | None = None
|
| 130 |
normalized_polyline = _normalize_polyline(payload.polyline or [])
|
| 131 |
has_map_image_column = _routes_has_map_image_column(db)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
if len(normalized_polyline) >= 2:
|
| 135 |
center_lat = payload.center_lat if payload.center_lat is not None else normalized_polyline[0][0]
|
| 136 |
center_lng = payload.center_lng if payload.center_lng is not None else normalized_polyline[0][1]
|
|
@@ -287,6 +378,42 @@ def create_run_session(
|
|
| 287 |
},
|
| 288 |
).first()
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
db.commit()
|
| 291 |
except SQLAlchemyError as exc:
|
| 292 |
db.rollback()
|
|
@@ -301,7 +428,11 @@ def create_run_session(
|
|
| 301 |
detail="Failed to save run session.",
|
| 302 |
)
|
| 303 |
|
| 304 |
-
return RunSessionCreateResponse(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
|
| 306 |
|
| 307 |
@router.patch("/runs/{run_session_id}/rating", status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
| 19 |
RunSessionCreateResponse,
|
| 20 |
RunSessionRatingUpdate,
|
| 21 |
)
|
| 22 |
+
from app.services.running_coaching import (
|
| 23 |
+
build_run_analysis,
|
| 24 |
+
build_sensor_summary,
|
| 25 |
+
ensure_running_coaching_table,
|
| 26 |
+
)
|
| 27 |
|
| 28 |
router = APIRouter(prefix="/auth", tags=["runs"])
|
| 29 |
|
|
|
|
| 112 |
return points
|
| 113 |
|
| 114 |
|
| 115 |
+
def _load_recent_watch_sensor_summary(
|
| 116 |
+
db: Session,
|
| 117 |
+
*,
|
| 118 |
+
user_id: int,
|
| 119 |
+
run_id: str | None,
|
| 120 |
+
session_id: str | None,
|
| 121 |
+
) -> dict[str, object]:
|
| 122 |
+
"""Use the companion's last persisted snapshot when finish URL has no metrics.
|
| 123 |
+
|
| 124 |
+
The snapshot must be fresh and, when supplied, match the native run/session id.
|
| 125 |
+
That prevents an earlier run's state from being attached to a new record.
|
| 126 |
+
"""
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
table_name = db.execute(text("SELECT to_regclass('watch_run_live_states')")).scalar()
|
| 130 |
+
if not table_name:
|
| 131 |
+
return {}
|
| 132 |
+
row = db.execute(
|
| 133 |
+
text(
|
| 134 |
+
"""
|
| 135 |
+
SELECT payload
|
| 136 |
+
FROM watch_run_live_states
|
| 137 |
+
WHERE user_id = :user_id
|
| 138 |
+
AND updated_at >= NOW() - INTERVAL '20 minutes'
|
| 139 |
+
LIMIT 1
|
| 140 |
+
"""
|
| 141 |
+
),
|
| 142 |
+
{"user_id": user_id},
|
| 143 |
+
).mappings().first()
|
| 144 |
+
except SQLAlchemyError:
|
| 145 |
+
return {}
|
| 146 |
+
|
| 147 |
+
if not row:
|
| 148 |
+
return {}
|
| 149 |
+
raw_payload = row.get("payload")
|
| 150 |
+
if isinstance(raw_payload, str):
|
| 151 |
+
try:
|
| 152 |
+
raw_payload = json.loads(raw_payload)
|
| 153 |
+
except json.JSONDecodeError:
|
| 154 |
+
return {}
|
| 155 |
+
if not isinstance(raw_payload, dict):
|
| 156 |
+
return {}
|
| 157 |
+
state = raw_payload.get("run_state")
|
| 158 |
+
if not isinstance(state, dict):
|
| 159 |
+
return {}
|
| 160 |
+
|
| 161 |
+
requested_ids = {value for value in (run_id, session_id) if value}
|
| 162 |
+
state_ids = {
|
| 163 |
+
str(value)
|
| 164 |
+
for value in (state.get("run_id"), state.get("session_id"))
|
| 165 |
+
if value is not None and str(value)
|
| 166 |
+
}
|
| 167 |
+
if requested_ids and state_ids and requested_ids.isdisjoint(state_ids):
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
form_metrics = state.get("form_metrics")
|
| 171 |
+
if not isinstance(form_metrics, dict):
|
| 172 |
+
form_metrics = {}
|
| 173 |
+
heart_rate = state.get("average_heart_rate") or state.get("current_heart_rate")
|
| 174 |
+
return build_sensor_summary(form_metrics, [], heart_rate)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
@router.post("/runs", response_model=RunSessionCreateResponse, status_code=status.HTTP_201_CREATED)
|
| 178 |
def create_run_session(
|
| 179 |
payload: RunSessionCreate,
|
|
|
|
| 196 |
route_id: int | None = None
|
| 197 |
normalized_polyline = _normalize_polyline(payload.polyline or [])
|
| 198 |
has_map_image_column = _routes_has_map_image_column(db)
|
| 199 |
+
sensor_samples = [sample.model_dump(exclude_none=True) for sample in payload.sensor_samples]
|
| 200 |
+
sensor_summary = build_sensor_summary(
|
| 201 |
+
payload.form_metrics.model_dump(exclude_none=True) if payload.form_metrics else None,
|
| 202 |
+
sensor_samples,
|
| 203 |
+
payload.avg_bpm,
|
| 204 |
+
)
|
| 205 |
+
if not sensor_summary:
|
| 206 |
+
sensor_summary = _load_recent_watch_sensor_summary(
|
| 207 |
+
db,
|
| 208 |
+
user_id=current_user.id,
|
| 209 |
+
run_id=payload.run_id,
|
| 210 |
+
session_id=payload.session_id,
|
| 211 |
+
)
|
| 212 |
+
run_analysis = build_run_analysis(
|
| 213 |
+
sensor_summary,
|
| 214 |
+
distance_km=payload.distance_km,
|
| 215 |
+
duration_sec=payload.duration_sec,
|
| 216 |
+
avg_pace_sec_per_km=payload.avg_pace_sec_per_km,
|
| 217 |
+
)
|
| 218 |
|
| 219 |
try:
|
| 220 |
+
# The migration formally owns this table. The idempotent check also lets
|
| 221 |
+
# the deployed service accept the first companion-app record immediately.
|
| 222 |
+
if sensor_summary:
|
| 223 |
+
ensure_running_coaching_table(db)
|
| 224 |
+
|
| 225 |
if len(normalized_polyline) >= 2:
|
| 226 |
center_lat = payload.center_lat if payload.center_lat is not None else normalized_polyline[0][0]
|
| 227 |
center_lng = payload.center_lng if payload.center_lng is not None else normalized_polyline[0][1]
|
|
|
|
| 378 |
},
|
| 379 |
).first()
|
| 380 |
|
| 381 |
+
if run_row and sensor_summary:
|
| 382 |
+
db.execute(
|
| 383 |
+
text(
|
| 384 |
+
"""
|
| 385 |
+
INSERT INTO run_sensor_records (
|
| 386 |
+
run_session_id,
|
| 387 |
+
user_id,
|
| 388 |
+
sensor_summary,
|
| 389 |
+
sensor_samples,
|
| 390 |
+
analysis,
|
| 391 |
+
updated_at
|
| 392 |
+
)
|
| 393 |
+
VALUES (
|
| 394 |
+
:run_session_id,
|
| 395 |
+
:user_id,
|
| 396 |
+
CAST(:sensor_summary AS jsonb),
|
| 397 |
+
CAST(:sensor_samples AS jsonb),
|
| 398 |
+
CAST(:analysis AS jsonb),
|
| 399 |
+
NOW()
|
| 400 |
+
)
|
| 401 |
+
ON CONFLICT (run_session_id) DO UPDATE SET
|
| 402 |
+
sensor_summary = EXCLUDED.sensor_summary,
|
| 403 |
+
sensor_samples = EXCLUDED.sensor_samples,
|
| 404 |
+
analysis = EXCLUDED.analysis,
|
| 405 |
+
updated_at = NOW()
|
| 406 |
+
"""
|
| 407 |
+
),
|
| 408 |
+
{
|
| 409 |
+
"run_session_id": int(run_row[0]),
|
| 410 |
+
"user_id": current_user.id,
|
| 411 |
+
"sensor_summary": json.dumps(sensor_summary, ensure_ascii=False),
|
| 412 |
+
"sensor_samples": json.dumps(sensor_samples, ensure_ascii=False),
|
| 413 |
+
"analysis": json.dumps(run_analysis, ensure_ascii=False),
|
| 414 |
+
},
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
db.commit()
|
| 418 |
except SQLAlchemyError as exc:
|
| 419 |
db.rollback()
|
|
|
|
| 428 |
detail="Failed to save run session.",
|
| 429 |
)
|
| 430 |
|
| 431 |
+
return RunSessionCreateResponse(
|
| 432 |
+
run_session_id=int(run_row[0]),
|
| 433 |
+
route_id=route_id,
|
| 434 |
+
run_analysis=run_analysis,
|
| 435 |
+
)
|
| 436 |
|
| 437 |
|
| 438 |
@router.patch("/runs/{run_session_id}/rating", status_code=status.HTTP_204_NO_CONTENT)
|
api/auth-service/app/api/v1/endpoints/watch_run.py
CHANGED
|
@@ -5,7 +5,7 @@ import threading
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
| 8 |
-
from pydantic import BaseModel, Field, field_validator
|
| 9 |
from sqlalchemy import text
|
| 10 |
from sqlalchemy.exc import SQLAlchemyError
|
| 11 |
from sqlalchemy.orm import Session
|
|
@@ -47,6 +47,8 @@ def _normalize_point(value: object) -> list[float] | None:
|
|
| 47 |
class WatchRunLiveState(BaseModel):
|
| 48 |
is_running: bool
|
| 49 |
is_paused: bool = False
|
|
|
|
|
|
|
| 50 |
elapsed_sec: int = Field(default=0, ge=0)
|
| 51 |
distance_meters: float = Field(default=0, ge=0)
|
| 52 |
distance_km: float | None = Field(default=None, ge=0)
|
|
@@ -62,9 +64,28 @@ class WatchRunLiveState(BaseModel):
|
|
| 62 |
current_position: LatLng | None = None
|
| 63 |
target_route: list[list[float]] = Field(default_factory=list)
|
| 64 |
actual_route: list[list[float]] = Field(default_factory=list)
|
|
|
|
| 65 |
sync_status: str = Field(default="sync_ok", max_length=32)
|
| 66 |
updated_at_epoch_ms: int | None = Field(default=None, ge=0)
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
@field_validator("current_position", mode="before")
|
| 69 |
@classmethod
|
| 70 |
def normalize_current_position(cls, value: object) -> object:
|
|
@@ -89,6 +110,49 @@ class WatchRunLiveState(BaseModel):
|
|
| 89 |
normalized.append(point)
|
| 90 |
return normalized
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
class WatchRunLiveStateEnvelope(BaseModel):
|
| 94 |
run_state: WatchRunLiveState
|
|
@@ -138,9 +202,8 @@ def _to_envelope_payload(payload: WatchRunLiveStateEnvelope) -> dict:
|
|
| 138 |
|
| 139 |
|
| 140 |
def _normalize_run_state_fields(state: WatchRunLiveState) -> None:
|
| 141 |
-
#
|
| 142 |
-
|
| 143 |
-
state.average_heart_rate = None
|
| 144 |
|
| 145 |
if state.distance_km is None:
|
| 146 |
state.distance_km = round(max(0.0, float(state.distance_meters)) / 1000.0, 3)
|
|
|
|
| 5 |
from datetime import datetime, timezone
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
| 8 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 9 |
from sqlalchemy import text
|
| 10 |
from sqlalchemy.exc import SQLAlchemyError
|
| 11 |
from sqlalchemy.orm import Session
|
|
|
|
| 47 |
class WatchRunLiveState(BaseModel):
|
| 48 |
is_running: bool
|
| 49 |
is_paused: bool = False
|
| 50 |
+
run_id: str | None = Field(default=None, max_length=160)
|
| 51 |
+
session_id: str | None = Field(default=None, max_length=160)
|
| 52 |
elapsed_sec: int = Field(default=0, ge=0)
|
| 53 |
distance_meters: float = Field(default=0, ge=0)
|
| 54 |
distance_km: float | None = Field(default=None, ge=0)
|
|
|
|
| 64 |
current_position: LatLng | None = None
|
| 65 |
target_route: list[list[float]] = Field(default_factory=list)
|
| 66 |
actual_route: list[list[float]] = Field(default_factory=list)
|
| 67 |
+
form_metrics: dict[str, float | int | bool] = Field(default_factory=dict)
|
| 68 |
sync_status: str = Field(default="sync_ok", max_length=32)
|
| 69 |
updated_at_epoch_ms: int | None = Field(default=None, ge=0)
|
| 70 |
|
| 71 |
+
@model_validator(mode="before")
|
| 72 |
+
@classmethod
|
| 73 |
+
def normalize_companion_field_names(cls, value: object) -> object:
|
| 74 |
+
if not isinstance(value, dict):
|
| 75 |
+
return value
|
| 76 |
+
normalized = dict(value)
|
| 77 |
+
aliases = {
|
| 78 |
+
"runId": "run_id",
|
| 79 |
+
"sessionId": "session_id",
|
| 80 |
+
"formMetrics": "form_metrics",
|
| 81 |
+
"currentHeartRate": "current_heart_rate",
|
| 82 |
+
"averageHeartRate": "average_heart_rate",
|
| 83 |
+
}
|
| 84 |
+
for source, destination in aliases.items():
|
| 85 |
+
if destination not in normalized and source in normalized:
|
| 86 |
+
normalized[destination] = normalized[source]
|
| 87 |
+
return normalized
|
| 88 |
+
|
| 89 |
@field_validator("current_position", mode="before")
|
| 90 |
@classmethod
|
| 91 |
def normalize_current_position(cls, value: object) -> object:
|
|
|
|
| 110 |
normalized.append(point)
|
| 111 |
return normalized
|
| 112 |
|
| 113 |
+
@field_validator("form_metrics", mode="before")
|
| 114 |
+
@classmethod
|
| 115 |
+
def normalize_form_metrics(cls, value: object) -> dict[str, float | int | bool]:
|
| 116 |
+
if not isinstance(value, dict):
|
| 117 |
+
return {}
|
| 118 |
+
aliases = {
|
| 119 |
+
"accelerometerAvailable": "accelerometer_available",
|
| 120 |
+
"totalMotionSamples": "sample_count",
|
| 121 |
+
"estimatedCadenceSpm": "avg_cadence_spm",
|
| 122 |
+
"cadenceSpm": "avg_cadence_spm",
|
| 123 |
+
"groundContactTimeMs": "avg_ground_contact_time_ms",
|
| 124 |
+
"flightTimeMs": "avg_flight_time_ms",
|
| 125 |
+
"verticalOscillationCm": "avg_vertical_oscillation_cm",
|
| 126 |
+
"leftRightAsymmetryPct": "avg_left_right_asymmetry_pct",
|
| 127 |
+
}
|
| 128 |
+
normalized: dict[str, float | int | bool] = {}
|
| 129 |
+
allowed = {
|
| 130 |
+
"accelerometer_available",
|
| 131 |
+
"sample_count",
|
| 132 |
+
"avg_cadence_spm",
|
| 133 |
+
"avg_ground_contact_time_ms",
|
| 134 |
+
"avg_flight_time_ms",
|
| 135 |
+
"avg_vertical_oscillation_cm",
|
| 136 |
+
"avg_left_right_asymmetry_pct",
|
| 137 |
+
"heart_rate_rise_bpm",
|
| 138 |
+
"pace_variability_pct",
|
| 139 |
+
}
|
| 140 |
+
for raw_key, raw_value in value.items():
|
| 141 |
+
key = aliases.get(str(raw_key), str(raw_key))
|
| 142 |
+
if key not in allowed:
|
| 143 |
+
continue
|
| 144 |
+
if key == "accelerometer_available":
|
| 145 |
+
normalized[key] = bool(raw_value)
|
| 146 |
+
continue
|
| 147 |
+
try:
|
| 148 |
+
numeric = float(raw_value)
|
| 149 |
+
except (TypeError, ValueError):
|
| 150 |
+
continue
|
| 151 |
+
if numeric < 0 or numeric > 200_000:
|
| 152 |
+
continue
|
| 153 |
+
normalized[key] = int(round(numeric)) if key == "sample_count" else round(numeric, 2)
|
| 154 |
+
return normalized
|
| 155 |
+
|
| 156 |
|
| 157 |
class WatchRunLiveStateEnvelope(BaseModel):
|
| 158 |
run_state: WatchRunLiveState
|
|
|
|
| 202 |
|
| 203 |
|
| 204 |
def _normalize_run_state_fields(state: WatchRunLiveState) -> None:
|
| 205 |
+
# This is an authenticated, user-authorized snapshot. Keep heart-rate and
|
| 206 |
+
# form values so the browser can coach from server-persisted wearable data.
|
|
|
|
| 207 |
|
| 208 |
if state.distance_km is None:
|
| 209 |
state.distance_km = round(max(0.0, float(state.distance_meters)) / 1000.0, 3)
|
api/auth-service/app/api/v1/router.py
CHANGED
|
@@ -1,10 +1,20 @@
|
|
| 1 |
๏ปฟfrom fastapi import APIRouter
|
| 2 |
|
| 3 |
-
from app.api.v1.endpoints import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
api_router = APIRouter()
|
| 6 |
api_router.include_router(auth.router)
|
| 7 |
api_router.include_router(runs.router)
|
|
|
|
| 8 |
api_router.include_router(stats.router)
|
| 9 |
api_router.include_router(community.router)
|
| 10 |
api_router.include_router(watch_routes.router)
|
|
|
|
| 1 |
๏ปฟfrom fastapi import APIRouter
|
| 2 |
|
| 3 |
+
from app.api.v1.endpoints import (
|
| 4 |
+
auth,
|
| 5 |
+
community,
|
| 6 |
+
running_coaching,
|
| 7 |
+
runs,
|
| 8 |
+
stats,
|
| 9 |
+
watch_auth,
|
| 10 |
+
watch_routes,
|
| 11 |
+
watch_run,
|
| 12 |
+
)
|
| 13 |
|
| 14 |
api_router = APIRouter()
|
| 15 |
api_router.include_router(auth.router)
|
| 16 |
api_router.include_router(runs.router)
|
| 17 |
+
api_router.include_router(running_coaching.router)
|
| 18 |
api_router.include_router(stats.router)
|
| 19 |
api_router.include_router(community.router)
|
| 20 |
api_router.include_router(watch_routes.router)
|
api/auth-service/app/schemas/run.py
CHANGED
|
@@ -1,6 +1,90 @@
|
|
| 1 |
from datetime import datetime
|
|
|
|
| 2 |
|
| 3 |
-
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
|
| 6 |
class RunSessionCreate(BaseModel):
|
|
@@ -8,6 +92,8 @@ class RunSessionCreate(BaseModel):
|
|
| 8 |
animal_label: str | None = Field(default=None, max_length=40)
|
| 9 |
source: str = Field(default="nearby")
|
| 10 |
place_text: str | None = None
|
|
|
|
|
|
|
| 11 |
started_at: datetime | None = None
|
| 12 |
finished_at: datetime | None = None
|
| 13 |
duration_sec: int = Field(..., ge=0)
|
|
@@ -22,6 +108,30 @@ class RunSessionCreate(BaseModel):
|
|
| 22 |
center_lng: float | None = Field(default=None, ge=-180, le=180)
|
| 23 |
polyline: list[list[float]] | None = None
|
| 24 |
map_image_url: str | None = Field(default=None, max_length=500)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
@field_validator("source")
|
| 27 |
@classmethod
|
|
@@ -54,6 +164,7 @@ class RunSessionCreate(BaseModel):
|
|
| 54 |
class RunSessionCreateResponse(BaseModel):
|
| 55 |
run_session_id: int
|
| 56 |
route_id: int | None = None
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
class RunSessionRatingUpdate(BaseModel):
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
+
from typing import Any
|
| 3 |
|
| 4 |
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class RunningFormMetrics(BaseModel):
|
| 8 |
+
"""Sensor-derived running form summary supplied by the companion app.
|
| 9 |
+
|
| 10 |
+
The watch calculates these values from accelerometer/heart-rate samples. They
|
| 11 |
+
are deliberately optional because every Wear OS device and permission setup
|
| 12 |
+
does not expose every signal.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
accelerometer_available: bool | None = None
|
| 16 |
+
sample_count: int | None = Field(default=None, ge=0, le=200_000)
|
| 17 |
+
avg_heart_rate_bpm: int | None = Field(default=None, ge=0, le=260)
|
| 18 |
+
avg_cadence_spm: float | None = Field(default=None, ge=40, le=300)
|
| 19 |
+
avg_ground_contact_time_ms: float | None = Field(default=None, ge=20, le=1_500)
|
| 20 |
+
avg_flight_time_ms: float | None = Field(default=None, ge=0, le=1_000)
|
| 21 |
+
avg_vertical_oscillation_cm: float | None = Field(default=None, ge=0, le=100)
|
| 22 |
+
avg_left_right_asymmetry_pct: float | None = Field(default=None, ge=0, le=100)
|
| 23 |
+
heart_rate_rise_bpm: float | None = Field(default=None, ge=-100, le=150)
|
| 24 |
+
pace_variability_pct: float | None = Field(default=None, ge=0, le=200)
|
| 25 |
+
|
| 26 |
+
@model_validator(mode="before")
|
| 27 |
+
@classmethod
|
| 28 |
+
def normalize_companion_field_names(cls, value: Any) -> Any:
|
| 29 |
+
if not isinstance(value, dict):
|
| 30 |
+
return value
|
| 31 |
+
normalized = dict(value)
|
| 32 |
+
aliases = {
|
| 33 |
+
"accelerometerAvailable": "accelerometer_available",
|
| 34 |
+
"totalMotionSamples": "sample_count",
|
| 35 |
+
"estimatedSampleCount": "sample_count",
|
| 36 |
+
"averageHeartRateBpm": "avg_heart_rate_bpm",
|
| 37 |
+
"avgHeartRateBpm": "avg_heart_rate_bpm",
|
| 38 |
+
"estimatedCadenceSpm": "avg_cadence_spm",
|
| 39 |
+
"cadenceSpm": "avg_cadence_spm",
|
| 40 |
+
"groundContactTimeMs": "avg_ground_contact_time_ms",
|
| 41 |
+
"estimatedGroundContactTimeMs": "avg_ground_contact_time_ms",
|
| 42 |
+
"flightTimeMs": "avg_flight_time_ms",
|
| 43 |
+
"estimatedFlightTimeMs": "avg_flight_time_ms",
|
| 44 |
+
"verticalOscillationCm": "avg_vertical_oscillation_cm",
|
| 45 |
+
"estimatedVerticalOscillationCm": "avg_vertical_oscillation_cm",
|
| 46 |
+
"leftRightAsymmetryPct": "avg_left_right_asymmetry_pct",
|
| 47 |
+
"estimatedLeftRightAsymmetryPct": "avg_left_right_asymmetry_pct",
|
| 48 |
+
"heartRateRiseBpm": "heart_rate_rise_bpm",
|
| 49 |
+
"paceVariabilityPct": "pace_variability_pct",
|
| 50 |
+
}
|
| 51 |
+
for source, destination in aliases.items():
|
| 52 |
+
if destination not in normalized and source in normalized:
|
| 53 |
+
normalized[destination] = normalized[source]
|
| 54 |
+
return normalized
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class RunningSensorSample(BaseModel):
|
| 58 |
+
elapsed_sec: int | None = Field(default=None, ge=0, le=172_800)
|
| 59 |
+
heart_rate_bpm: int | None = Field(default=None, ge=0, le=260)
|
| 60 |
+
pace_sec_per_km: int | None = Field(default=None, ge=0, le=7_200)
|
| 61 |
+
cadence_spm: float | None = Field(default=None, ge=40, le=300)
|
| 62 |
+
ground_contact_time_ms: float | None = Field(default=None, ge=20, le=1_500)
|
| 63 |
+
flight_time_ms: float | None = Field(default=None, ge=0, le=1_000)
|
| 64 |
+
vertical_oscillation_cm: float | None = Field(default=None, ge=0, le=100)
|
| 65 |
+
left_right_asymmetry_pct: float | None = Field(default=None, ge=0, le=100)
|
| 66 |
+
|
| 67 |
+
@model_validator(mode="before")
|
| 68 |
+
@classmethod
|
| 69 |
+
def normalize_companion_field_names(cls, value: Any) -> Any:
|
| 70 |
+
if not isinstance(value, dict):
|
| 71 |
+
return value
|
| 72 |
+
normalized = dict(value)
|
| 73 |
+
aliases = {
|
| 74 |
+
"elapsedSec": "elapsed_sec",
|
| 75 |
+
"heartRateBpm": "heart_rate_bpm",
|
| 76 |
+
"currentHeartRate": "heart_rate_bpm",
|
| 77 |
+
"paceSecPerKm": "pace_sec_per_km",
|
| 78 |
+
"cadenceSpm": "cadence_spm",
|
| 79 |
+
"groundContactTimeMs": "ground_contact_time_ms",
|
| 80 |
+
"flightTimeMs": "flight_time_ms",
|
| 81 |
+
"verticalOscillationCm": "vertical_oscillation_cm",
|
| 82 |
+
"leftRightAsymmetryPct": "left_right_asymmetry_pct",
|
| 83 |
+
}
|
| 84 |
+
for source, destination in aliases.items():
|
| 85 |
+
if destination not in normalized and source in normalized:
|
| 86 |
+
normalized[destination] = normalized[source]
|
| 87 |
+
return normalized
|
| 88 |
|
| 89 |
|
| 90 |
class RunSessionCreate(BaseModel):
|
|
|
|
| 92 |
animal_label: str | None = Field(default=None, max_length=40)
|
| 93 |
source: str = Field(default="nearby")
|
| 94 |
place_text: str | None = None
|
| 95 |
+
run_id: str | None = Field(default=None, max_length=160)
|
| 96 |
+
session_id: str | None = Field(default=None, max_length=160)
|
| 97 |
started_at: datetime | None = None
|
| 98 |
finished_at: datetime | None = None
|
| 99 |
duration_sec: int = Field(..., ge=0)
|
|
|
|
| 108 |
center_lng: float | None = Field(default=None, ge=-180, le=180)
|
| 109 |
polyline: list[list[float]] | None = None
|
| 110 |
map_image_url: str | None = Field(default=None, max_length=500)
|
| 111 |
+
form_metrics: RunningFormMetrics | None = None
|
| 112 |
+
sensor_samples: list[RunningSensorSample] = Field(default_factory=list, max_length=3_600)
|
| 113 |
+
|
| 114 |
+
@model_validator(mode="before")
|
| 115 |
+
@classmethod
|
| 116 |
+
def normalize_sensor_payload_names(cls, value: Any) -> Any:
|
| 117 |
+
if not isinstance(value, dict):
|
| 118 |
+
return value
|
| 119 |
+
normalized = dict(value)
|
| 120 |
+
if "form_metrics" not in normalized:
|
| 121 |
+
for key in ("formMetrics", "sensor_summary", "sensorSummary"):
|
| 122 |
+
if key in normalized:
|
| 123 |
+
normalized["form_metrics"] = normalized[key]
|
| 124 |
+
break
|
| 125 |
+
if "sensor_samples" not in normalized:
|
| 126 |
+
for key in ("sensorSamples", "samples"):
|
| 127 |
+
if key in normalized:
|
| 128 |
+
normalized["sensor_samples"] = normalized[key]
|
| 129 |
+
break
|
| 130 |
+
aliases = {"runId": "run_id", "sessionId": "session_id"}
|
| 131 |
+
for source, destination in aliases.items():
|
| 132 |
+
if destination not in normalized and source in normalized:
|
| 133 |
+
normalized[destination] = normalized[source]
|
| 134 |
+
return normalized
|
| 135 |
|
| 136 |
@field_validator("source")
|
| 137 |
@classmethod
|
|
|
|
| 164 |
class RunSessionCreateResponse(BaseModel):
|
| 165 |
run_session_id: int
|
| 166 |
route_id: int | None = None
|
| 167 |
+
run_analysis: dict[str, Any] | None = None
|
| 168 |
|
| 169 |
|
| 170 |
class RunSessionRatingUpdate(BaseModel):
|
api/auth-service/app/services/running_coaching.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic running-form coaching based on persisted wearable summaries.
|
| 2 |
+
|
| 3 |
+
The service intentionally uses conservative wording. Wearable values are estimates,
|
| 4 |
+
not a diagnosis or a substitute for professional medical advice.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import math
|
| 11 |
+
import threading
|
| 12 |
+
from statistics import mean, pstdev
|
| 13 |
+
from typing import TYPE_CHECKING, Any, Iterable
|
| 14 |
+
|
| 15 |
+
if TYPE_CHECKING:
|
| 16 |
+
from sqlalchemy.orm import Session
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
_schema_ready = False
|
| 20 |
+
_schema_lock = threading.Lock()
|
| 21 |
+
|
| 22 |
+
_SAMPLE_TO_SUMMARY = {
|
| 23 |
+
"heart_rate_bpm": "avg_heart_rate_bpm",
|
| 24 |
+
"cadence_spm": "avg_cadence_spm",
|
| 25 |
+
"ground_contact_time_ms": "avg_ground_contact_time_ms",
|
| 26 |
+
"flight_time_ms": "avg_flight_time_ms",
|
| 27 |
+
"vertical_oscillation_cm": "avg_vertical_oscillation_cm",
|
| 28 |
+
"left_right_asymmetry_pct": "avg_left_right_asymmetry_pct",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def ensure_running_coaching_table(db: Session) -> None:
|
| 33 |
+
"""Make the feature deploy-safe while Alembic is applied on the next deploy."""
|
| 34 |
+
|
| 35 |
+
from sqlalchemy import text
|
| 36 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 37 |
+
|
| 38 |
+
global _schema_ready
|
| 39 |
+
if _schema_ready:
|
| 40 |
+
return
|
| 41 |
+
|
| 42 |
+
with _schema_lock:
|
| 43 |
+
if _schema_ready:
|
| 44 |
+
return
|
| 45 |
+
try:
|
| 46 |
+
db.execute(
|
| 47 |
+
text(
|
| 48 |
+
"""
|
| 49 |
+
CREATE TABLE IF NOT EXISTS run_sensor_records (
|
| 50 |
+
run_session_id BIGINT PRIMARY KEY
|
| 51 |
+
REFERENCES run_sessions(id) ON DELETE CASCADE,
|
| 52 |
+
user_id INTEGER NOT NULL
|
| 53 |
+
REFERENCES users(id) ON DELETE CASCADE,
|
| 54 |
+
sensor_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 55 |
+
sensor_samples JSONB NOT NULL DEFAULT '[]'::jsonb,
|
| 56 |
+
analysis JSONB NOT NULL DEFAULT '{}'::jsonb,
|
| 57 |
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
| 58 |
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
| 59 |
+
)
|
| 60 |
+
"""
|
| 61 |
+
)
|
| 62 |
+
)
|
| 63 |
+
db.execute(
|
| 64 |
+
text(
|
| 65 |
+
"""
|
| 66 |
+
CREATE INDEX IF NOT EXISTS idx_run_sensor_records_user_updated
|
| 67 |
+
ON run_sensor_records (user_id, updated_at DESC)
|
| 68 |
+
"""
|
| 69 |
+
)
|
| 70 |
+
)
|
| 71 |
+
db.commit()
|
| 72 |
+
_schema_ready = True
|
| 73 |
+
except SQLAlchemyError:
|
| 74 |
+
db.rollback()
|
| 75 |
+
raise
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _number(value: Any) -> float | None:
|
| 79 |
+
try:
|
| 80 |
+
parsed = float(value)
|
| 81 |
+
except (TypeError, ValueError):
|
| 82 |
+
return None
|
| 83 |
+
return parsed if math.isfinite(parsed) else None
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _mean(values: Iterable[Any]) -> float | None:
|
| 87 |
+
numbers = [value for value in (_number(item) for item in values) if value is not None]
|
| 88 |
+
return mean(numbers) if numbers else None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _round(value: float | None, digits: int = 1) -> float | None:
|
| 92 |
+
return round(value, digits) if value is not None else None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _compact(values: dict[str, Any]) -> dict[str, Any]:
|
| 96 |
+
return {key: value for key, value in values.items() if value is not None}
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def build_sensor_summary(
|
| 100 |
+
form_metrics: dict[str, Any] | None,
|
| 101 |
+
sensor_samples: list[dict[str, Any]] | None,
|
| 102 |
+
avg_bpm: int | None,
|
| 103 |
+
) -> dict[str, Any]:
|
| 104 |
+
"""Fill a compact summary from a native summary and optional raw samples."""
|
| 105 |
+
|
| 106 |
+
summary = dict(form_metrics or {})
|
| 107 |
+
samples = [sample for sample in (sensor_samples or []) if isinstance(sample, dict)]
|
| 108 |
+
if summary.get("sample_count") is None and samples:
|
| 109 |
+
summary["sample_count"] = len(samples)
|
| 110 |
+
if avg_bpm and avg_bpm > 0 and summary.get("avg_heart_rate_bpm") is None:
|
| 111 |
+
summary["avg_heart_rate_bpm"] = avg_bpm
|
| 112 |
+
|
| 113 |
+
for sample_field, summary_field in _SAMPLE_TO_SUMMARY.items():
|
| 114 |
+
if summary.get(summary_field) is None:
|
| 115 |
+
value = _mean(sample.get(sample_field) for sample in samples)
|
| 116 |
+
if value is not None:
|
| 117 |
+
summary[summary_field] = _round(value)
|
| 118 |
+
|
| 119 |
+
heart_rates = [
|
| 120 |
+
value
|
| 121 |
+
for value in (_number(sample.get("heart_rate_bpm")) for sample in samples)
|
| 122 |
+
if value is not None
|
| 123 |
+
]
|
| 124 |
+
if summary.get("heart_rate_rise_bpm") is None and len(heart_rates) >= 8:
|
| 125 |
+
window = max(2, len(heart_rates) // 4)
|
| 126 |
+
summary["heart_rate_rise_bpm"] = _round(
|
| 127 |
+
mean(heart_rates[-window:]) - mean(heart_rates[:window])
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
paces = [
|
| 131 |
+
value
|
| 132 |
+
for value in (_number(sample.get("pace_sec_per_km")) for sample in samples)
|
| 133 |
+
if value is not None and value > 0
|
| 134 |
+
]
|
| 135 |
+
if summary.get("pace_variability_pct") is None and len(paces) >= 5:
|
| 136 |
+
average_pace = mean(paces)
|
| 137 |
+
if average_pace > 0:
|
| 138 |
+
summary["pace_variability_pct"] = _round((pstdev(paces) / average_pace) * 100)
|
| 139 |
+
|
| 140 |
+
numeric_fields = {
|
| 141 |
+
"sample_count": 0,
|
| 142 |
+
"avg_heart_rate_bpm": 0,
|
| 143 |
+
"avg_cadence_spm": 1,
|
| 144 |
+
"avg_ground_contact_time_ms": 1,
|
| 145 |
+
"avg_flight_time_ms": 1,
|
| 146 |
+
"avg_vertical_oscillation_cm": 1,
|
| 147 |
+
"avg_left_right_asymmetry_pct": 1,
|
| 148 |
+
"heart_rate_rise_bpm": 1,
|
| 149 |
+
"pace_variability_pct": 1,
|
| 150 |
+
}
|
| 151 |
+
for field, digits in numeric_fields.items():
|
| 152 |
+
value = _number(summary.get(field))
|
| 153 |
+
if value is None:
|
| 154 |
+
summary.pop(field, None)
|
| 155 |
+
elif field == "sample_count":
|
| 156 |
+
summary[field] = max(0, int(round(value)))
|
| 157 |
+
else:
|
| 158 |
+
summary[field] = round(value, digits)
|
| 159 |
+
|
| 160 |
+
if summary.get("accelerometer_available") is not None:
|
| 161 |
+
summary["accelerometer_available"] = bool(summary["accelerometer_available"])
|
| 162 |
+
return _compact(summary)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _coaching_item(
|
| 166 |
+
key: str,
|
| 167 |
+
title: str,
|
| 168 |
+
message: str,
|
| 169 |
+
action: str,
|
| 170 |
+
severity: int,
|
| 171 |
+
) -> dict[str, Any]:
|
| 172 |
+
return {
|
| 173 |
+
"key": key,
|
| 174 |
+
"title": title,
|
| 175 |
+
"message": message,
|
| 176 |
+
"action": action,
|
| 177 |
+
"severity": severity,
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def build_run_analysis(
|
| 182 |
+
summary: dict[str, Any] | None,
|
| 183 |
+
*,
|
| 184 |
+
distance_km: float = 0,
|
| 185 |
+
duration_sec: int = 0,
|
| 186 |
+
avg_pace_sec_per_km: int | None = None,
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
"""Return a small, actionable after-run coaching result."""
|
| 189 |
+
|
| 190 |
+
metrics = build_sensor_summary(summary, [], None)
|
| 191 |
+
sample_count = int(metrics.get("sample_count") or 0)
|
| 192 |
+
has_motion = any(
|
| 193 |
+
key in metrics
|
| 194 |
+
for key in (
|
| 195 |
+
"avg_cadence_spm",
|
| 196 |
+
"avg_ground_contact_time_ms",
|
| 197 |
+
"avg_vertical_oscillation_cm",
|
| 198 |
+
"avg_left_right_asymmetry_pct",
|
| 199 |
+
)
|
| 200 |
+
)
|
| 201 |
+
has_heart = "avg_heart_rate_bpm" in metrics
|
| 202 |
+
|
| 203 |
+
if not has_motion and not has_heart:
|
| 204 |
+
return {
|
| 205 |
+
"status": "waiting_for_sensor_data",
|
| 206 |
+
"headline": "์ผ์ ๊ธฐ๋ฐ ๋ฌ๋ ์ฝ์นญ์ ์ค๋น ์ค์ด์์.",
|
| 207 |
+
"strengths": [],
|
| 208 |
+
"focus": [],
|
| 209 |
+
"next_action": "์์น์ ์ ์ฒด ํ๋ยท์ฌ๋ฐ ๊ถํ์ ํ์ฉํ ๋ค ๋ฌ๋์ ๊ธฐ๋กํด ์ฃผ์ธ์.",
|
| 210 |
+
"sample_count": sample_count,
|
| 211 |
+
"disclaimer": "์์น ์ผ์์ ์ถ์ ๊ฐ์ ๋ฐํ์ผ๋ก ํ ๋ฌ๋ ์๋ด์
๋๋ค.",
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
strengths: list[dict[str, Any]] = []
|
| 215 |
+
focus: list[dict[str, Any]] = []
|
| 216 |
+
|
| 217 |
+
heart_rise = _number(metrics.get("heart_rate_rise_bpm"))
|
| 218 |
+
avg_hr = _number(metrics.get("avg_heart_rate_bpm"))
|
| 219 |
+
if heart_rise is not None and heart_rise >= 12 and (avg_hr or 0) >= 120:
|
| 220 |
+
focus.append(
|
| 221 |
+
_coaching_item(
|
| 222 |
+
"heart_rate",
|
| 223 |
+
"ํ๋ฐ ๊ฐ๋",
|
| 224 |
+
f"ํ๋ฐ ์ฌ๋ฐ์ด ์ฝ {heart_rise:.0f} BPM ์ฌ๋ผ๊ฐ์ด์.",
|
| 225 |
+
"๋ค์ ๊ตฌ๊ฐ์ ๋ณดํญ์ ์กฐ๊ธ ์ค์ด๊ณ , ๋ฌธ์ฅ์ผ๋ก ๋งํ ์ ์๋ ํธํก์ ์ ์งํด ๋ณด์ธ์.",
|
| 226 |
+
4,
|
| 227 |
+
)
|
| 228 |
+
)
|
| 229 |
+
elif avg_hr is not None:
|
| 230 |
+
strengths.append(
|
| 231 |
+
{
|
| 232 |
+
"key": "heart_rate",
|
| 233 |
+
"title": "๋ฌ๋ ๊ฐ๋ ๊ธฐ๋ก",
|
| 234 |
+
"message": f"ํ๊ท ์ฌ๋ฐ {avg_hr:.0f} BPM์ ๋ค์ ๋ฌ๋์ ๋น๊ต ๊ธฐ์ค์ผ๋ก ์ ์ฅํ์ด์.",
|
| 235 |
+
}
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
cadence = _number(metrics.get("avg_cadence_spm"))
|
| 239 |
+
if cadence is not None:
|
| 240 |
+
if cadence < 150:
|
| 241 |
+
focus.append(
|
| 242 |
+
_coaching_item(
|
| 243 |
+
"cadence",
|
| 244 |
+
"๋ฌ๋ ๋ฆฌ๋ฌ",
|
| 245 |
+
f"ํ๊ท ์ผ์ด๋์ค๊ฐ {cadence:.0f} SPM์ผ๋ก ๋ฎ๊ฒ ๊ธฐ๋ก๋์ด์.",
|
| 246 |
+
"๋ณดํญ์ ์ต์ง๋ก ๋๋ฆฌ๊ธฐ๋ณด๋ค ๋ฐ์ ์งง๊ณ ๊ฐ๋ณ๊ฒ ์ฎ๊ฒจ ๋ฆฌ๋ฌ์ ๋์ฌ ๋ณด์ธ์.",
|
| 247 |
+
3,
|
| 248 |
+
)
|
| 249 |
+
)
|
| 250 |
+
elif cadence >= 165:
|
| 251 |
+
strengths.append(
|
| 252 |
+
{
|
| 253 |
+
"key": "cadence",
|
| 254 |
+
"title": "์์ ์ ์ธ ๋ฆฌ๋ฌ",
|
| 255 |
+
"message": f"ํ๊ท ์ผ์ด๋์ค {cadence:.0f} SPM์ผ๋ก ๊ฐ๋ฒผ์ด ๋ฆฌ๋ฌ์ ์ ์งํ์ด์.",
|
| 256 |
+
}
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
contact = _number(metrics.get("avg_ground_contact_time_ms"))
|
| 260 |
+
flight = _number(metrics.get("avg_flight_time_ms"))
|
| 261 |
+
if contact is not None and contact >= 310:
|
| 262 |
+
extra = " ์ฒด๊ณต ์๊ฐ์ด ์งง๊ฒ ํจ๊ป ๊ธฐ๋ก๋์ด์." if flight is not None and flight < 90 else ""
|
| 263 |
+
focus.append(
|
| 264 |
+
_coaching_item(
|
| 265 |
+
"ground_contact",
|
| 266 |
+
"์ง๋ฉด ์ ์ด",
|
| 267 |
+
f"๋ฐ์ด ์ง๋ฉด์ ๋จธ๋ฌธ ์ถ์ ์๊ฐ์ด {contact:.0f}ms๋ก ๊ธธ์ด์ก์ด์.{extra}",
|
| 268 |
+
"๋ฐ๋ฐ์ ์ค๋ ๋๋ฅด๊ธฐ๋ณด๋ค ์ง๋ฉด์ ๊ฐ๋ณ๊ฒ ๋ฐ๊ณ ๋ค์ ๋ฐ๋ก ๋์ด๊ฐ ๋ณด์ธ์.",
|
| 269 |
+
2,
|
| 270 |
+
)
|
| 271 |
+
)
|
| 272 |
+
elif contact is not None and contact < 290:
|
| 273 |
+
strengths.append(
|
| 274 |
+
{
|
| 275 |
+
"key": "ground_contact",
|
| 276 |
+
"title": "๊ฐ๋ฒผ์ด ์ง๋ฉด ์ ์ด",
|
| 277 |
+
"message": f"์ถ์ ์ ์ง ์๊ฐ {contact:.0f}ms๋ก ๋ฐ์ ์ ํ์ด ๊ฐ๋ณ๊ฒ ๊ธฐ๋ก๋์ด์.",
|
| 278 |
+
}
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
vertical = _number(metrics.get("avg_vertical_oscillation_cm"))
|
| 282 |
+
if vertical is not None and vertical >= 10.5:
|
| 283 |
+
focus.append(
|
| 284 |
+
_coaching_item(
|
| 285 |
+
"vertical_oscillation",
|
| 286 |
+
"์ํ ์์ง์",
|
| 287 |
+
f"์ํ ์์ง์์ด ์ฝ {vertical:.1f}cm๋ก ํฌ๊ฒ ๊ธฐ๋ก๋์ด์.",
|
| 288 |
+
"์์ ์ ์ ๋ฉด์ ๋๊ณ , ์๋ก ํ๊ธฐ๋ณด๋ค ๋ชธ์ ์์ผ๋ก ๋ถ๋๋ฝ๊ฒ ๋ณด๋ด๋ ๋ฐ ์ง์คํด ๋ณด์ธ์.",
|
| 289 |
+
2,
|
| 290 |
+
)
|
| 291 |
+
)
|
| 292 |
+
elif vertical is not None and vertical <= 8.5:
|
| 293 |
+
strengths.append(
|
| 294 |
+
{
|
| 295 |
+
"key": "vertical_oscillation",
|
| 296 |
+
"title": "์ ์ง ์ค์ฌ ์์ง์",
|
| 297 |
+
"message": f"์ํ ์์ง์์ด ์ฝ {vertical:.1f}cm๋ก ์์ ์ ์ผ๋ก ๊ธฐ๋ก๋์ด์.",
|
| 298 |
+
}
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
asymmetry = _number(metrics.get("avg_left_right_asymmetry_pct"))
|
| 302 |
+
if asymmetry is not None and asymmetry >= 8:
|
| 303 |
+
focus.append(
|
| 304 |
+
_coaching_item(
|
| 305 |
+
"asymmetry",
|
| 306 |
+
"์ข์ฐ ๊ท ํ",
|
| 307 |
+
f"์ข์ฐ ๋น๋์นญ ์ถ์ ์น๊ฐ {asymmetry:.1f}%๋ก ๋ฐ๋ณต๋์ด์.",
|
| 308 |
+
"ํผ๋ก๊ฐ ์์ธ ๊ตฌ๊ฐ์๋ ์๋๋ฅผ ๋ฎ์ถ๊ณ , ์์น ์ฐฉ์ฉ ์์น๋ ํจ๊ป ํ์ธํด ๋ณด์ธ์.",
|
| 309 |
+
3,
|
| 310 |
+
)
|
| 311 |
+
)
|
| 312 |
+
elif asymmetry is not None and asymmetry <= 4:
|
| 313 |
+
strengths.append(
|
| 314 |
+
{
|
| 315 |
+
"key": "asymmetry",
|
| 316 |
+
"title": "์ข์ฐ ๋ฆฌ๋ฌ",
|
| 317 |
+
"message": f"์ข์ฐ ๋น๋์นญ ์ถ์ ์น {asymmetry:.1f}%๋ก ๊ท ํ ์๊ฒ ๊ธฐ๋ก๋์ด์.",
|
| 318 |
+
}
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
+
pace_variability = _number(metrics.get("pace_variability_pct"))
|
| 322 |
+
if pace_variability is not None and pace_variability >= 18:
|
| 323 |
+
focus.append(
|
| 324 |
+
_coaching_item(
|
| 325 |
+
"pace_stability",
|
| 326 |
+
"ํ์ด์ค ๋ณํ",
|
| 327 |
+
f"๊ตฌ๊ฐ๋ณ ํ์ด์ค ๋ณํ๊ฐ ์ฝ {pace_variability:.0f}%๋ก ์ปธ์ด์.",
|
| 328 |
+
"์ฒซ 5๋ถ์ ํ ๋จ๊ณ ์ฌ์ ์๋ ์๋๋ก ์์ํด ํ์ด์ค ๋ณํ๋ฅผ ์ค์ฌ ๋ณด์ธ์.",
|
| 329 |
+
1,
|
| 330 |
+
)
|
| 331 |
+
)
|
| 332 |
+
|
| 333 |
+
focus.sort(key=lambda item: int(item["severity"]), reverse=True)
|
| 334 |
+
strengths = strengths[:2]
|
| 335 |
+
focus = focus[:2]
|
| 336 |
+
pace_text = ""
|
| 337 |
+
if avg_pace_sec_per_km and avg_pace_sec_per_km > 0:
|
| 338 |
+
pace_text = f" ํ๊ท ํ์ด์ค {avg_pace_sec_per_km // 60}'{avg_pace_sec_per_km % 60:02d}''์ ํจ๊ป ๋น๊ตํด ๋ณผ ์ ์์ด์."
|
| 339 |
+
|
| 340 |
+
status = "ready" if has_motion and sample_count >= 20 else "partial"
|
| 341 |
+
headline = (
|
| 342 |
+
"์ด๋ฒ ๋ฌ๋์์ ๊ฐ์ฅ ๋จผ์ ๋ค๋ฌ์ ํ ๊ฐ์ง๋ฅผ ๊ณจ๋ผ ์๋ดํ์ด์."
|
| 343 |
+
if focus
|
| 344 |
+
else "์ผ์๋ก ํ์ธํ ๋ฌ๋ ๋ฆฌ๋ฌ์ด ์ ๋ฐ์ ์ผ๋ก ์์ ์ ์ด์์."
|
| 345 |
+
)
|
| 346 |
+
return {
|
| 347 |
+
"status": status,
|
| 348 |
+
"headline": headline + pace_text,
|
| 349 |
+
"strengths": strengths,
|
| 350 |
+
"focus": focus,
|
| 351 |
+
"next_action": (
|
| 352 |
+
focus[0]["action"]
|
| 353 |
+
if focus
|
| 354 |
+
else "๋ค์ ๋ฌ๋์์๋ ๊ฐ์ ์ผ์ ๊ถํ์ ์ ์งํด ๋ด ๋ฆฌ๋ฌ์ ๊ธฐ์ค์ ์์ ๋ณด์ธ์."
|
| 355 |
+
),
|
| 356 |
+
"sample_count": sample_count,
|
| 357 |
+
"disclaimer": "์์น ์ผ์์ ์ถ์ ๊ฐ์ ๋ฐํ์ผ๋ก ํ ๋ฌ๋ ์๋ด์
๋๋ค. ํต์ฆยท์ด์ง๋ผ ๋ฑ ์ด์์ด ์์ผ๋ฉด ์ด๋์ ๋ฉ์ถ๊ณ ํด์ํ์ธ์.",
|
| 358 |
+
"metrics": metrics,
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def _trend(
|
| 363 |
+
values: list[float],
|
| 364 |
+
*,
|
| 365 |
+
lower_is_better: bool,
|
| 366 |
+
tolerance: float,
|
| 367 |
+
) -> str:
|
| 368 |
+
if len(values) < 4:
|
| 369 |
+
return "collecting"
|
| 370 |
+
split = max(1, len(values) // 2)
|
| 371 |
+
older = mean(values[:split])
|
| 372 |
+
newer = mean(values[split:])
|
| 373 |
+
delta = newer - older
|
| 374 |
+
if abs(delta) <= tolerance:
|
| 375 |
+
return "stable"
|
| 376 |
+
improved = delta < 0 if lower_is_better else delta > 0
|
| 377 |
+
return "improving" if improved else "needs_attention"
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def build_running_profile(records: list[dict[str, Any]]) -> dict[str, Any]:
|
| 381 |
+
"""Build an explainable long-term profile from the latest persisted runs."""
|
| 382 |
+
|
| 383 |
+
normalized: list[dict[str, Any]] = []
|
| 384 |
+
for record in reversed(records):
|
| 385 |
+
raw_summary = record.get("sensor_summary") or record.get("summary") or {}
|
| 386 |
+
if isinstance(raw_summary, str):
|
| 387 |
+
try:
|
| 388 |
+
raw_summary = json.loads(raw_summary)
|
| 389 |
+
except json.JSONDecodeError:
|
| 390 |
+
raw_summary = {}
|
| 391 |
+
if not isinstance(raw_summary, dict):
|
| 392 |
+
continue
|
| 393 |
+
summary = build_sensor_summary(raw_summary, [], record.get("avg_bpm"))
|
| 394 |
+
if any(
|
| 395 |
+
key in summary
|
| 396 |
+
for key in (
|
| 397 |
+
"avg_cadence_spm",
|
| 398 |
+
"avg_ground_contact_time_ms",
|
| 399 |
+
"avg_vertical_oscillation_cm",
|
| 400 |
+
"avg_left_right_asymmetry_pct",
|
| 401 |
+
"avg_heart_rate_bpm",
|
| 402 |
+
)
|
| 403 |
+
):
|
| 404 |
+
normalized.append(summary)
|
| 405 |
+
|
| 406 |
+
sample_size = len(normalized)
|
| 407 |
+
if sample_size == 0:
|
| 408 |
+
return {
|
| 409 |
+
"status": "waiting_for_sensor_data",
|
| 410 |
+
"sample_size": 0,
|
| 411 |
+
"required_sample_size": 3,
|
| 412 |
+
"headline": "์ผ์๊ฐ ๊ธฐ๋ก๋ ๋ฌ๋์ ์์ผ๋ฉด ๋๋ง์ ๋ฌ๋ ํน์ฑ์ ๋ณด์ฌ๋๋ฆด๊ฒ์.",
|
| 413 |
+
"characteristics": [],
|
| 414 |
+
"next_focus": None,
|
| 415 |
+
"recommendations": ["์์น์ ์ ์ฒด ํ๋ยท์ฌ๋ฐ ๊ถํ์ ํ์ฉํ๊ณ ๋ฌ๋์ 3ํ ์ด์ ๊ธฐ๋กํด ์ฃผ์ธ์."],
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
config = [
|
| 419 |
+
(
|
| 420 |
+
"cadence",
|
| 421 |
+
"๋ฌ๋ ๋ฆฌ๋ฌ",
|
| 422 |
+
"avg_cadence_spm",
|
| 423 |
+
"SPM",
|
| 424 |
+
False,
|
| 425 |
+
4.0,
|
| 426 |
+
lambda value: value < 150,
|
| 427 |
+
"๋ณดํญ์ ์กฐ๊ธ ์ค์ด๊ณ ๊ฐ๋ฒผ์ด ๋ฐ๊ฑธ์์ผ๋ก ๋ฆฌ๋ฌ์ ๋ง์ถฐ ๋ณด์ธ์.",
|
| 428 |
+
),
|
| 429 |
+
(
|
| 430 |
+
"ground_contact",
|
| 431 |
+
"์ง๋ฉด ์ ์ด",
|
| 432 |
+
"avg_ground_contact_time_ms",
|
| 433 |
+
"ms",
|
| 434 |
+
True,
|
| 435 |
+
12.0,
|
| 436 |
+
lambda value: value >= 310,
|
| 437 |
+
"๋ฐ์ ์ค๋ ๋๋ฅด๊ธฐ๋ณด๋ค ์ง๋ฉด์ ๊ฐ๋ณ๊ฒ ๋ฐ๊ณ ๋ค์ ๋ฐ๋ก ๋์ด๊ฐ ๋ณด์ธ์.",
|
| 438 |
+
),
|
| 439 |
+
(
|
| 440 |
+
"vertical_oscillation",
|
| 441 |
+
"์ํ ์์ง์",
|
| 442 |
+
"avg_vertical_oscillation_cm",
|
| 443 |
+
"cm",
|
| 444 |
+
True,
|
| 445 |
+
0.5,
|
| 446 |
+
lambda value: value >= 10.5,
|
| 447 |
+
"์์ ์ ์ ๋ฉด์ ๋๊ณ ์๋ก ํ๊ธฐ๋ณด๋ค ์์ผ๋ก ๋์๊ฐ๋ ๋ฆฌ๋ฌ์ ๋ง๋ค์ด ๋ณด์ธ์.",
|
| 448 |
+
),
|
| 449 |
+
(
|
| 450 |
+
"asymmetry",
|
| 451 |
+
"์ข์ฐ ๊ท ํ",
|
| 452 |
+
"avg_left_right_asymmetry_pct",
|
| 453 |
+
"%",
|
| 454 |
+
True,
|
| 455 |
+
0.8,
|
| 456 |
+
lambda value: value >= 8,
|
| 457 |
+
"ํผ๋กํ ๋ ์๋ ์๋๋ฅผ ๋ฎ์ถ๊ณ ์์ชฝ ๋ฐ์ด ๊ฐ์ ๋ฆฌ๋ฌ์ธ์ง ํ์ธํด ๋ณด์ธ์.",
|
| 458 |
+
),
|
| 459 |
+
]
|
| 460 |
+
|
| 461 |
+
characteristics: list[dict[str, Any]] = []
|
| 462 |
+
candidates: list[tuple[float, str, str]] = []
|
| 463 |
+
for key, title, field, unit, lower_is_better, tolerance, needs_work, action in config:
|
| 464 |
+
values = [
|
| 465 |
+
value
|
| 466 |
+
for value in (_number(item.get(field)) for item in normalized)
|
| 467 |
+
if value is not None
|
| 468 |
+
]
|
| 469 |
+
if not values:
|
| 470 |
+
continue
|
| 471 |
+
average = mean(values)
|
| 472 |
+
flagged_ratio = sum(1 for value in values if needs_work(value)) / len(values)
|
| 473 |
+
characteristics.append(
|
| 474 |
+
{
|
| 475 |
+
"key": key,
|
| 476 |
+
"title": title,
|
| 477 |
+
"average": _round(average),
|
| 478 |
+
"unit": unit,
|
| 479 |
+
"recorded_runs": len(values),
|
| 480 |
+
"trend": _trend(values, lower_is_better=lower_is_better, tolerance=tolerance),
|
| 481 |
+
"summary": f"์ต๊ทผ {len(values)}ํ ํ๊ท {average:.1f}{unit}",
|
| 482 |
+
}
|
| 483 |
+
)
|
| 484 |
+
if flagged_ratio > 0:
|
| 485 |
+
candidates.append((flagged_ratio, title, action))
|
| 486 |
+
|
| 487 |
+
heart_values = [
|
| 488 |
+
value
|
| 489 |
+
for value in (_number(item.get("avg_heart_rate_bpm")) for item in normalized)
|
| 490 |
+
if value is not None
|
| 491 |
+
]
|
| 492 |
+
if heart_values:
|
| 493 |
+
avg_heart = mean(heart_values)
|
| 494 |
+
characteristics.append(
|
| 495 |
+
{
|
| 496 |
+
"key": "heart_rate",
|
| 497 |
+
"title": "๋ฌ๋ ๊ฐ๋ ๊ธฐ์ค",
|
| 498 |
+
"average": _round(avg_heart),
|
| 499 |
+
"unit": "BPM",
|
| 500 |
+
"recorded_runs": len(heart_values),
|
| 501 |
+
"trend": "collecting" if len(heart_values) < 4 else "stable",
|
| 502 |
+
"summary": f"์ต๊ทผ {len(heart_values)}ํ ํ๊ท {avg_heart:.0f}BPM",
|
| 503 |
+
}
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
candidates.sort(key=lambda item: item[0], reverse=True)
|
| 507 |
+
next_focus = None
|
| 508 |
+
recommendations: list[str] = []
|
| 509 |
+
if candidates:
|
| 510 |
+
_, title, action = candidates[0]
|
| 511 |
+
next_focus = {"title": title, "action": action}
|
| 512 |
+
recommendations.append(action)
|
| 513 |
+
else:
|
| 514 |
+
recommendations.append("ํ์ฌ ๋ฆฌ๋ฌ์ ์ ์งํ๋ฉด์ ๊ฐ์ ์กฐ๊ฑด์ ๋ฌ๋ ๋ฐ์ดํฐ๋ฅผ ๋ ์์ ๋ณด์ธ์.")
|
| 515 |
+
if sample_size < 3:
|
| 516 |
+
recommendations.append("3ํ ์ด์ ๊ธฐ๋ก๋๋ฉด ์ต๊ทผ ๋ณํ ์ถ์ธ๋ ํจ๊ป ๋ณด์ฌ๋๋ฆด ์ ์์ด์.")
|
| 517 |
+
|
| 518 |
+
return {
|
| 519 |
+
"status": "ready" if sample_size >= 3 else "collecting",
|
| 520 |
+
"sample_size": sample_size,
|
| 521 |
+
"required_sample_size": 3,
|
| 522 |
+
"headline": (
|
| 523 |
+
"์ต๊ทผ ๋ฌ๋์ ๋ฐ๋ณต๋๋ ๋ฆฌ๋ฌ์ ๋ฐํ์ผ๋ก ๋ค์ ํ ๊ฐ์ง๋ฅผ ์ ์ํด์."
|
| 524 |
+
if sample_size >= 3
|
| 525 |
+
else "์์ง ํ๋ณธ์ ๋ชจ์ผ๋ ์ค์ด์์. ์ง๊ธ ๊ธฐ๋ก๋ ๋ค์ ์ฝ์นญ์ ๊ธฐ์ค์ด ๋ฉ๋๋ค."
|
| 526 |
+
),
|
| 527 |
+
"characteristics": characteristics,
|
| 528 |
+
"next_focus": next_focus,
|
| 529 |
+
"recommendations": recommendations,
|
| 530 |
+
"disclaimer": "์์น ์ผ์์ ์ถ์ ๊ฐ์ ๋น๊ตํ ๋ฌ๋ ํน์ฑ์
๋๋ค. ๊ฑด๊ฐ ์ํ ํ๋จ์๋ ์ฌ์ฉํ์ง ๋ง์ธ์.",
|
| 531 |
+
}
|
api/auth-service/tests/test_running_coaching.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from app.services.running_coaching import build_run_analysis, build_running_profile
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class RunningCoachingTests(unittest.TestCase):
|
| 7 |
+
def test_prioritizes_high_load_and_low_cadence(self):
|
| 8 |
+
analysis = build_run_analysis(
|
| 9 |
+
{
|
| 10 |
+
"sample_count": 80,
|
| 11 |
+
"avg_heart_rate_bpm": 154,
|
| 12 |
+
"heart_rate_rise_bpm": 16,
|
| 13 |
+
"avg_cadence_spm": 142,
|
| 14 |
+
"avg_ground_contact_time_ms": 330,
|
| 15 |
+
"avg_vertical_oscillation_cm": 11.4,
|
| 16 |
+
"avg_left_right_asymmetry_pct": 9.2,
|
| 17 |
+
}
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
self.assertEqual([item["key"] for item in analysis["focus"]], ["heart_rate", "cadence"])
|
| 21 |
+
self.assertIn("๋ณดํญ", analysis["focus"][1]["action"])
|
| 22 |
+
|
| 23 |
+
def test_does_not_warn_when_form_values_are_stable(self):
|
| 24 |
+
analysis = build_run_analysis(
|
| 25 |
+
{
|
| 26 |
+
"sample_count": 80,
|
| 27 |
+
"avg_heart_rate_bpm": 148,
|
| 28 |
+
"avg_cadence_spm": 170,
|
| 29 |
+
"avg_ground_contact_time_ms": 280,
|
| 30 |
+
"avg_vertical_oscillation_cm": 8.0,
|
| 31 |
+
"avg_left_right_asymmetry_pct": 3.1,
|
| 32 |
+
}
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
self.assertEqual(analysis["focus"], [])
|
| 36 |
+
self.assertGreaterEqual(len(analysis["strengths"]), 2)
|
| 37 |
+
|
| 38 |
+
def test_profile_uses_repeated_sensor_records(self):
|
| 39 |
+
profile = build_running_profile(
|
| 40 |
+
[
|
| 41 |
+
{"sensor_summary": {"avg_cadence_spm": 142, "avg_ground_contact_time_ms": 325}},
|
| 42 |
+
{"sensor_summary": {"avg_cadence_spm": 145, "avg_ground_contact_time_ms": 320}},
|
| 43 |
+
{"sensor_summary": {"avg_cadence_spm": 148, "avg_ground_contact_time_ms": 315}},
|
| 44 |
+
]
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
self.assertEqual(profile["status"], "ready")
|
| 48 |
+
self.assertEqual(profile["next_focus"]["title"], "๋ฌ๋ ๋ฆฌ๋ฌ")
|
| 49 |
+
self.assertEqual(profile["sample_size"], 3)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
unittest.main()
|
main/app/_components/TopBar.tsx
CHANGED
|
@@ -20,6 +20,7 @@ const menuSections = [
|
|
| 20 |
{ label: "์ด ์ด๋ ๊ฑฐ๋ฆฌ", href: "/stats/total-length" },
|
| 21 |
{ label: "์ด ์๊ฐ", href: "/stats/total-time" },
|
| 22 |
{ label: "์์ฃผ ์บ๋ฆฐ๋", href: "/stats/complete-number" },
|
|
|
|
| 23 |
],
|
| 24 |
},
|
| 25 |
{ title: "์ ์ฅ๋ ๋ฃจํธ", href: "/saved-routes", items: [] },
|
|
|
|
| 20 |
{ label: "์ด ์ด๋ ๊ฑฐ๋ฆฌ", href: "/stats/total-length" },
|
| 21 |
{ label: "์ด ์๊ฐ", href: "/stats/total-time" },
|
| 22 |
{ label: "์์ฃผ ์บ๋ฆฐ๋", href: "/stats/complete-number" },
|
| 23 |
+
{ label: "๋์ ๋ฌ๋ ํน์ฑ", href: "/running-profile" },
|
| 24 |
],
|
| 25 |
},
|
| 26 |
{ title: "์ ์ฅ๋ ๋ฃจํธ", href: "/saved-routes", items: [] },
|
main/app/_utils/runningCoaching.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type RunningFormMetrics = {
|
| 2 |
+
accelerometer_available?: boolean;
|
| 3 |
+
sample_count?: number;
|
| 4 |
+
avg_heart_rate_bpm?: number;
|
| 5 |
+
avg_cadence_spm?: number;
|
| 6 |
+
avg_ground_contact_time_ms?: number;
|
| 7 |
+
avg_flight_time_ms?: number;
|
| 8 |
+
avg_vertical_oscillation_cm?: number;
|
| 9 |
+
avg_left_right_asymmetry_pct?: number;
|
| 10 |
+
heart_rate_rise_bpm?: number;
|
| 11 |
+
pace_variability_pct?: number;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export type RunningCoachItem = {
|
| 15 |
+
key: string;
|
| 16 |
+
title: string;
|
| 17 |
+
message: string;
|
| 18 |
+
action?: string;
|
| 19 |
+
severity?: number;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
export type RunAnalysis = {
|
| 23 |
+
status: "ready" | "partial" | "waiting_for_sensor_data" | string;
|
| 24 |
+
headline: string;
|
| 25 |
+
strengths: RunningCoachItem[];
|
| 26 |
+
focus: RunningCoachItem[];
|
| 27 |
+
next_action: string;
|
| 28 |
+
sample_count: number;
|
| 29 |
+
disclaimer: string;
|
| 30 |
+
metrics?: RunningFormMetrics;
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
export type WatchRunStateForCoaching = {
|
| 34 |
+
is_running: boolean;
|
| 35 |
+
is_paused?: boolean;
|
| 36 |
+
current_heart_rate?: number | null;
|
| 37 |
+
average_heart_rate?: number | null;
|
| 38 |
+
form_metrics?: RunningFormMetrics | null;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
const FIELD_ALIASES: Record<string, keyof RunningFormMetrics> = {
|
| 42 |
+
accelerometerAvailable: "accelerometer_available",
|
| 43 |
+
totalMotionSamples: "sample_count",
|
| 44 |
+
estimatedSampleCount: "sample_count",
|
| 45 |
+
averageHeartRateBpm: "avg_heart_rate_bpm",
|
| 46 |
+
avgHeartRateBpm: "avg_heart_rate_bpm",
|
| 47 |
+
estimatedCadenceSpm: "avg_cadence_spm",
|
| 48 |
+
cadenceSpm: "avg_cadence_spm",
|
| 49 |
+
groundContactTimeMs: "avg_ground_contact_time_ms",
|
| 50 |
+
estimatedGroundContactTimeMs: "avg_ground_contact_time_ms",
|
| 51 |
+
flightTimeMs: "avg_flight_time_ms",
|
| 52 |
+
estimatedFlightTimeMs: "avg_flight_time_ms",
|
| 53 |
+
verticalOscillationCm: "avg_vertical_oscillation_cm",
|
| 54 |
+
estimatedVerticalOscillationCm: "avg_vertical_oscillation_cm",
|
| 55 |
+
leftRightAsymmetryPct: "avg_left_right_asymmetry_pct",
|
| 56 |
+
estimatedLeftRightAsymmetryPct: "avg_left_right_asymmetry_pct",
|
| 57 |
+
heartRateRiseBpm: "heart_rate_rise_bpm",
|
| 58 |
+
paceVariabilityPct: "pace_variability_pct",
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
function finiteNumber(value: unknown): number | null {
|
| 62 |
+
const parsed = typeof value === "number" ? value : Number(value);
|
| 63 |
+
return Number.isFinite(parsed) ? parsed : null;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function parseRunningFormMetrics(raw: string | null): RunningFormMetrics | null {
|
| 67 |
+
if (!raw) return null;
|
| 68 |
+
const candidates = [raw];
|
| 69 |
+
try {
|
| 70 |
+
candidates.unshift(decodeURIComponent(raw));
|
| 71 |
+
} catch {
|
| 72 |
+
// Use the original value if it was not URI encoded.
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
for (const candidate of candidates) {
|
| 76 |
+
try {
|
| 77 |
+
const parsed = JSON.parse(candidate) as unknown;
|
| 78 |
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
|
| 79 |
+
const normalized: RunningFormMetrics = {};
|
| 80 |
+
for (const [rawKey, rawValue] of Object.entries(parsed)) {
|
| 81 |
+
const key = FIELD_ALIASES[rawKey] ?? (rawKey as keyof RunningFormMetrics);
|
| 82 |
+
if (key === "accelerometer_available") {
|
| 83 |
+
normalized[key] = Boolean(rawValue);
|
| 84 |
+
continue;
|
| 85 |
+
}
|
| 86 |
+
const numeric = finiteNumber(rawValue);
|
| 87 |
+
if (numeric !== null) {
|
| 88 |
+
normalized[key] = numeric;
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
return Object.keys(normalized).length > 0 ? normalized : null;
|
| 92 |
+
} catch {
|
| 93 |
+
// Try a once-decoded value before rejecting the URL parameter.
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
return null;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export function buildLiveSensorCoachMessage(
|
| 100 |
+
state: WatchRunStateForCoaching
|
| 101 |
+
): { key: string; message: string } | null {
|
| 102 |
+
if (!state.is_running || state.is_paused) return null;
|
| 103 |
+
const metrics = state.form_metrics ?? {};
|
| 104 |
+
const currentHeartRate = finiteNumber(state.current_heart_rate);
|
| 105 |
+
const averageHeartRate = finiteNumber(state.average_heart_rate);
|
| 106 |
+
if (
|
| 107 |
+
currentHeartRate !== null &&
|
| 108 |
+
averageHeartRate !== null &&
|
| 109 |
+
averageHeartRate >= 120 &&
|
| 110 |
+
currentHeartRate - averageHeartRate >= 12
|
| 111 |
+
) {
|
| 112 |
+
return {
|
| 113 |
+
key: "heart_rate",
|
| 114 |
+
message:
|
| 115 |
+
"ํ์ฌ ์ฌ๋ฐ์ด ์ด๋ฒ ๋ฌ๋ ํ๊ท ๋ณด๋ค ์ฌ๋ผ๊ฐ์ด์. ๋ณดํญ์ ์กฐ๊ธ ์ค์ด๊ณ ํธํ ํธํก์ผ๋ก ์๋๋ฅผ ์กฐ์ ํด ๋ณด์ธ์.",
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
const cadence = finiteNumber(metrics.avg_cadence_spm);
|
| 120 |
+
if (cadence !== null && cadence < 150) {
|
| 121 |
+
return {
|
| 122 |
+
key: "cadence",
|
| 123 |
+
message:
|
| 124 |
+
"๋ฌ๋ ๋ฆฌ๋ฌ์ด ๋๋ ค์ก์ด์. ๋ณดํญ์ ๋ฌด๋ฆฌํ๊ฒ ๋๋ฆฌ์ง ๋ง๊ณ ๋ฐ์ ์งง๊ณ ๊ฐ๋ณ๊ฒ ์ฎ๊ฒจ ๋ณด์ธ์.",
|
| 125 |
+
};
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
const asymmetry = finiteNumber(metrics.avg_left_right_asymmetry_pct);
|
| 129 |
+
if (asymmetry !== null && asymmetry >= 8) {
|
| 130 |
+
return {
|
| 131 |
+
key: "asymmetry",
|
| 132 |
+
message:
|
| 133 |
+
"์ข์ฐ ๋ฆฌ๋ฌ์ด ํ์ชฝ์ผ๋ก ์น์ฐ์ณ ๊ธฐ๋ก๋๊ณ ์์ด์. ์๋๋ฅผ ์กฐ๊ธ ๋ฎ์ถ๊ณ ๊ท ํ ์๋ ๋ฐ๊ฑธ์์ ํ์ธํด ๋ณด์ธ์.",
|
| 134 |
+
};
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const contact = finiteNumber(metrics.avg_ground_contact_time_ms);
|
| 138 |
+
if (contact !== null && contact >= 310) {
|
| 139 |
+
return {
|
| 140 |
+
key: "ground_contact",
|
| 141 |
+
message:
|
| 142 |
+
"๋ฐ์ด ์ง๋ฉด์ ๋จธ๋ฌด๋ ์๊ฐ์ด ๊ธธ์ด์ก์ด์. ์ง๋ฉด์ ๊ฐ๋ณ๊ฒ ๋ฐ๊ณ ๋ค์ ๋ฐ๋ก ๋์ด๊ฐ ๋ณด์ธ์.",
|
| 143 |
+
};
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const vertical = finiteNumber(metrics.avg_vertical_oscillation_cm);
|
| 147 |
+
if (vertical !== null && vertical >= 10.5) {
|
| 148 |
+
return {
|
| 149 |
+
key: "vertical_oscillation",
|
| 150 |
+
message:
|
| 151 |
+
"์์๋ ์์ง์์ด ์ปค์ก์ด์. ์์ ์ ์ ๋ฉด์ ๋๊ณ ์์ผ๋ก ๋ถ๋๋ฝ๊ฒ ๋์๊ฐ ๋ณด์ธ์.",
|
| 152 |
+
};
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
return null;
|
| 156 |
+
}
|
main/app/run/[id]/finish/page.tsx
CHANGED
|
@@ -7,6 +7,10 @@ import TopBar from "../../../_components/TopBar";
|
|
| 7 |
import LeafletMap from "../../../_components/LeafletMap";
|
| 8 |
import { FLOW_KEYS, isUiFlowPermitValid } from "../../../_utils/uiFlowGuard";
|
| 9 |
import { isNativeAppEnvironment } from "../../../_utils/nativeWearBridge";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
const DEFAULT_CENTER: [number, number] = [37.5665, 126.978];
|
| 12 |
const MANUAL_CENTER: [number, number] = [35.1796, 129.0756];
|
|
@@ -26,6 +30,7 @@ type StoredActual = {
|
|
| 26 |
type RunSessionCreateResponse = {
|
| 27 |
run_session_id?: number;
|
| 28 |
route_id?: number | null;
|
|
|
|
| 29 |
};
|
| 30 |
|
| 31 |
function parseNumber(value: string | null, fallback = 0): number {
|
|
@@ -210,6 +215,8 @@ function RunFinishPageContent() {
|
|
| 210 |
const lngParam = searchParams.get("lng");
|
| 211 |
const polylineParam = searchParams.get("polyline");
|
| 212 |
const actualPolylineParam = searchParams.get("actualPolyline");
|
|
|
|
|
|
|
| 213 |
const nativeResult = searchParams.get("nativeResult") === "1";
|
| 214 |
|
| 215 |
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
|
@@ -222,6 +229,7 @@ function RunFinishPageContent() {
|
|
| 222 |
const [actualMarker, setActualMarker] = useState<Point | null>(null);
|
| 223 |
const [runSessionId, setRunSessionId] = useState<number | null>(null);
|
| 224 |
const [savedRouteId, setSavedRouteId] = useState<number | null>(null);
|
|
|
|
| 225 |
const [rating, setRating] = useState(0);
|
| 226 |
const [isSavingRun, setIsSavingRun] = useState(false);
|
| 227 |
const [hasAccessToken, setHasAccessToken] = useState<boolean | null>(null);
|
|
@@ -354,12 +362,15 @@ function RunFinishPageContent() {
|
|
| 354 |
const achievementPct = parseNumber(achievement, 0);
|
| 355 |
const caloriesValue = Math.round(parseNumber(calories, 0));
|
| 356 |
const bpmValue = Math.round(parseNumber(bpm, 0));
|
|
|
|
| 357 |
|
| 358 |
const payload = {
|
| 359 |
route_title: routeTitle,
|
| 360 |
animal_label: animal || null,
|
| 361 |
source: mode === "manual" ? "manual" : "nearby",
|
| 362 |
place_text: place || address || null,
|
|
|
|
|
|
|
| 363 |
finished_at: new Date().toISOString(),
|
| 364 |
duration_sec: durationSec,
|
| 365 |
distance_km: distanceKm,
|
|
@@ -373,6 +384,7 @@ function RunFinishPageContent() {
|
|
| 373 |
center_lng: centerFromPolyline?.[1] ?? null,
|
| 374 |
polyline: routePolylineForSave.length > 1 ? routePolylineForSave : null,
|
| 375 |
map_image_url: image || null,
|
|
|
|
| 376 |
};
|
| 377 |
|
| 378 |
fetch(`${apiBase}/api/v1/auth/runs`, {
|
|
@@ -394,6 +406,9 @@ function RunFinishPageContent() {
|
|
| 394 |
if (typeof responseJson.route_id === "number") {
|
| 395 |
setSavedRouteId(responseJson.route_id);
|
| 396 |
}
|
|
|
|
|
|
|
|
|
|
| 397 |
window.sessionStorage.removeItem("tranimal-stats-bundle-v1");
|
| 398 |
})
|
| 399 |
.catch(() => {
|
|
@@ -408,6 +423,7 @@ function RunFinishPageContent() {
|
|
| 408 |
actualKey,
|
| 409 |
polylineParam,
|
| 410 |
actualPolylineParam,
|
|
|
|
| 411 |
time,
|
| 412 |
pace,
|
| 413 |
distance,
|
|
@@ -611,6 +627,57 @@ function RunFinishPageContent() {
|
|
| 611 |
</div>
|
| 612 |
</div>
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
{saveError && (
|
| 615 |
<div className="rounded-xl border border-[#ffcccc] bg-[#fff1f1] px-4 py-3 text-sm text-[#a61d1d]">
|
| 616 |
{saveError}
|
|
|
|
| 7 |
import LeafletMap from "../../../_components/LeafletMap";
|
| 8 |
import { FLOW_KEYS, isUiFlowPermitValid } from "../../../_utils/uiFlowGuard";
|
| 9 |
import { isNativeAppEnvironment } from "../../../_utils/nativeWearBridge";
|
| 10 |
+
import {
|
| 11 |
+
parseRunningFormMetrics,
|
| 12 |
+
type RunAnalysis,
|
| 13 |
+
} from "../../../_utils/runningCoaching";
|
| 14 |
|
| 15 |
const DEFAULT_CENTER: [number, number] = [37.5665, 126.978];
|
| 16 |
const MANUAL_CENTER: [number, number] = [35.1796, 129.0756];
|
|
|
|
| 30 |
type RunSessionCreateResponse = {
|
| 31 |
run_session_id?: number;
|
| 32 |
route_id?: number | null;
|
| 33 |
+
run_analysis?: RunAnalysis | null;
|
| 34 |
};
|
| 35 |
|
| 36 |
function parseNumber(value: string | null, fallback = 0): number {
|
|
|
|
| 215 |
const lngParam = searchParams.get("lng");
|
| 216 |
const polylineParam = searchParams.get("polyline");
|
| 217 |
const actualPolylineParam = searchParams.get("actualPolyline");
|
| 218 |
+
const sensorSummaryParam =
|
| 219 |
+
searchParams.get("sensorSummary") ?? searchParams.get("formMetrics");
|
| 220 |
const nativeResult = searchParams.get("nativeResult") === "1";
|
| 221 |
|
| 222 |
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
|
|
|
| 229 |
const [actualMarker, setActualMarker] = useState<Point | null>(null);
|
| 230 |
const [runSessionId, setRunSessionId] = useState<number | null>(null);
|
| 231 |
const [savedRouteId, setSavedRouteId] = useState<number | null>(null);
|
| 232 |
+
const [runAnalysis, setRunAnalysis] = useState<RunAnalysis | null>(null);
|
| 233 |
const [rating, setRating] = useState(0);
|
| 234 |
const [isSavingRun, setIsSavingRun] = useState(false);
|
| 235 |
const [hasAccessToken, setHasAccessToken] = useState<boolean | null>(null);
|
|
|
|
| 362 |
const achievementPct = parseNumber(achievement, 0);
|
| 363 |
const caloriesValue = Math.round(parseNumber(calories, 0));
|
| 364 |
const bpmValue = Math.round(parseNumber(bpm, 0));
|
| 365 |
+
const formMetrics = parseRunningFormMetrics(sensorSummaryParam);
|
| 366 |
|
| 367 |
const payload = {
|
| 368 |
route_title: routeTitle,
|
| 369 |
animal_label: animal || null,
|
| 370 |
source: mode === "manual" ? "manual" : "nearby",
|
| 371 |
place_text: place || address || null,
|
| 372 |
+
run_id: runKey || null,
|
| 373 |
+
session_id: runKey || null,
|
| 374 |
finished_at: new Date().toISOString(),
|
| 375 |
duration_sec: durationSec,
|
| 376 |
distance_km: distanceKm,
|
|
|
|
| 384 |
center_lng: centerFromPolyline?.[1] ?? null,
|
| 385 |
polyline: routePolylineForSave.length > 1 ? routePolylineForSave : null,
|
| 386 |
map_image_url: image || null,
|
| 387 |
+
form_metrics: formMetrics,
|
| 388 |
};
|
| 389 |
|
| 390 |
fetch(`${apiBase}/api/v1/auth/runs`, {
|
|
|
|
| 406 |
if (typeof responseJson.route_id === "number") {
|
| 407 |
setSavedRouteId(responseJson.route_id);
|
| 408 |
}
|
| 409 |
+
if (responseJson.run_analysis) {
|
| 410 |
+
setRunAnalysis(responseJson.run_analysis);
|
| 411 |
+
}
|
| 412 |
window.sessionStorage.removeItem("tranimal-stats-bundle-v1");
|
| 413 |
})
|
| 414 |
.catch(() => {
|
|
|
|
| 423 |
actualKey,
|
| 424 |
polylineParam,
|
| 425 |
actualPolylineParam,
|
| 426 |
+
sensorSummaryParam,
|
| 427 |
time,
|
| 428 |
pace,
|
| 429 |
distance,
|
|
|
|
| 627 |
</div>
|
| 628 |
</div>
|
| 629 |
|
| 630 |
+
{runAnalysis && (
|
| 631 |
+
<section className="rounded-2xl border border-[#b9d7ff] bg-[#f4f9ff] px-4 py-4">
|
| 632 |
+
<div className="flex items-start justify-between gap-3">
|
| 633 |
+
<div>
|
| 634 |
+
<h2 className="text-base font-bold text-[#123a67]">์ผ์ ๊ธฐ๋ฐ ๋ฌ๋ ์ฝ์นญ</h2>
|
| 635 |
+
<p className="mt-1 text-sm leading-5 text-[#315b87]">{runAnalysis.headline}</p>
|
| 636 |
+
</div>
|
| 637 |
+
{runAnalysis.sample_count > 0 && (
|
| 638 |
+
<span className="shrink-0 rounded-full bg-white px-2 py-1 text-xs font-semibold text-[#315b87]">
|
| 639 |
+
{runAnalysis.sample_count}๊ฐ ์ํ
|
| 640 |
+
</span>
|
| 641 |
+
)}
|
| 642 |
+
</div>
|
| 643 |
+
|
| 644 |
+
{runAnalysis.strengths?.length > 0 && (
|
| 645 |
+
<div className="mt-4">
|
| 646 |
+
<p className="text-xs font-bold text-[#315b87]">์ ์ ์งํ ์ </p>
|
| 647 |
+
<div className="mt-2 flex flex-col gap-2">
|
| 648 |
+
{runAnalysis.strengths.map((item) => (
|
| 649 |
+
<div key={item.key} className="rounded-xl bg-white px-3 py-2 text-sm text-[#24496f]">
|
| 650 |
+
<span className="font-semibold">{item.title}</span> ยท {item.message}
|
| 651 |
+
</div>
|
| 652 |
+
))}
|
| 653 |
+
</div>
|
| 654 |
+
</div>
|
| 655 |
+
)}
|
| 656 |
+
|
| 657 |
+
{runAnalysis.focus?.length > 0 && (
|
| 658 |
+
<div className="mt-4">
|
| 659 |
+
<p className="text-xs font-bold text-[#315b87]">๋ค์ ๋ฌ๋์์ ํ ๊ฐ์ง</p>
|
| 660 |
+
<div className="mt-2 flex flex-col gap-2">
|
| 661 |
+
{runAnalysis.focus.map((item) => (
|
| 662 |
+
<div key={item.key} className="rounded-xl border border-[#d8e8fb] bg-white px-3 py-3 text-sm text-[#24496f]">
|
| 663 |
+
<p className="font-semibold">{item.title}</p>
|
| 664 |
+
<p className="mt-1 leading-5">{item.message}</p>
|
| 665 |
+
{item.action && <p className="mt-2 font-medium text-[#1565b5]">{item.action}</p>}
|
| 666 |
+
</div>
|
| 667 |
+
))}
|
| 668 |
+
</div>
|
| 669 |
+
</div>
|
| 670 |
+
)}
|
| 671 |
+
|
| 672 |
+
{runAnalysis.focus?.length === 0 && (
|
| 673 |
+
<p className="mt-4 rounded-xl bg-white px-3 py-3 text-sm leading-5 text-[#24496f]">
|
| 674 |
+
{runAnalysis.next_action}
|
| 675 |
+
</p>
|
| 676 |
+
)}
|
| 677 |
+
<p className="mt-3 text-xs leading-4 text-[#5f7d9d]">{runAnalysis.disclaimer}</p>
|
| 678 |
+
</section>
|
| 679 |
+
)}
|
| 680 |
+
|
| 681 |
{saveError && (
|
| 682 |
<div className="rounded-xl border border-[#ffcccc] bg-[#fff1f1] px-4 py-3 text-sm text-[#a61d1d]">
|
| 683 |
{saveError}
|
main/app/run/[id]/page.tsx
CHANGED
|
@@ -8,6 +8,10 @@ import YouTubeAudioPlayer, {
|
|
| 8 |
} from "../../_components/YouTubeAudioPlayer";
|
| 9 |
import { FLOW_KEYS, isUiFlowPermitValid, issueUiFlowPermit } from "../../_utils/uiFlowGuard";
|
| 10 |
import { sendNativeWearControl } from "../../_utils/nativeWearBridge";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
const routeDetails = [
|
| 13 |
{ id: "1", title: "๊ฐ์์ง ์ฝ์ค", distance: "3.2km" },
|
|
@@ -109,6 +113,16 @@ const parseDistance = (value: string) => {
|
|
| 109 |
return Number.isNaN(parsed) ? 0 : parsed;
|
| 110 |
};
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
| 113 |
|
| 114 |
const segmentLengthKm = (
|
|
@@ -156,6 +170,10 @@ type RunProgressSnapshot = {
|
|
| 156 |
elapsedSec: number;
|
| 157 |
};
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
const interpolateElapsedAtDistance = (
|
| 160 |
targetDistanceKm: number,
|
| 161 |
previous: RunProgressSnapshot,
|
|
@@ -222,6 +240,7 @@ function RunPageContent({ params }: { params: { id: string } }) {
|
|
| 222 |
const halfDistanceNotifiedRef = useRef(false);
|
| 223 |
const finish500mNotifiedRef = useRef(false);
|
| 224 |
const goalDistanceNotifiedRef = useRef(false);
|
|
|
|
| 225 |
const lastGpsPointRef = useRef<{
|
| 226 |
lat: number;
|
| 227 |
lon: number;
|
|
@@ -414,6 +433,52 @@ function RunPageContent({ params }: { params: { id: string } }) {
|
|
| 414 |
speech.speak(utterance);
|
| 415 |
}, []);
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
useEffect(() => {
|
| 418 |
if (!flowAllowed || distanceKm <= 0) {
|
| 419 |
return;
|
|
|
|
| 8 |
} from "../../_components/YouTubeAudioPlayer";
|
| 9 |
import { FLOW_KEYS, isUiFlowPermitValid, issueUiFlowPermit } from "../../_utils/uiFlowGuard";
|
| 10 |
import { sendNativeWearControl } from "../../_utils/nativeWearBridge";
|
| 11 |
+
import {
|
| 12 |
+
buildLiveSensorCoachMessage,
|
| 13 |
+
type WatchRunStateForCoaching,
|
| 14 |
+
} from "../../_utils/runningCoaching";
|
| 15 |
|
| 16 |
const routeDetails = [
|
| 17 |
{ id: "1", title: "๊ฐ์์ง ์ฝ์ค", distance: "3.2km" },
|
|
|
|
| 113 |
return Number.isNaN(parsed) ? 0 : parsed;
|
| 114 |
};
|
| 115 |
|
| 116 |
+
const getCookieValue = (name: string): string | null => {
|
| 117 |
+
if (typeof document === "undefined") return null;
|
| 118 |
+
return (
|
| 119 |
+
document.cookie
|
| 120 |
+
.split("; ")
|
| 121 |
+
.find((row) => row.startsWith(`${name}=`))
|
| 122 |
+
?.split("=")[1] ?? null
|
| 123 |
+
);
|
| 124 |
+
};
|
| 125 |
+
|
| 126 |
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
| 127 |
|
| 128 |
const segmentLengthKm = (
|
|
|
|
| 170 |
elapsedSec: number;
|
| 171 |
};
|
| 172 |
|
| 173 |
+
type WatchRunLiveEnvelope = {
|
| 174 |
+
run_state?: WatchRunStateForCoaching & { run_id?: string | null; session_id?: string | null };
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
const interpolateElapsedAtDistance = (
|
| 178 |
targetDistanceKm: number,
|
| 179 |
previous: RunProgressSnapshot,
|
|
|
|
| 240 |
const halfDistanceNotifiedRef = useRef(false);
|
| 241 |
const finish500mNotifiedRef = useRef(false);
|
| 242 |
const goalDistanceNotifiedRef = useRef(false);
|
| 243 |
+
const lastSensorGuideRef = useRef({ key: "", sentAt: 0 });
|
| 244 |
const lastGpsPointRef = useRef<{
|
| 245 |
lat: number;
|
| 246 |
lon: number;
|
|
|
|
| 433 |
speech.speak(utterance);
|
| 434 |
}, []);
|
| 435 |
|
| 436 |
+
useEffect(() => {
|
| 437 |
+
if (!flowAllowed || isPaused || typeof window === "undefined") {
|
| 438 |
+
return;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
const accessToken = localStorage.getItem("access_token") ?? getCookieValue("access_token");
|
| 442 |
+
if (!accessToken) {
|
| 443 |
+
return;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
| 447 |
+
let disposed = false;
|
| 448 |
+
const pollWearableSnapshot = async () => {
|
| 449 |
+
try {
|
| 450 |
+
const response = await fetch(`${apiBase}/api/v1/auth/watch/run/live-state`, {
|
| 451 |
+
headers: { Authorization: `Bearer ${accessToken}` },
|
| 452 |
+
cache: "no-store",
|
| 453 |
+
});
|
| 454 |
+
if (!response.ok || response.status === 204 || disposed) return;
|
| 455 |
+
const payload = (await response.json()) as WatchRunLiveEnvelope;
|
| 456 |
+
const state = payload.run_state;
|
| 457 |
+
if (!state || (runKey && state.run_id && state.run_id !== runKey)) return;
|
| 458 |
+
|
| 459 |
+
const guide = buildLiveSensorCoachMessage(state);
|
| 460 |
+
if (!guide) return;
|
| 461 |
+
const now = Date.now();
|
| 462 |
+
const previous = lastSensorGuideRef.current;
|
| 463 |
+
const cooldown = previous.key === guide.key ? 120_000 : 90_000;
|
| 464 |
+
if (now - previous.sentAt < cooldown) return;
|
| 465 |
+
if (window.speechSynthesis?.speaking) return;
|
| 466 |
+
|
| 467 |
+
lastSensorGuideRef.current = { key: guide.key, sentAt: now };
|
| 468 |
+
speakRunGuide(guide.message);
|
| 469 |
+
} catch {
|
| 470 |
+
// A temporary network loss should not interrupt GPS tracking or the run.
|
| 471 |
+
}
|
| 472 |
+
};
|
| 473 |
+
|
| 474 |
+
void pollWearableSnapshot();
|
| 475 |
+
const timer = window.setInterval(() => void pollWearableSnapshot(), 5_000);
|
| 476 |
+
return () => {
|
| 477 |
+
disposed = true;
|
| 478 |
+
window.clearInterval(timer);
|
| 479 |
+
};
|
| 480 |
+
}, [flowAllowed, isPaused, runKey, speakRunGuide]);
|
| 481 |
+
|
| 482 |
useEffect(() => {
|
| 483 |
if (!flowAllowed || distanceKm <= 0) {
|
| 484 |
return;
|
main/app/running-profile/page.tsx
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from "next/link";
|
| 4 |
+
import { useEffect, useState } from "react";
|
| 5 |
+
import TopBar from "../_components/TopBar";
|
| 6 |
+
|
| 7 |
+
type Characteristic = {
|
| 8 |
+
key: string;
|
| 9 |
+
title: string;
|
| 10 |
+
average: number;
|
| 11 |
+
unit: string;
|
| 12 |
+
recorded_runs: number;
|
| 13 |
+
trend: "collecting" | "stable" | "improving" | "needs_attention" | string;
|
| 14 |
+
summary: string;
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
type RunningProfile = {
|
| 18 |
+
status: "ready" | "collecting" | "waiting_for_sensor_data" | string;
|
| 19 |
+
sample_size: number;
|
| 20 |
+
required_sample_size: number;
|
| 21 |
+
headline: string;
|
| 22 |
+
characteristics: Characteristic[];
|
| 23 |
+
next_focus: { title: string; action: string } | null;
|
| 24 |
+
recommendations: string[];
|
| 25 |
+
disclaimer?: string;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function getAccessToken(): string | null {
|
| 29 |
+
if (typeof window === "undefined") return null;
|
| 30 |
+
const cookieToken = document.cookie
|
| 31 |
+
.split("; ")
|
| 32 |
+
.find((row) => row.startsWith("access_token="))
|
| 33 |
+
?.split("=")[1];
|
| 34 |
+
return localStorage.getItem("access_token") ?? cookieToken ?? null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function trendLabel(trend: Characteristic["trend"]): string {
|
| 38 |
+
if (trend === "improving") return "์ต๊ทผ ๊ฐ์ ";
|
| 39 |
+
if (trend === "needs_attention") return "์ต๊ทผ ํ์ธ";
|
| 40 |
+
if (trend === "stable") return "์์ ์ ";
|
| 41 |
+
return "๋ฐ์ดํฐ ์์ง ์ค";
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export default function RunningProfilePage() {
|
| 45 |
+
const [profile, setProfile] = useState<RunningProfile | null>(null);
|
| 46 |
+
const [loading, setLoading] = useState(true);
|
| 47 |
+
const [error, setError] = useState<string | null>(null);
|
| 48 |
+
|
| 49 |
+
useEffect(() => {
|
| 50 |
+
const token = getAccessToken();
|
| 51 |
+
if (!token) {
|
| 52 |
+
setError("๋ฌ๋ ํน์ฑ์ ๋ก๊ทธ์ธ ํ ํ์ธํ ์ ์์ด์.");
|
| 53 |
+
setLoading(false);
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
| 57 |
+
fetch(`${apiBase}/api/v1/auth/running-coaching/profile`, {
|
| 58 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 59 |
+
cache: "no-store",
|
| 60 |
+
})
|
| 61 |
+
.then(async (response) => {
|
| 62 |
+
if (!response.ok) throw new Error("profile request failed");
|
| 63 |
+
return (await response.json()) as { profile?: RunningProfile };
|
| 64 |
+
})
|
| 65 |
+
.then((payload) => setProfile(payload.profile ?? null))
|
| 66 |
+
.catch(() => setError("๋ฌ๋ ํน์ฑ์ ๋ถ๋ฌ์ค์ง ๋ชปํ์ด์. ์ ์ ํ ๋ค์ ์๋ํด ์ฃผ์ธ์."))
|
| 67 |
+
.finally(() => setLoading(false));
|
| 68 |
+
}, []);
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<main className="min-h-screen bg-[#f7f9fc] text-black">
|
| 72 |
+
<TopBar />
|
| 73 |
+
<section className="mx-auto w-full max-w-3xl px-6 py-8 sm:px-10">
|
| 74 |
+
<Link href="/stats" className="text-sm font-medium text-[#1565b5] hover:underline">
|
| 75 |
+
โ ํต๊ณ๋ก ๋์๊ฐ๊ธฐ
|
| 76 |
+
</Link>
|
| 77 |
+
<div className="mt-4">
|
| 78 |
+
<p className="text-sm font-semibold text-[#1565b5]">WEARABLE RUNNING INSIGHT</p>
|
| 79 |
+
<h1 className="mt-1 text-3xl font-bold">๋์ ๋ฌ๋ ํน์ฑ</h1>
|
| 80 |
+
<p className="mt-2 text-sm leading-6 text-[#5b6675]">
|
| 81 |
+
์์น๊ฐ ์ ์ฅํ ์ฌ๋ฐยท์์ง์ ๋ฐ์ดํฐ๋ฅผ ์ต๊ทผ ๋ฌ๋๋ผ๋ฆฌ ๋น๊ตํด, ๋ค์์ ๋ค๋ฌ์ ํ ๊ฐ์ง๋ฅผ ์๋ ค๋๋ ค์.
|
| 82 |
+
</p>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
{loading && <p className="mt-8 text-sm text-[#667085]">๋ฌ๋ ๊ธฐ๋ก์ ๋ถ์ํ๊ณ ์์ด์...</p>}
|
| 86 |
+
{error && <div className="mt-8 rounded-2xl border border-[#ffd1d1] bg-white px-4 py-4 text-sm text-[#a61d1d]">{error}</div>}
|
| 87 |
+
|
| 88 |
+
{profile && (
|
| 89 |
+
<div className="mt-7 flex flex-col gap-4">
|
| 90 |
+
<section className="rounded-2xl border border-[#cfe1f6] bg-[#eef7ff] px-5 py-5">
|
| 91 |
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
| 92 |
+
<div>
|
| 93 |
+
<h2 className="text-lg font-bold text-[#123a67]">์ด๋ฒ ์ฃผ์ ๋ฌ๋ ๋ฐฉํฅ</h2>
|
| 94 |
+
<p className="mt-2 text-sm leading-6 text-[#315b87]">{profile.headline}</p>
|
| 95 |
+
</div>
|
| 96 |
+
<span className="rounded-full bg-white px-3 py-1.5 text-xs font-bold text-[#315b87]">
|
| 97 |
+
์ผ์ ๊ธฐ๋ก {profile.sample_size}ํ
|
| 98 |
+
</span>
|
| 99 |
+
</div>
|
| 100 |
+
{profile.next_focus && (
|
| 101 |
+
<div className="mt-4 rounded-xl bg-white px-4 py-3 text-sm text-[#24496f]">
|
| 102 |
+
<p className="font-semibold">์ง์คํ ํญ๋ชฉ: {profile.next_focus.title}</p>
|
| 103 |
+
<p className="mt-1 leading-5">{profile.next_focus.action}</p>
|
| 104 |
+
</div>
|
| 105 |
+
)}
|
| 106 |
+
</section>
|
| 107 |
+
|
| 108 |
+
{profile.characteristics.length > 0 && (
|
| 109 |
+
<section>
|
| 110 |
+
<h2 className="text-lg font-bold">๊ธฐ๋ก์์ ๋ณด์ธ ํน์ฑ</h2>
|
| 111 |
+
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
| 112 |
+
{profile.characteristics.map((item) => (
|
| 113 |
+
<article key={item.key} className="rounded-2xl border border-black/10 bg-white px-4 py-4 shadow-sm">
|
| 114 |
+
<div className="flex items-start justify-between gap-2">
|
| 115 |
+
<h3 className="font-semibold">{item.title}</h3>
|
| 116 |
+
<span className="rounded-full bg-[#f2f5f8] px-2 py-1 text-[11px] font-semibold text-[#536273]">
|
| 117 |
+
{trendLabel(item.trend)}
|
| 118 |
+
</span>
|
| 119 |
+
</div>
|
| 120 |
+
<p className="mt-4 text-2xl font-bold">
|
| 121 |
+
{item.average} <span className="text-sm font-medium text-[#667085]">{item.unit}</span>
|
| 122 |
+
</p>
|
| 123 |
+
<p className="mt-2 text-sm text-[#667085]">{item.summary}</p>
|
| 124 |
+
</article>
|
| 125 |
+
))}
|
| 126 |
+
</div>
|
| 127 |
+
</section>
|
| 128 |
+
)}
|
| 129 |
+
|
| 130 |
+
<section className="rounded-2xl border border-black/10 bg-white px-5 py-5">
|
| 131 |
+
<h2 className="text-lg font-bold">์ฌ๋ฐ๋ฅธ ๋ฌ๋์ ์ํ ๋ค์ ๋จ๊ณ</h2>
|
| 132 |
+
<ul className="mt-3 flex list-disc flex-col gap-2 pl-5 text-sm leading-6 text-[#4b5563]">
|
| 133 |
+
{profile.recommendations.map((recommendation) => (
|
| 134 |
+
<li key={recommendation}>{recommendation}</li>
|
| 135 |
+
))}
|
| 136 |
+
</ul>
|
| 137 |
+
{profile.status === "collecting" && (
|
| 138 |
+
<p className="mt-4 text-xs leading-5 text-[#667085]">
|
| 139 |
+
์ต๊ทผ {profile.required_sample_size}ํ ์ด์ ์ผ์ ๊ธฐ๋ก์ด ๋ชจ์ด๋ฉด ๋ณํ ์ถ์ธ๊น์ง ๋ ์ ํํ๊ฒ ๋น๊ตํ ์ ์์ด์.
|
| 140 |
+
</p>
|
| 141 |
+
)}
|
| 142 |
+
{profile.disclaimer && <p className="mt-4 text-xs leading-5 text-[#667085]">{profile.disclaimer}</p>}
|
| 143 |
+
</section>
|
| 144 |
+
</div>
|
| 145 |
+
)}
|
| 146 |
+
</section>
|
| 147 |
+
</main>
|
| 148 |
+
);
|
| 149 |
+
}
|