tkadkghdlf commited on
Commit
8348f0b
ยท
verified ยท
1 Parent(s): 6b9c53e

Sync from GitHub via hub-sync

Browse files
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(run_session_id=int(run_row[0]), route_id=route_id)
 
 
 
 
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
- # Heart-rate must stay watch-local unless explicitly required.
142
- state.current_heart_rate = None
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 auth, community, runs, stats, watch_auth, watch_routes, watch_run
 
 
 
 
 
 
 
 
 
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
+ }