pymite6941 commited on
Commit
6cc9547
·
verified ·
1 Parent(s): 965e7b0

Deploy full Fitness AI Agents backend: auth, DB, watch sync, GPS routes, AI analysis

Browse files
backend/auth.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import jwt
3
+ from jwt import PyJWKClient
4
+ from fastapi import Depends, HTTPException, Security
5
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
6
+
7
+ bearer = HTTPBearer()
8
+
9
+ CLERK_JWKS_URL = os.getenv("CLERK_JWKS_URL")
10
+ CLERK_ISSUER_URL = os.getenv("CLERK_ISSUER_URL", "") # e.g. https://pretty-bird-74.clerk.accounts.dev
11
+
12
+ _jwks_client: PyJWKClient | None = None
13
+
14
+
15
+ def get_jwks_client() -> PyJWKClient:
16
+ global _jwks_client
17
+ if _jwks_client is None:
18
+ _jwks_client = PyJWKClient(CLERK_JWKS_URL, cache_keys=True)
19
+ return _jwks_client
20
+
21
+
22
+ async def verify_token(credentials: HTTPAuthorizationCredentials = Security(bearer)) -> dict:
23
+ token = credentials.credentials
24
+ try:
25
+ signing_key = get_jwks_client().get_signing_key_from_jwt(token)
26
+ decode_kwargs: dict = {
27
+ "algorithms": ["RS256"],
28
+ "options": {"verify_aud": False},
29
+ }
30
+ if CLERK_ISSUER_URL:
31
+ decode_kwargs["issuer"] = CLERK_ISSUER_URL
32
+ payload = jwt.decode(token, signing_key.key, **decode_kwargs)
33
+ return payload
34
+ except jwt.ExpiredSignatureError:
35
+ raise HTTPException(status_code=401, detail="Token expired")
36
+ except jwt.InvalidTokenError as e:
37
+ raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
38
+
39
+
40
+ def get_user_id(payload: dict = Depends(verify_token)) -> str:
41
+ user_id = payload.get("sub")
42
+ if not user_id:
43
+ raise HTTPException(status_code=401, detail="No user ID in token")
44
+ return user_id
backend/bots.py CHANGED
@@ -1,26 +1,42 @@
1
- from crewai import Agent, Crew, Task, Process, LLM
2
- from crewai_tools import FileReadTool
 
 
 
 
 
 
 
 
 
3
  import os
 
4
  import time as _time
 
 
 
5
  from threading import Lock
6
- from typing import Optional, Literal
 
 
 
 
 
7
  from pydantic import BaseModel
8
 
9
- import re as _re
10
- import json as _json
 
11
  import litellm
12
- litellm.cache = None # Disable response caching
13
- litellm.drop_params = True # Silently drop unsupported params per provider
14
 
 
15
  # Groq rejects messages that contain a 'cache_breakpoint' property.
16
- # Two-layer fix:
17
- # 1. Force caching=False so litellm's client wrapper never injects cache_breakpoint
18
- # into messages internally (happens when CrewAI passes caching=True in kwargs).
19
- # 2. Strip cache_breakpoint from any message that already has it (belt + suspenders).
20
  _real_completion = litellm.completion
21
 
22
  def _completion_no_cache_breakpoint(*args, **kwargs):
23
- kwargs["caching"] = False # prevents litellm wrapper from re-injecting cache_breakpoint
24
  for msg in kwargs.get("messages", []):
25
  if isinstance(msg, dict):
26
  msg.pop("cache_breakpoint", None)
@@ -33,53 +49,41 @@ def _completion_no_cache_breakpoint(*args, **kwargs):
33
  litellm.completion = _completion_no_cache_breakpoint
34
 
35
  # ── Model rotation pools ──────────────────────────────────────────────────────
36
- # Tier 1 (Groq): 14,400 req/day free, fast, tried first.
37
- # Tier 2 (OpenRouter :free): shared global rate limits — deep fallback only.
38
  _FAST_MODELS = [
39
- # ── Tier 1: Groq ──────────────────────────────────────────────────────────
40
- "groq/llama-3.1-8b-instant", # Groq — primary
41
- "groq/gemma2-9b-it", # Groq
42
- "groq/llama3-8b-8192", # Groq — stable fallback
43
- # ── Tier 2: OpenRouter free fallback ──────────────────────────────────────
44
- "openrouter/nvidia/nemotron-nano-9b-v2:free", # NVIDIA
45
- "openrouter/minimax/minimax-m2.5:free", # OpenInference
46
- "openrouter/meta-llama/llama-3.1-8b-instruct:free", # Meta/Lepton
47
- "openrouter/mistralai/mistral-7b-instruct:free", # Mistral
48
- "openrouter/google/gemma-3-12b-it:free", # Google
49
- "openrouter/qwen/qwen3-8b:free", # Qwen small
50
- "openrouter/meta-llama/llama-4-scout:free", # Meta Llama 4
51
- "openrouter/microsoft/phi-3-mini-128k-instruct:free", # Microsoft
52
  ]
53
  _SMART_MODELS = [
54
- # ── Tier 1: Groq ──────────────────────────────────────────────────────────
55
- "groq/llama-3.3-70b-versatile", # Groq — best tool use
56
- "groq/llama3-70b-8192", # Groq — stable 70b fallback
57
- # ── Tier 2: OpenRouter free fallback ──────────────────────────────────────
58
- "openrouter/google/gemma-3-27b-it:free", # Google
59
- "openrouter/qwen/qwen3-coder:free", # Qwen
60
- "openrouter/meta-llama/llama-3.3-70b-instruct:free", # Meta
61
- "openrouter/deepseek/deepseek-chat-v3-0324:free", # DeepSeek
62
- "openrouter/meta-llama/llama-4-maverick:free", # Meta Llama 4
63
- "openrouter/mistralai/mistral-small-3.1-24b-instruct:free", # Mistral
64
- "openrouter/google/gemma-3-12b-it:free", # Google smaller
65
  ]
66
 
67
-
68
- # ── Module-level cooldown state (persists across requests) ───────────────────
69
- # Groq models share one org-wide TPM quota — rate-limiting one limits all.
70
  _GROQ_MODELS_ALL: frozenset = frozenset(
71
  m for m in _FAST_MODELS + _SMART_MODELS if m.startswith("groq/")
72
  )
73
- _cooldown: dict[str, float] = {} # model → monotonic timestamp when available again
74
  _cooldown_lock = Lock()
75
-
76
- # Serialize concurrent HTTP requests so they don't collide on shared Groq TPM.
77
- # Without this, two simultaneous requests each consume ~12000 TPM and both fail.
78
  _crew_lock = Lock()
79
 
80
 
81
  def _set_cooldown(model: str, seconds: float) -> None:
82
- """Mark model unavailable. Groq models cool together (shared org TPM quota)."""
83
  until = _time.monotonic() + seconds
84
  with _cooldown_lock:
85
  if model.startswith("groq/"):
@@ -90,22 +94,16 @@ def _set_cooldown(model: str, seconds: float) -> None:
90
 
91
 
92
  def _pick_model(pool: list[str]) -> tuple[str, int]:
93
- """Return (model, index) of the highest-priority model not in cooldown.
94
- Always scans from index 0 so higher-priority models (Groq) are preferred
95
- the moment their cooldown expires. Falls back to soonest-available if all
96
- are still cooling."""
97
  now = _time.monotonic()
98
  with _cooldown_lock:
99
  for idx, model in enumerate(pool):
100
  if _cooldown.get(model, 0.0) <= now:
101
  return model, idx
102
- # All cooling — return the one whose cooldown expires soonest
103
  best = min(range(len(pool)), key=lambda i: _cooldown.get(pool[i], 0.0))
104
  return pool[best], best
105
 
106
 
107
  def _wait_until_available() -> None:
108
- """Sleep until at least one model in each pool is ready. No-op if already ready."""
109
  now = _time.monotonic()
110
  with _cooldown_lock:
111
  fast_waits = [max(0.0, _cooldown.get(m, 0.0) - now) for m in _FAST_MODELS]
@@ -117,12 +115,6 @@ def _wait_until_available() -> None:
117
 
118
 
119
  def _parse_retry_after(err_str: str) -> float:
120
- """Extract retry delay from provider error string, default 35s.
121
-
122
- Handles two formats:
123
- - OpenRouter: retry_after_seconds: 30
124
- - Groq: Please try again in 2.3s.
125
- """
126
  m = _re.search(r"retry_after_seconds['\"\s:]+(\d+(?:\.\d+)?)", err_str)
127
  if m:
128
  return float(m.group(1)) + 5
@@ -133,26 +125,14 @@ def _parse_retry_after(err_str: str) -> float:
133
 
134
 
135
  def _extract_json(text: str) -> str:
136
- """
137
- Robustly extract a JSON object from LLM output.
138
-
139
- LLMs often wrap output in markdown fences (```json...```) despite instructions.
140
- This strips fences first, then uses brace-counting to find the first complete
141
- JSON object — avoiding greedy regex that matches across multiple objects.
142
- """
143
- # Strip markdown fences
144
  text = _re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=_re.MULTILINE)
145
  text = _re.sub(r"\s*```$", "", text.strip(), flags=_re.MULTILINE)
146
  text = text.strip()
147
-
148
- # Try parsing the cleaned text directly
149
  try:
150
  _json.loads(text)
151
  return text
152
  except _json.JSONDecodeError:
153
  pass
154
-
155
- # Walk characters counting braces to find the first balanced JSON object
156
  start = text.find("{")
157
  if start != -1:
158
  depth = 0
@@ -175,26 +155,21 @@ def _extract_json(text: str) -> str:
175
  elif ch == "}":
176
  depth -= 1
177
  if depth == 0:
178
- candidate = text[start : i + 1]
179
  try:
180
  _json.loads(candidate)
181
  return candidate
182
  except _json.JSONDecodeError:
183
  break
184
-
185
  return text
186
 
187
 
188
- # LiteLLM reads provider keys from environment automatically for known prefixes.
189
- # Mirror the OpenRouter key to OPENAI_API_KEY so LiteLLM internal paths that
190
- # fall back to OpenAI don't error when no OpenAI key is set.
191
  _OR_KEY = os.getenv("OPENROUTER_API_KEY", "")
192
  if _OR_KEY:
193
  os.environ.setdefault("OPENAI_API_KEY", _OR_KEY)
194
 
195
 
196
  def _api_key_for(model: str) -> str | None:
197
- """Return the correct API key for a given model string."""
198
  if model.startswith("groq/"):
199
  return os.getenv("GROQ_API_KEY")
200
  return os.getenv("OPENROUTER_API_KEY")
@@ -206,59 +181,103 @@ class DataPoint(BaseModel):
206
  label: str
207
  value: float
208
  category: Optional[str] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
 
211
  class FormattedOutput(BaseModel):
212
  """
213
- Strict output contract for the output_formatter agent.
214
-
215
- RULES:
216
- - output_type = "chart" when the findings contain numerical data that can
217
- be meaningfully compared across discrete categories or over time.
218
- Examples: revenue by product, sessions by user, requests by endpoint,
219
- errors by day.
220
- - output_type = "report" when the findings are qualitative, narrative,
221
- or mixed no clear numerical comparison is possible.
222
-
223
- CHART TYPE RULES (only when output_type = "chart"):
224
- - chart_type = "bar" → comparing discrete categories (products, regions,
225
- users, endpoints). Most common choice.
226
- - chart_type = "line" → trend over time (daily counts, weekly averages).
227
- Use when labels are dates or sequential time periods.
228
- - chart_type = "pie" → parts of a whole where values sum to a meaningful
229
- total and there are 2-6 categories. Do NOT use for more than 6 slices.
230
-
231
- DATA RULES:
232
- - data_points must be populated whenever output_type = "chart".
233
- - label: the category name (short, no line breaks).
234
- - value: the primary numeric metric (revenue, count, duration, etc.).
235
- - category: optional secondary grouping (e.g. "Hardware", "Software").
236
- - x_axis_label: what the labels represent (e.g. "Product", "Date", "Region").
237
- - y_axis_label: what the values represent (e.g. "Revenue ($)", "Sessions").
238
-
239
- REPORT FIELDS (always required):
240
- - summary: 2-3 sentences answering the user's original question directly.
241
- - findings: exactly 3-5 bullet-ready strings, each a concrete fact from data.
242
- - recommendations: exactly 2-3 actionable strings based solely on the data.
 
243
  """
244
- output_type: Literal["chart", "report"]
245
- chart_type: Optional[Literal["bar", "line", "pie"]] = None
 
246
  chart_title: Optional[str] = None
247
  x_axis_label: Optional[str] = None
248
  y_axis_label: Optional[str] = None
249
  data_points: Optional[list[DataPoint]] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  summary: str
251
  findings: list[str]
252
  recommendations: list[str]
 
 
253
 
254
 
255
- # ── Bots ──────────────────────────────────────────────────────────────────────
256
 
257
  class Bots:
258
  def __init__(self, context: str):
259
  self.context = context
260
- # Escape braces so CrewAI's .format() interpolation doesn't treat user
261
- # text like "{something}" as a template variable and raise KeyError.
262
  self._ctx = context.replace("{", "{{").replace("}", "}}")
263
  self._fast_idx = 0
264
  self._smart_idx = 0
@@ -266,8 +285,6 @@ class Bots:
266
 
267
  def _smart_llm(self, temperature: float) -> LLM:
268
  model = _SMART_MODELS[self._smart_idx % len(_SMART_MODELS)]
269
- # 1024 keeps total TPM per call under Groq's 12000/min limit when
270
- # input is large (file contents + long prompt chain).
271
  return LLM(
272
  model=model,
273
  api_key=_api_key_for(model),
@@ -277,12 +294,12 @@ class Bots:
277
  temperature=temperature,
278
  )
279
 
280
- def _fast_llm(self, temperature: float) -> LLM:
281
  model = _FAST_MODELS[self._fast_idx % len(_FAST_MODELS)]
282
  return LLM(
283
  model=model,
284
  api_key=_api_key_for(model),
285
- max_tokens=1024,
286
  max_retries=0,
287
  timeout=120,
288
  temperature=temperature,
@@ -294,15 +311,11 @@ class Bots:
294
  goal=(
295
  "Read the user's raw context and rewrite it as a precise, unambiguous "
296
  "analysis directive. Identify the core question, the most relevant columns "
297
- "or metrics, and the exact type of analysis needed (trend, comparison, "
298
- "anomaly, summary, correlation). Output only the directive — nothing else."
299
  ),
300
  backstory=(
301
- "You are an expert at translating vague or freeform requests into sharp, "
302
- "actionable instructions for data analysts. You have a talent for identifying "
303
- "what someone actually wants to know versus what they literally said. "
304
- "You never perform analysis yourself — your only job is to make the analyst's "
305
- "directive so clear that there is no room for misinterpretation."
306
  ),
307
  tools=[],
308
  verbose=True,
@@ -312,20 +325,35 @@ class Bots:
312
  cache=False,
313
  )
314
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  self.prompt_engineer = Agent(
316
  role="Data Analysis Prompt Engineer",
317
  goal=(
318
- "Take the raw FastAPI input and the analysis directive, then construct a "
319
- "precise, step-by-step analysis prompt for the data analyst. The prompt must "
320
- "specify exactly which columns to examine, what calculations to run, what "
321
- "patterns to look for, and in what order to approach the analysis."
322
  ),
323
  backstory=(
324
- "You are a specialist in writing technical prompts for data analysis pipelines. "
325
- "You understand how LLM-based analysts think and know that vague instructions "
326
- "produce vague results. You break every analysis job into clear, ordered steps "
327
- "with explicit column names, metric names, and success criteria. You never "
328
- "perform the analysis yourself — you only write the instruction that makes it happen."
329
  ),
330
  tools=[],
331
  verbose=True,
@@ -338,17 +366,12 @@ class Bots:
338
  self.data_analyst = Agent(
339
  role="Senior Data Analyst",
340
  goal=(
341
- "Follow the analysis prompt exactly. If a file path is provided, call FileReadTool "
342
- "ONCE to read the file, then reason over its contents to answer the prompt. "
343
- "If no file is provided, reason from the context and prompt alone. "
344
- "Never speculate beyond what the data or context shows."
345
  ),
346
  backstory=(
347
- "You are a rigorous data analyst with experience across many domains and file formats. "
348
- "You call FileReadTool exactly once per task — re-reading the same file wastes tokens "
349
- "and produces no new information. After reading, you reason directly over the content. "
350
- "When no file is provided, you produce a thorough analytical response from context alone. "
351
- "You back every finding with evidence from the data."
352
  ),
353
  tools=[self.file_read],
354
  verbose=True,
@@ -362,22 +385,30 @@ class Bots:
362
  self.output_formatter = Agent(
363
  role="Structured Output Specialist",
364
  goal=(
365
- "Convert the analyst's findings into a strict FormattedOutput JSON object. "
366
- "Decide output_type based on one rule: if there are numerical values that can "
367
- "be meaningfully compared across 2 or more categories, use 'chart'. Otherwise "
368
- "use 'report'. Never invent data — only use what the analyst found."
369
  ),
370
  backstory=(
371
- "You are an expert in structured data serialization. You always output valid "
372
- "JSON matching the FormattedOutput schema exactly no extra keys, no missing "
373
- "required fields, no markdown fences around the JSON. "
374
- "Your chart selection rules are strict: "
375
- "bar for category comparisons, line for time-series trends, pie only for "
376
- "2-6 slices that sum to a whole. You populate data_points with the actual "
377
- "numbers from the analyst's report, converted to floats. "
378
- "You always write summary in plain English (2-3 sentences), "
379
- "findings as 3-5 specific factual strings, and recommendations as 2-3 "
380
- "actionable strings. You never add commentary outside the JSON object."
 
 
 
 
 
 
 
 
 
 
381
  ),
382
  tools=[],
383
  verbose=True,
@@ -387,64 +418,89 @@ class Bots:
387
  cache=False,
388
  )
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  def create_tasks(self):
391
  self.interpret_task = Task(
392
  description=(
393
- f"The user has provided this context about what they want analyzed:\n\n"
394
- f"CONTEXT: {self._ctx}\n\n"
395
- "Rewrite this into a structured analysis directive by answering these four questions:\n"
396
- "1. What is the single core question to answer?\n"
397
- "2. Which columns or metrics are most relevant to that question?\n"
398
- "3. What analysis type is needed — trend over time, comparison between groups, "
399
- "anomaly detection, statistical summary, or correlation?\n"
400
- "4. Are there any constraints or focus areas implied by the context "
401
- "(e.g. a date range, a specific user, a threshold)?\n\n"
402
- "Write the final directive as 3-5 plain sentences addressed directly to a data analyst."
403
  ),
404
  expected_output=(
405
- "A single block of 3-5 plain sentences. No headers, no bullet points, no preamble. "
406
- "Written as a direct instruction to a data analyst. Must specify: the question to answer, "
407
- "the relevant columns, the analysis type, and any constraints."
408
  ),
409
  agent=self.context_agent,
410
  )
411
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  self.prompt_task = Task(
413
  description=(
414
- f"You have received the original user request and an analysis directive.\n\n"
415
- f"ORIGINAL REQUEST: {self._ctx}\n\n"
416
- "Using the directive from the previous step, write a precise step-by-step "
417
- "analysis prompt for the data analyst. Your prompt must include:\n"
418
- "1. The exact columns to load and examine\n"
419
- "2. The specific calculations or aggregations to run\n"
420
- "3. What patterns, outliers, or trends to look for\n"
421
- "4. The order in which to approach the analysis\n"
422
- "5. What a complete, correct answer looks like"
423
  ),
424
  expected_output=(
425
- "A numbered step-by-step prompt addressed to a data analyst. "
426
- "Each step must be specific and actionable — no vague instructions. "
427
- "Must reference exact column names or metric types where possible."
428
  ),
429
- context=[self.interpret_task],
430
  agent=self.prompt_engineer,
431
  )
432
 
433
  self.analyze_task = Task(
434
  description=(
435
- "You have been given a step-by-step analysis prompt from the previous task.\n\n"
436
- "Dataset path: {data}\n\n"
437
- "If {data} is not empty, use FileReadTool to read the file contents.\n\n"
438
- "If {data} is empty, answer based on the analysis prompt using your knowledge "
439
- "and reasoning state clearly that no file was provided.\n\n"
440
- "Follow every step in the prompt exactly. Report only what the data shows."
441
  ),
442
  expected_output=(
443
- "A thorough data analysis report containing:\n"
444
- "1. Data source summary (file type, rows found, or 'no file provided')\n"
445
- "2. Key statistics relevant to the prompt (averages, ranges, counts, outliers)\n"
446
- "3. 3-5 concrete findings that directly answer the prompt\n"
447
- "4. 2-3 actionable recommendations based on the findings"
448
  ),
449
  context=[self.prompt_task],
450
  agent=self.data_analyst,
@@ -452,40 +508,53 @@ class Bots:
452
 
453
  self.format_task = Task(
454
  description=(
455
- f"Convert the analyst's findings into a FormattedOutput JSON object.\n\n"
456
- f"ORIGINAL USER REQUEST: {self._ctx}\n\n"
457
- "DECISION RULE output_type:\n"
458
- " 'chart' if the findings contain at least 2 data points with distinct numerical "
459
- "values that can be meaningfully compared (revenue by product, sessions by user, "
460
- "requests by endpoint, errors by day, etc.).\n"
461
- " 'report' for qualitative, narrative, or text-heavy findings.\n\n"
462
- "CHART TYPE SELECTION:\n"
463
- " bar comparing named categories (products, regions, users, endpoints)\n"
464
- " line → trend over sequential time periods (days, weeks, months)\n"
465
- " pie → parts of a whole, 2-6 categories only\n\n"
466
- "OUTPUT REQUIREMENTS:\n"
467
- "- Return ONLY the raw JSON object — no markdown fences, no commentary.\n"
468
- "- data_points: extract actual numbers from the analyst's report as floats.\n"
469
- "- summary: 2-3 sentences answering the original request directly.\n"
470
- "- findings: 3-5 specific factual strings (not bullet points, just the text).\n"
471
- "- recommendations: 2-3 actionable strings.\n"
472
- "- chart_title, x_axis_label, y_axis_label: short, descriptive strings.\n"
473
- "- If output_type is 'report', set chart_type, chart_title, x_axis_label, "
474
- "y_axis_label, and data_points to null."
475
  ),
476
  expected_output=(
477
- "A single raw JSON object matching the FormattedOutput schema. "
478
- "No markdown fences, no preamble, no trailing text. "
479
- "The JSON must be parseable by json.loads() without modification."
480
  ),
481
  context=[self.analyze_task],
482
  agent=self.output_formatter,
483
  output_pydantic=FormattedOutput,
484
  )
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  def create_crew(self, data) -> str:
487
- # Hold the lock for the entire pipeline so a second concurrent request
488
- # waits rather than hammering the same shared Groq TPM quota.
489
  with _crew_lock:
490
  return self._run_pipeline(data)
491
 
@@ -493,12 +562,7 @@ class Bots:
493
  max_attempts = (len(_FAST_MODELS) + len(_SMART_MODELS)) * 2
494
 
495
  for attempt in range(max_attempts):
496
- # Wait BEFORE picking models — ensures cooldowns are honoured
497
- # even on the very first attempt of a retry cycle.
498
  _wait_until_available()
499
-
500
- # Scan from index 0 every time so Groq (index 0) is always preferred
501
- # when its cooldown has expired. Cooldowns handle skipping, not the index.
502
  fast_model, self._fast_idx = _pick_model(_FAST_MODELS)
503
  smart_model, self._smart_idx = _pick_model(_SMART_MODELS)
504
 
@@ -506,8 +570,14 @@ class Bots:
506
  self.create_tasks()
507
 
508
  crew = Crew(
509
- agents=[self.context_agent, self.prompt_engineer, self.data_analyst, self.output_formatter],
510
- tasks=[self.interpret_task, self.prompt_task, self.analyze_task, self.format_task],
 
 
 
 
 
 
511
  process=Process.sequential,
512
  verbose=True,
513
  memory=False,
@@ -536,13 +606,32 @@ class Bots:
536
  _set_cooldown(fast_model, 60)
537
  _set_cooldown(smart_model, 60)
538
  print(f"[SERVER-ERR] fast={fast_model} smart={smart_model} → 60s cooldown")
539
-
540
- continue # _wait_until_available() fires at the top of the next iteration
541
  raise
542
 
543
- # Best case: pydantic model validated successfully
544
- if hasattr(result, "pydantic") and result.pydantic is not None:
545
- return result.pydantic.model_dump_json()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
 
547
  raw = result.raw if hasattr(result, "raw") else str(result)
548
  return _extract_json(raw)
 
 
1
+ """
2
+ DataFlow AI single-file backend.
3
+ FastAPI SSE server + CrewAI 6-agent pipeline.
4
+ """
5
+
6
+ # ── Stdlib ────────────────────────────────────────────────────────────────────
7
+ import sys
8
+ import io
9
+ import queue
10
+ import threading
11
+ import tempfile
12
  import os
13
+ import re as _re
14
  import time as _time
15
+ import asyncio
16
+ import json
17
+ import json as _json
18
  from threading import Lock
19
+ from typing import Optional, Literal, List as _List
20
+
21
+ # ── Third-party ───────────────────────────────────────────────────────────────
22
+ from dotenv import load_dotenv
23
+ load_dotenv()
24
+
25
  from pydantic import BaseModel
26
 
27
+ from crewai import Agent, Crew, Task, Process, LLM
28
+ from crewai_tools import FileReadTool
29
+
30
  import litellm
31
+ litellm.cache = None
32
+ litellm.drop_params = True
33
 
34
+ # ── Groq cache_breakpoint patch ───────────────────────────────────────────────
35
  # Groq rejects messages that contain a 'cache_breakpoint' property.
 
 
 
 
36
  _real_completion = litellm.completion
37
 
38
  def _completion_no_cache_breakpoint(*args, **kwargs):
39
+ kwargs["caching"] = False
40
  for msg in kwargs.get("messages", []):
41
  if isinstance(msg, dict):
42
  msg.pop("cache_breakpoint", None)
 
49
  litellm.completion = _completion_no_cache_breakpoint
50
 
51
  # ── Model rotation pools ──────────────────────────────────────────────────────
 
 
52
  _FAST_MODELS = [
53
+ "groq/llama-3.1-8b-instant",
54
+ "groq/gemma2-9b-it",
55
+ "groq/llama3-8b-8192",
56
+ "openrouter/nvidia/nemotron-nano-9b-v2:free",
57
+ "openrouter/minimax/minimax-m2.5:free",
58
+ "openrouter/meta-llama/llama-3.1-8b-instruct:free",
59
+ "openrouter/mistralai/mistral-7b-instruct:free",
60
+ "openrouter/google/gemma-3-12b-it:free",
61
+ "openrouter/qwen/qwen3-8b:free",
62
+ "openrouter/meta-llama/llama-4-scout:free",
63
+ "openrouter/microsoft/phi-3-mini-128k-instruct:free",
 
 
64
  ]
65
  _SMART_MODELS = [
66
+ "groq/llama-3.3-70b-versatile",
67
+ "groq/llama3-70b-8192",
68
+ "openrouter/google/gemma-3-27b-it:free",
69
+ "openrouter/qwen/qwen3-coder:free",
70
+ "openrouter/meta-llama/llama-3.3-70b-instruct:free",
71
+ "openrouter/deepseek/deepseek-chat-v3-0324:free",
72
+ "openrouter/meta-llama/llama-4-maverick:free",
73
+ "openrouter/mistralai/mistral-small-3.1-24b-instruct:free",
74
+ "openrouter/google/gemma-3-12b-it:free",
 
 
75
  ]
76
 
77
+ # ── Cooldown state ────────────────────────────────────────────────────────────
 
 
78
  _GROQ_MODELS_ALL: frozenset = frozenset(
79
  m for m in _FAST_MODELS + _SMART_MODELS if m.startswith("groq/")
80
  )
81
+ _cooldown: dict[str, float] = {}
82
  _cooldown_lock = Lock()
 
 
 
83
  _crew_lock = Lock()
84
 
85
 
86
  def _set_cooldown(model: str, seconds: float) -> None:
 
87
  until = _time.monotonic() + seconds
88
  with _cooldown_lock:
89
  if model.startswith("groq/"):
 
94
 
95
 
96
  def _pick_model(pool: list[str]) -> tuple[str, int]:
 
 
 
 
97
  now = _time.monotonic()
98
  with _cooldown_lock:
99
  for idx, model in enumerate(pool):
100
  if _cooldown.get(model, 0.0) <= now:
101
  return model, idx
 
102
  best = min(range(len(pool)), key=lambda i: _cooldown.get(pool[i], 0.0))
103
  return pool[best], best
104
 
105
 
106
  def _wait_until_available() -> None:
 
107
  now = _time.monotonic()
108
  with _cooldown_lock:
109
  fast_waits = [max(0.0, _cooldown.get(m, 0.0) - now) for m in _FAST_MODELS]
 
115
 
116
 
117
  def _parse_retry_after(err_str: str) -> float:
 
 
 
 
 
 
118
  m = _re.search(r"retry_after_seconds['\"\s:]+(\d+(?:\.\d+)?)", err_str)
119
  if m:
120
  return float(m.group(1)) + 5
 
125
 
126
 
127
  def _extract_json(text: str) -> str:
 
 
 
 
 
 
 
 
128
  text = _re.sub(r"^```(?:json)?\s*", "", text.strip(), flags=_re.MULTILINE)
129
  text = _re.sub(r"\s*```$", "", text.strip(), flags=_re.MULTILINE)
130
  text = text.strip()
 
 
131
  try:
132
  _json.loads(text)
133
  return text
134
  except _json.JSONDecodeError:
135
  pass
 
 
136
  start = text.find("{")
137
  if start != -1:
138
  depth = 0
 
155
  elif ch == "}":
156
  depth -= 1
157
  if depth == 0:
158
+ candidate = text[start: i + 1]
159
  try:
160
  _json.loads(candidate)
161
  return candidate
162
  except _json.JSONDecodeError:
163
  break
 
164
  return text
165
 
166
 
 
 
 
167
  _OR_KEY = os.getenv("OPENROUTER_API_KEY", "")
168
  if _OR_KEY:
169
  os.environ.setdefault("OPENAI_API_KEY", _OR_KEY)
170
 
171
 
172
  def _api_key_for(model: str) -> str | None:
 
173
  if model.startswith("groq/"):
174
  return os.getenv("GROQ_API_KEY")
175
  return os.getenv("OPENROUTER_API_KEY")
 
181
  label: str
182
  value: float
183
  category: Optional[str] = None
184
+ x_value: Optional[float] = None # scatter: second axis
185
+ value2: Optional[float] = None # radar: second series value
186
+
187
+
188
+ class CodeBlock(BaseModel):
189
+ language: str # python | sql | bash | r | javascript
190
+ title: str
191
+ code: str # keep under 400 chars
192
+
193
+
194
+ class MetricItem(BaseModel):
195
+ label: str
196
+ value: str # formatted string, e.g. "1,234" or "98.5%"
197
+ unit: Optional[str] = None
198
+ trend: Optional[str] = None # up | down | neutral
199
+ change: Optional[str] = None # e.g. "+12%"
200
+ context: Optional[str] = None # e.g. "vs last week"
201
+
202
+
203
+ class ComparisonRow(BaseModel):
204
+ metric: str # e.g. "Avg Revenue"
205
+ value_a: str # formatted value for entity A
206
+ value_b: str # formatted value for entity B
207
+ winner: Optional[Literal["a", "b", "tie"]] = None
208
 
209
 
210
  class FormattedOutput(BaseModel):
211
  """
212
+ OUTPUT TYPE DECISION RULES — pick the FIRST that matches:
213
+ 1. "code" → answer is or includes runnable code/queries/scripts.
214
+ Populate code_blocks (1-3 blocks).
215
+ 2. "metrics" answer is a set of KPIs or key numbers (3-8 items).
216
+ Populate metrics list.
217
+ 3. "comparison" comparing two named entities side-by-side across metrics.
218
+ Populate comparison_a_label, comparison_b_label, comparison_rows.
219
+ 4. "heatmap" data is a matrix (rows × columns) of numeric values — e.g.
220
+ a correlation matrix, time-of-day × day-of-week activity grid,
221
+ or category × category frequency table.
222
+ Populate heatmap_row_labels, heatmap_col_labels, heatmap_values.
223
+ Max 10 rows × 10 cols.
224
+ 5. "table" ranked/multi-attribute list best shown as labelled rows+columns.
225
+ Populate table_headers and table_rows (max 20 rows).
226
+ 6. "chart" 2+ numeric values that can be compared visually.
227
+ Choose chart_type:
228
+ bar → named categories
229
+ line → sequential time periods
230
+ pie → parts of a whole, 2-6 slices
231
+ scatter correlation (set x_value + value per DataPoint)
232
+ funnel → sequential stages with drop-off (conversion pipelines)
233
+ radar → multi-attribute profile comparison across dimensions;
234
+ set value for series A; set value2 + radar_b_label
235
+ if comparing two entities on the same axes.
236
+ 7. "report" → qualitative or narrative findings.
237
+
238
+ ALWAYS REQUIRED:
239
+ - summary: 2-3 sentences directly answering the original question.
240
+ - findings: 3-5 specific factual strings from the data.
241
+ - recommendations: 2-3 actionable strings.
242
+ - Set unused fields to null.
243
  """
244
+ output_type: Literal["chart", "report", "code", "table", "metrics", "comparison", "heatmap"]
245
+ # chart
246
+ chart_type: Optional[Literal["bar", "line", "pie", "scatter", "funnel", "radar"]] = None
247
  chart_title: Optional[str] = None
248
  x_axis_label: Optional[str] = None
249
  y_axis_label: Optional[str] = None
250
  data_points: Optional[list[DataPoint]] = None
251
+ radar_b_label: Optional[str] = None # label for value2 series in radar
252
+ # code
253
+ code_blocks: Optional[list[CodeBlock]] = None
254
+ # table
255
+ table_headers: Optional[list[str]] = None
256
+ table_rows: Optional[list[list[str]]] = None
257
+ # metrics
258
+ metrics: Optional[list[MetricItem]] = None
259
+ # comparison
260
+ comparison_a_label: Optional[str] = None
261
+ comparison_b_label: Optional[str] = None
262
+ comparison_rows: Optional[list[ComparisonRow]] = None
263
+ # heatmap
264
+ heatmap_title: Optional[str] = None
265
+ heatmap_row_labels: Optional[list[str]] = None
266
+ heatmap_col_labels: Optional[list[str]] = None
267
+ heatmap_values: Optional[list[list[float]]] = None # [row_idx][col_idx]
268
+ # always
269
  summary: str
270
  findings: list[str]
271
  recommendations: list[str]
272
+ quality_score: Optional[int] = None
273
+ quality_verdict: Optional[str] = None
274
 
275
 
276
+ # ── Agent pipeline ────────────────────────────────────────────────────────────
277
 
278
  class Bots:
279
  def __init__(self, context: str):
280
  self.context = context
 
 
281
  self._ctx = context.replace("{", "{{").replace("}", "}}")
282
  self._fast_idx = 0
283
  self._smart_idx = 0
 
285
 
286
  def _smart_llm(self, temperature: float) -> LLM:
287
  model = _SMART_MODELS[self._smart_idx % len(_SMART_MODELS)]
 
 
288
  return LLM(
289
  model=model,
290
  api_key=_api_key_for(model),
 
294
  temperature=temperature,
295
  )
296
 
297
+ def _fast_llm(self, temperature: float, max_tokens: int = 1024) -> LLM:
298
  model = _FAST_MODELS[self._fast_idx % len(_FAST_MODELS)]
299
  return LLM(
300
  model=model,
301
  api_key=_api_key_for(model),
302
+ max_tokens=max_tokens,
303
  max_retries=0,
304
  timeout=120,
305
  temperature=temperature,
 
311
  goal=(
312
  "Read the user's raw context and rewrite it as a precise, unambiguous "
313
  "analysis directive. Identify the core question, the most relevant columns "
314
+ "or metrics, and the exact type of analysis needed."
 
315
  ),
316
  backstory=(
317
+ "You translate vague requests into sharp, actionable instructions. "
318
+ "You never perform analysis you only clarify the directive."
 
 
 
319
  ),
320
  tools=[],
321
  verbose=True,
 
325
  cache=False,
326
  )
327
 
328
+ self.data_cleaner = Agent(
329
+ role="Data Quality Inspector",
330
+ goal=(
331
+ "Read every file provided and produce a concise data quality report: "
332
+ "column names, row count, missing values, duplicate rows, data type issues. "
333
+ "Keep under 200 words."
334
+ ),
335
+ backstory=(
336
+ "You are a meticulous data auditor. You use FileReadTool once per file, "
337
+ "then summarise its structure and flag obvious problems."
338
+ ),
339
+ tools=[self.file_read],
340
+ verbose=True,
341
+ memory=False,
342
+ max_iter=5,
343
+ llm=self._fast_llm(0.1, max_tokens=512),
344
+ allow_delegation=False,
345
+ cache=False,
346
+ )
347
+
348
  self.prompt_engineer = Agent(
349
  role="Data Analysis Prompt Engineer",
350
  goal=(
351
+ "Construct a precise, step-by-step analysis prompt for the data analyst. "
352
+ "Specify exact columns, calculations, patterns to look for, and order of steps."
 
 
353
  ),
354
  backstory=(
355
+ "You write technical prompts for data analysis pipelines. "
356
+ "Vague instructions produce vague results you are never vague."
 
 
 
357
  ),
358
  tools=[],
359
  verbose=True,
 
366
  self.data_analyst = Agent(
367
  role="Senior Data Analyst",
368
  goal=(
369
+ "Follow the analysis prompt exactly. Call FileReadTool ONCE per file path. "
370
+ "Reason over the content to answer the prompt. Never speculate beyond the data."
 
 
371
  ),
372
  backstory=(
373
+ "You are a rigorous analyst. You call FileReadTool exactly once per file "
374
+ "re-reading wastes tokens. You back every finding with evidence."
 
 
 
375
  ),
376
  tools=[self.file_read],
377
  verbose=True,
 
385
  self.output_formatter = Agent(
386
  role="Structured Output Specialist",
387
  goal=(
388
+ "Convert analyst findings into a strict FormattedOutput JSON object. "
389
+ "Choose output_type by priority: code metrics comparison heatmap table → chart → report."
 
 
390
  ),
391
  backstory=(
392
+ "You serialise analysis results into one of 7 output modes:\n"
393
+ " code runnable scripts, queries, or algorithms\n"
394
+ " metrics — key numbers / KPIs (3-8 items)\n"
395
+ " comparison two named entities compared across metrics\n"
396
+ " heatmap — matrix of values (correlation, frequency, activity)\n"
397
+ " table — ranked/multi-attribute list (max 20 rows)\n"
398
+ " chart — bar, line, pie, scatter, funnel, or radar\n"
399
+ " report — qualitative or narrative findings\n\n"
400
+ "CHART TYPE SELECTION:\n"
401
+ " funnel sequential conversion stages with drop-off\n"
402
+ " radar → multi-attribute profile (use value2+radar_b_label for dual series)\n"
403
+ " scatter → correlation (x_value + value per point)\n"
404
+ " pie → 2-6 parts of a whole\n"
405
+ " line → time series\n"
406
+ " bar → named categories\n\n"
407
+ "COMPARISON: comparison_a_label and comparison_b_label name the two entities. "
408
+ "Each comparison_row has metric, value_a, value_b, and winner (a/b/tie).\n\n"
409
+ "HEATMAP: heatmap_values is a 2D list [row][col] of floats. Max 10×10.\n\n"
410
+ "Output ONLY the raw JSON object. No markdown fences, no preamble. "
411
+ "summary=2-3 sentences, findings=3-5 strings, recommendations=2-3 strings."
412
  ),
413
  tools=[],
414
  verbose=True,
 
418
  cache=False,
419
  )
420
 
421
+ self.qa_critic = Agent(
422
+ role="Analysis Quality Critic",
423
+ goal=(
424
+ "Rate how well the analysis answered the original question. "
425
+ 'Output ONLY: {"score": <int 1-10>, "verdict": "<1-2 sentences>"}'
426
+ ),
427
+ backstory=(
428
+ "You review analyses for completeness, specificity, and evidence quality. "
429
+ "Score 10 = every aspect answered with data. Score <5 = question not answered. "
430
+ "Output ONLY the raw JSON — no markdown, no preamble."
431
+ ),
432
+ tools=[],
433
+ verbose=True,
434
+ memory=False,
435
+ llm=self._fast_llm(0.2, max_tokens=512),
436
+ allow_delegation=False,
437
+ cache=False,
438
+ )
439
+
440
  def create_tasks(self):
441
  self.interpret_task = Task(
442
  description=(
443
+ f"The user wants: {self._ctx}\n\n"
444
+ "Rewrite this into a structured analysis directive:\n"
445
+ "1. The single core question to answer\n"
446
+ "2. Relevant columns/metrics\n"
447
+ "3. Analysis type (trend, comparison, anomaly, summary, correlation)\n"
448
+ "4. Any constraints (date range, thresholds, focus areas)\n\n"
449
+ "Write 3-5 plain sentences addressed directly to a data analyst."
 
 
 
450
  ),
451
  expected_output=(
452
+ "3-5 plain sentences. No headers, no bullets. "
453
+ "Direct instruction specifying: the question, relevant columns, analysis type, constraints."
 
454
  ),
455
  agent=self.context_agent,
456
  )
457
 
458
+ self.clean_task = Task(
459
+ description=(
460
+ "Dataset path(s):\n{data}\n\n"
461
+ "If {data} is not '(no file)', use FileReadTool to read each path once.\n"
462
+ "Report per file: type/size, column names, row count, missing values, "
463
+ "obvious issues, 2 sample records.\n"
464
+ "If no file: report 'No file provided — analysis uses context only.'\n"
465
+ "Keep under 200 words."
466
+ ),
467
+ expected_output="Concise data quality report under 200 words. Plain prose or bullets. No JSON.",
468
+ context=[self.interpret_task],
469
+ agent=self.data_cleaner,
470
+ )
471
+
472
  self.prompt_task = Task(
473
  description=(
474
+ f"Original request: {self._ctx}\n\n"
475
+ "Using the directive and data quality report, write a step-by-step analysis prompt:\n"
476
+ "1. Exact columns to load\n"
477
+ "2. Calculations/aggregations to run\n"
478
+ "3. Patterns, outliers, or trends to look for\n"
479
+ "4. Order of approach\n"
480
+ "5. What a complete answer looks like"
 
 
481
  ),
482
  expected_output=(
483
+ "Numbered step-by-step prompt. Each step specific and actionable. "
484
+ "References exact column names where possible."
 
485
  ),
486
+ context=[self.interpret_task, self.clean_task],
487
  agent=self.prompt_engineer,
488
  )
489
 
490
  self.analyze_task = Task(
491
  description=(
492
+ "Dataset path(s):\n{data}\n\n"
493
+ "If file paths are provided (one per line), read each with FileReadTool exactly once. "
494
+ "If multiple files, analyze together. "
495
+ "If no file, answer from the analysis prompt using reasoning.\n\n"
496
+ "Follow every step in the prompt. Read each file only once. Report only what the data shows."
 
497
  ),
498
  expected_output=(
499
+ "Thorough analysis containing:\n"
500
+ "1. Data source summary\n"
501
+ "2. Key statistics (averages, ranges, counts, outliers)\n"
502
+ "3. 3-5 concrete findings that answer the prompt\n"
503
+ "4. 2-3 actionable recommendations"
504
  ),
505
  context=[self.prompt_task],
506
  agent=self.data_analyst,
 
508
 
509
  self.format_task = Task(
510
  description=(
511
+ f"Original request: {self._ctx}\n\n"
512
+ "Convert the analyst's findings into a FormattedOutput JSON object.\n\n"
513
+ "OUTPUT TYPE PRIORITY (pick first that fits):\n"
514
+ " 'code' answer is or includes runnable code/queries/scripts\n"
515
+ " 'metrics' → answer is a set of KPIs or key numbers (3-8 items)\n"
516
+ " 'comparison' comparing two named entities across multiple metrics;\n"
517
+ " set comparison_a_label, comparison_b_label, comparison_rows\n"
518
+ " (each row: metric, value_a, value_b, winner='a'/'b'/'tie')\n"
519
+ " 'heatmap' data is a matrix (rows × cols) of numeric values;\n"
520
+ " set heatmap_row_labels, heatmap_col_labels,\n"
521
+ " heatmap_values (2D float list), heatmap_title. Max 10×10.\n"
522
+ " 'table' → ranked/multi-attribute list, max 20 rows\n"
523
+ " 'chart' → visual comparison of 2+ values; chart_type options:\n"
524
+ " bar, line, pie, scatter, funnel, radar\n"
525
+ " For funnel: stages in order, value = count/rate at each stage\n"
526
+ " For radar: label=axis, value=series A; optionally value2=series B\n"
527
+ " and set radar_b_label for B's name\n"
528
+ " 'report' → qualitative/narrative findings\n\n"
529
+ "ALWAYS: summary (2-3 sentences), findings (3-5 strings), recommendations (2-3 strings).\n"
530
+ "Return ONLY the raw JSON object. No markdown, no commentary."
531
  ),
532
  expected_output=(
533
+ "Single raw JSON object matching FormattedOutput. "
534
+ "No markdown fences. Parseable by json.loads() without modification."
 
535
  ),
536
  context=[self.analyze_task],
537
  agent=self.output_formatter,
538
  output_pydantic=FormattedOutput,
539
  )
540
 
541
+ self.qa_task = Task(
542
+ description=(
543
+ f"Original request: {self._ctx}\n\n"
544
+ "Review the completed analysis. Score 1-10 based on:\n"
545
+ "- Did it directly and specifically answer the original question?\n"
546
+ "- Are findings backed by concrete numbers from the data?\n"
547
+ "- Are recommendations actionable and relevant?\n"
548
+ "- Is anything important missing, vague, or invented?\n\n"
549
+ "Return ONLY: "
550
+ '{"score": <int 1-10>, "verdict": "<1-2 sentences: what was done well and what gap remains>"}'
551
+ ),
552
+ expected_output='Raw JSON only: {"score": <int>, "verdict": "<string>"}. No markdown.',
553
+ context=[self.analyze_task, self.format_task],
554
+ agent=self.qa_critic,
555
+ )
556
+
557
  def create_crew(self, data) -> str:
 
 
558
  with _crew_lock:
559
  return self._run_pipeline(data)
560
 
 
562
  max_attempts = (len(_FAST_MODELS) + len(_SMART_MODELS)) * 2
563
 
564
  for attempt in range(max_attempts):
 
 
565
  _wait_until_available()
 
 
 
566
  fast_model, self._fast_idx = _pick_model(_FAST_MODELS)
567
  smart_model, self._smart_idx = _pick_model(_SMART_MODELS)
568
 
 
570
  self.create_tasks()
571
 
572
  crew = Crew(
573
+ agents=[
574
+ self.context_agent, self.data_cleaner, self.prompt_engineer,
575
+ self.data_analyst, self.output_formatter, self.qa_critic,
576
+ ],
577
+ tasks=[
578
+ self.interpret_task, self.clean_task, self.prompt_task,
579
+ self.analyze_task, self.format_task, self.qa_task,
580
+ ],
581
  process=Process.sequential,
582
  verbose=True,
583
  memory=False,
 
606
  _set_cooldown(fast_model, 60)
607
  _set_cooldown(smart_model, 60)
608
  print(f"[SERVER-ERR] fast={fast_model} smart={smart_model} → 60s cooldown")
609
+ continue
 
610
  raise
611
 
612
+ fmt_task_out = crew.tasks[4].output if len(crew.tasks) > 4 else None
613
+ formatted = None
614
+ if fmt_task_out:
615
+ if getattr(fmt_task_out, "pydantic", None):
616
+ formatted = fmt_task_out.pydantic
617
+ else:
618
+ try:
619
+ formatted = FormattedOutput(
620
+ **_json.loads(_extract_json(fmt_task_out.raw or ""))
621
+ )
622
+ except Exception:
623
+ pass
624
+
625
+ if formatted:
626
+ qa_raw = result.raw if hasattr(result, "raw") else str(result)
627
+ try:
628
+ qa = _json.loads(_extract_json(qa_raw))
629
+ formatted.quality_score = int(qa.get("score", 0)) or None
630
+ formatted.quality_verdict = qa.get("verdict")
631
+ except Exception:
632
+ pass
633
+ return formatted.model_dump_json()
634
 
635
  raw = result.raw if hasattr(result, "raw") else str(result)
636
  return _extract_json(raw)
637
+
backend/calories.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Server-side calorie estimation.
2
+
3
+ Keeps the watch firmware thin: the device no longer hardcodes a body weight or
4
+ runs MET math (the ESP32-C3 has no FPU). The watch sends duration + workout type
5
+ and the backend estimates the burn here, in one place.
6
+
7
+ Formula: calories ≈ MET × weight_kg × hours. MET ("metabolic equivalent") values
8
+ are moderate-intensity averages from the Compendium of Physical Activities.
9
+ """
10
+ from typing import Optional
11
+
12
+ # TODO: replace with the authenticated user's real weight once a profile exists.
13
+ DEFAULT_WEIGHT_KG = 70.0
14
+
15
+ # MET by activity. Keys are lowercased; the watch currently reports "running".
16
+ _MET = {
17
+ "running": 9.8,
18
+ "run": 9.8,
19
+ "jogging": 7.0,
20
+ "walking": 3.5,
21
+ "walk": 3.5,
22
+ "hiking": 6.0,
23
+ "cycling": 7.5,
24
+ "bike": 7.5,
25
+ "biking": 7.5,
26
+ "swimming": 8.0,
27
+ "rowing": 7.0,
28
+ "elliptical": 5.0,
29
+ "weightlifting": 6.0,
30
+ "strength": 6.0,
31
+ "workout": 7.0,
32
+ }
33
+ _DEFAULT_MET = 7.0
34
+
35
+
36
+ def met_for(workout_type: Optional[str]) -> float:
37
+ if not workout_type:
38
+ return _DEFAULT_MET
39
+ return _MET.get(workout_type.strip().lower(), _DEFAULT_MET)
40
+
41
+
42
+ def estimate_calories(duration_minutes: float,
43
+ workout_type: Optional[str] = None,
44
+ weight_kg: float = DEFAULT_WEIGHT_KG) -> float:
45
+ """Estimate kcal burned for a workout, rounded to 1 decimal place."""
46
+ if not duration_minutes or duration_minutes <= 0:
47
+ return 0.0
48
+ return round(met_for(workout_type) * weight_kg * (duration_minutes / 60.0), 1)
backend/db.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from supabase import acreate_client, AsyncClient
3
+
4
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
5
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY")
6
+
7
+ _client: AsyncClient | None = None
8
+
9
+
10
+ async def get_db() -> AsyncClient:
11
+ global _client
12
+ if _client is None:
13
+ _client = await acreate_client(SUPABASE_URL, SUPABASE_KEY)
14
+ return _client
backend/main.py CHANGED
@@ -1,201 +1,33 @@
1
- import sys
2
- import io
3
- import queue
4
- import threading
5
- import tempfile
6
- import os
7
- import re
8
- import time
9
- import asyncio
10
- import json
11
  from dotenv import load_dotenv
12
  load_dotenv()
13
 
14
- from fastapi import FastAPI, UploadFile, File, Form
 
15
  from fastapi.middleware.cors import CORSMiddleware
16
- from fastapi.responses import StreamingResponse, JSONResponse
17
- from bots import Bots
 
18
 
19
- app = FastAPI()
 
 
 
20
 
21
  app.add_middleware(
22
  CORSMiddleware,
23
- allow_origins=["*"],
 
24
  allow_methods=["*"],
25
  allow_headers=["*"],
26
  )
27
 
28
- ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
29
- BOX_CHARS = re.compile(r"[╭╮╰╯│╞╡╢╟╔╗╚╝╬═─┼┤├┬┴┌└┐┘╠╣╦╧╨╩╪╫]")
30
-
31
- MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
32
- MAX_CONTEXT_LEN = 2000 # chars
33
- MAX_RUNTIME = 600 # seconds (10 min total pipeline timeout)
34
- ALLOWED_EXTS = {".csv", ".json", ".txt", ".pdf", ".xml"}
35
-
36
-
37
- class LineCapture(io.TextIOBase):
38
- """Captures stdout/stderr line-by-line into a queue, stripping ANSI codes."""
39
-
40
- def __init__(self, q: queue.Queue):
41
- self._q = q
42
- self._buf = ""
43
-
44
- def write(self, text: str) -> int:
45
- cleaned = ANSI_ESCAPE.sub("", text)
46
- cleaned = BOX_CHARS.sub("", cleaned)
47
- self._buf += cleaned
48
- while "\n" in self._buf:
49
- line, self._buf = self._buf.split("\n", 1)
50
- stripped = line.strip()
51
- if stripped:
52
- self._q.put(stripped)
53
- return len(text)
54
-
55
- def flush(self):
56
- if self._buf.strip():
57
- self._q.put(self._buf.strip())
58
- self._buf = ""
59
-
60
 
61
  @app.get("/health")
62
  async def health():
63
  return {"status": "ok"}
64
-
65
-
66
- @app.post("/analyze")
67
- async def analyze(context: str = Form(...), file: UploadFile = File(None)):
68
- # ── Input validation (before opening the SSE stream) ──────────────────────
69
- context = context.strip()
70
- if not context:
71
- return JSONResponse({"error": "Context is required."}, status_code=400)
72
- if len(context) > MAX_CONTEXT_LEN:
73
- return JSONResponse(
74
- {"error": f"Context too long ({len(context)} chars, max {MAX_CONTEXT_LEN})."},
75
- status_code=400,
76
- )
77
-
78
- file_content = None
79
- file_suffix = ".csv"
80
- if file and file.filename:
81
- ext = os.path.splitext(file.filename)[1].lower()
82
- if ext not in ALLOWED_EXTS:
83
- return JSONResponse(
84
- {"error": f"File type '{ext}' not supported. Allowed: {', '.join(sorted(ALLOWED_EXTS))}"},
85
- status_code=400,
86
- )
87
- file_content = await file.read()
88
- if len(file_content) > MAX_FILE_SIZE:
89
- return JSONResponse(
90
- {"error": f"File too large ({len(file_content) // 1024}KB, max {MAX_FILE_SIZE // 1024 // 1024}MB)."},
91
- status_code=400,
92
- )
93
- file_suffix = ext
94
-
95
- # ── SSE stream ────────────────────────────────────────────────────────────
96
- async def event_stream():
97
- q: queue.Queue = queue.Queue()
98
-
99
- def run_crew():
100
- old_stdout, old_stderr = sys.stdout, sys.stderr
101
- capture = LineCapture(q)
102
- sys.stdout = capture
103
- sys.stderr = capture
104
- data_path = None
105
- try:
106
- if file_content:
107
- with tempfile.NamedTemporaryFile(
108
- delete=False, suffix=file_suffix
109
- ) as tmp:
110
- tmp.write(file_content)
111
- data_path = tmp.name
112
-
113
- bots = Bots(context)
114
- result = bots.create_crew(data_path or "")
115
- if result:
116
- q.put({"__result__": result})
117
- except Exception as exc:
118
- # Flush any buffered partial output before the error
119
- capture.flush()
120
- import traceback
121
- q.put(f"[ERROR] {exc}")
122
- for line in traceback.format_exc().splitlines():
123
- if line.strip():
124
- q.put(f"[TRACE] {line}")
125
- finally:
126
- sys.stdout = old_stdout
127
- sys.stderr = old_stderr
128
- if data_path and os.path.exists(data_path):
129
- os.unlink(data_path)
130
- q.put(None)
131
-
132
- thread = threading.Thread(target=run_crew, daemon=True)
133
- thread.start()
134
-
135
- # CrewAI internal noise that clutters the stream during rotation/retry.
136
- # We surface our own [ROTATE] / [RETRY] lines instead.
137
- _NOISE = (
138
- "ERROR:root:",
139
- "ERROR:crewai.",
140
- "[CrewAIEventsBus]",
141
- "An unknown error occurred. Please check",
142
- "Error details: Error code:",
143
- "Error details: Model ",
144
- # CrewAI event ordering warnings (box chars are stripped in LineCapture)
145
- "'agent_execution_started'",
146
- "Tracing Preference Saved",
147
- "Tracing has been disabled",
148
- "Your preference has been saved",
149
- "To enable tracing later",
150
- "Set tracing=True",
151
- "Set CREWAI_TRACING_ENABLED",
152
- "Run: crewai traces",
153
- "[Finalize]",
154
- "[TRACE]",
155
- "✨ Update Available",
156
- "collect traces.",
157
- "New version of crewai",
158
- "Run `pip install",
159
- "pip install --upgrade",
160
- "All providers rate-limited", # litellm internal retry message
161
- "Auto-retrying in", # litellm internal retry message
162
- "[RETRY]", # litellm router retry prefix
163
- )
164
-
165
- loop = asyncio.get_running_loop()
166
- deadline = time.monotonic() + MAX_RUNTIME
167
-
168
- while True:
169
- # Overall pipeline timeout guard
170
- remaining = deadline - time.monotonic()
171
- if remaining <= 0:
172
- yield f"data: {json.dumps('[ERROR] Analysis timed out after 10 minutes.')}\n\n"
173
- yield f"data: {json.dumps('__DONE__')}\n\n"
174
- break
175
-
176
- try:
177
- item = await loop.run_in_executor(
178
- None, lambda: q.get(timeout=min(30, remaining))
179
- )
180
- except queue.Empty:
181
- # Heartbeat keeps Cloudflare proxy from closing idle connection
182
- yield ": ping\n\n"
183
- continue
184
-
185
- # Drop internal CrewAI error noise during rotation; keep our own messages
186
- if isinstance(item, str) and any(item.startswith(p) or p in item for p in _NOISE):
187
- continue
188
-
189
- if item is None:
190
- yield f"data: {json.dumps('__DONE__')}\n\n"
191
- break
192
- if isinstance(item, dict) and "__result__" in item:
193
- yield f"data: {json.dumps({'type': 'result', 'content': item['__result__']})}\n\n"
194
- else:
195
- yield f"data: {json.dumps(item)}\n\n"
196
-
197
- return StreamingResponse(
198
- event_stream(),
199
- media_type="text/event-stream",
200
- headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
201
- )
 
 
 
 
 
 
 
 
 
 
 
1
  from dotenv import load_dotenv
2
  load_dotenv()
3
 
4
+ import os
5
+ from fastapi import FastAPI
6
  from fastapi.middleware.cors import CORSMiddleware
7
+ from routes import watch, analysis, user, charts, gps, integrations
8
+
9
+ app = FastAPI(title="Fitness AI Agents")
10
 
11
+ _ALLOWED_ORIGINS = [o.strip() for o in os.getenv(
12
+ "ALLOWED_ORIGINS",
13
+ "http://localhost:5173,http://localhost:4173",
14
+ ).split(",") if o.strip()]
15
 
16
  app.add_middleware(
17
  CORSMiddleware,
18
+ allow_origins=_ALLOWED_ORIGINS,
19
+ allow_credentials=True,
20
  allow_methods=["*"],
21
  allow_headers=["*"],
22
  )
23
 
24
+ app.include_router(watch.router, prefix="/watch", tags=["Watch"])
25
+ app.include_router(analysis.router, prefix="/analyze", tags=["Analysis"])
26
+ app.include_router(user.router, prefix="/user", tags=["User"])
27
+ app.include_router(charts.router, prefix="/charts", tags=["Charts"])
28
+ app.include_router(gps.router, prefix="/routes", tags=["GPS"])
29
+ app.include_router(integrations.router, prefix="/integrations", tags=["Integrations"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  @app.get("/health")
32
  async def health():
33
  return {"status": "ok"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/models/__init__.py ADDED
File without changes
backend/models/analysis.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class AnalysisRequest(BaseModel):
7
+ context: str # what the user wants to know
8
+ date_from: Optional[datetime] = None
9
+ date_to: Optional[datetime] = None
10
+
11
+
12
+ class AnalysisResult(BaseModel):
13
+ user_id: str
14
+ context: str
15
+ summary: str
16
+ key_findings: list[str]
17
+ anomalies: list[str]
18
+ recommendations: list[str]
19
+ created_at: datetime = datetime.utcnow()
backend/models/watch.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel
2
+ from typing import Optional
3
+ from datetime import datetime
4
+
5
+
6
+ class WatchReading(BaseModel):
7
+ timestamp: datetime
8
+ heart_rate: Optional[float] = None
9
+ steps: Optional[int] = None
10
+ calories_burned: Optional[float] = None
11
+ active_calories: Optional[float] = None
12
+ distance_meters: Optional[float] = None
13
+ sleep_hours: Optional[float] = None
14
+ spo2: Optional[float] = None # blood oxygen %
15
+ hrv: Optional[float] = None # heart rate variability
16
+
17
+
18
+ class WorkoutSession(BaseModel):
19
+ timestamp: datetime
20
+ workout_type: str # running, cycling, weightlifting, etc.
21
+ duration_minutes: float
22
+ avg_heart_rate: Optional[float] = None
23
+ max_heart_rate: Optional[float] = None
24
+ ending_heart_rate: Optional[float] = None # BPM at end/cool-down
25
+ calories_burned: Optional[float] = None
26
+ distance_meters: Optional[float] = None
27
+ notes: Optional[str] = None
28
+
29
+
30
+ class WatchSyncPayload(BaseModel):
31
+ readings: list[WatchReading] = []
32
+ workouts: list[WorkoutSession] = []
33
+ device: Optional[str] = None # "apple_watch", "garmin", "fitbit", etc.
34
+ app_version: Optional[str] = None
backend/requirements.txt CHANGED
@@ -1,7 +1,11 @@
1
- fastapi
2
- uvicorn[standard]
3
- python-dotenv
4
- crewai
5
- crewai-tools
6
- pydantic
7
- # build-v2
 
 
 
 
 
1
+ fastapi==0.136.1
2
+ uvicorn==0.47.0
3
+ python-dotenv==1.2.2
4
+ python-multipart==0.0.20
5
+ httpx==0.28.1
6
+ PyJWT==2.12.1
7
+ supabase==2.30.1
8
+ crewai==1.14.5
9
+ crewai-tools==1.14.5
10
+ pandas==3.0.3
11
+ fastembed==0.8.0
backend/routes/__init__.py ADDED
File without changes
backend/routes/analysis.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import csv
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from fastapi import APIRouter, Depends, HTTPException
7
+ from datetime import datetime, timezone
8
+ from auth import get_user_id
9
+ from db import get_db
10
+ from models.analysis import AnalysisRequest
11
+ from bots import Bots
12
+
13
+ router = APIRouter()
14
+
15
+ # Columns from the routes table that are meaningful for analysis.
16
+ # Excludes coordinates (raw lat/lng blob), user_id, and id.
17
+ _ROUTE_COLUMNS = [
18
+ "started_at", "ended_at", "workout_type",
19
+ "distance_meters", "duration_seconds", "pace", "calories_burned", "notes",
20
+ ]
21
+
22
+
23
+ def _write_csv(rows: list[dict], columns: list[str]) -> str:
24
+ """Write rows to a temp CSV (only the given columns) and return the path."""
25
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False, newline="") as f:
26
+ writer = csv.DictWriter(f, fieldnames=columns, extrasaction="ignore")
27
+ writer.writeheader()
28
+ writer.writerows(rows)
29
+ return f.name
30
+
31
+
32
+ @router.post("/")
33
+ async def run_analysis(request: AnalysisRequest, user_id: str = Depends(get_user_id)):
34
+ """Pull the user's watch data + routes, run the AI crew, store and return the result."""
35
+ db = await get_db()
36
+
37
+ # ── Watch readings + workouts ──────────────────────────────────────────
38
+ wd_query = db.table("watch_data").select("*").eq("user_id", user_id)
39
+ if request.date_from:
40
+ wd_query = wd_query.gte("timestamp", request.date_from.isoformat())
41
+ if request.date_to:
42
+ wd_query = wd_query.lte("timestamp", request.date_to.isoformat())
43
+
44
+ wd_result = await wd_query.order("timestamp").execute()
45
+ wd_rows = wd_result.data
46
+
47
+ if not wd_rows:
48
+ raise HTTPException(status_code=404, detail="No watch data found for this user")
49
+
50
+ for row in wd_rows:
51
+ row.pop("user_id", None)
52
+
53
+ # ── GPS routes ─────────────────────────────────────────────────────────
54
+ rt_query = db.table("routes").select(", ".join(_ROUTE_COLUMNS)).eq("user_id", user_id)
55
+ if request.date_from:
56
+ rt_query = rt_query.gte("started_at", request.date_from.isoformat())
57
+ if request.date_to:
58
+ rt_query = rt_query.lte("started_at", request.date_to.isoformat())
59
+
60
+ rt_result = await rt_query.order("started_at").execute()
61
+ rt_rows = rt_result.data
62
+
63
+ # ── Write CSVs ─────────────────────────────────────────────────────────
64
+ tmp_watch = _write_csv(wd_rows, list(wd_rows[0].keys()))
65
+ tmp_routes = _write_csv(rt_rows, _ROUTE_COLUMNS) if rt_rows else None
66
+
67
+ # Pass both paths (newline-separated) — the crew's task descriptions already
68
+ # handle multiple files and say "If multiple files, analyze together."
69
+ data_input = tmp_watch
70
+ if tmp_routes:
71
+ data_input = f"{tmp_watch}\n{tmp_routes}"
72
+
73
+ try:
74
+ bots = Bots(context=request.context)
75
+ loop = asyncio.get_running_loop()
76
+ raw_json = await loop.run_in_executor(None, lambda: bots.create_crew(data=data_input))
77
+ except Exception as e:
78
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
79
+ finally:
80
+ if os.path.exists(tmp_watch):
81
+ os.unlink(tmp_watch)
82
+ if tmp_routes and os.path.exists(tmp_routes):
83
+ os.unlink(tmp_routes)
84
+
85
+ # Parse the structured FormattedOutput returned by bots.create_crew()
86
+ parsed: dict = {}
87
+ try:
88
+ parsed = json.loads(raw_json)
89
+ except Exception:
90
+ parsed = {}
91
+
92
+ record = {
93
+ "user_id": user_id,
94
+ "context": request.context,
95
+ "summary": parsed.get("summary") or raw_json,
96
+ "key_findings": parsed.get("findings") or [],
97
+ "recommendations": parsed.get("recommendations") or [],
98
+ "output_type": parsed.get("output_type"),
99
+ "chart_type": parsed.get("chart_type"),
100
+ "chart_title": parsed.get("chart_title"),
101
+ "data_points": parsed.get("data_points"),
102
+ "metrics": parsed.get("metrics"),
103
+ "table_headers": parsed.get("table_headers"),
104
+ "table_rows": parsed.get("table_rows"),
105
+ "quality_score": parsed.get("quality_score"),
106
+ "quality_verdict": parsed.get("quality_verdict"),
107
+ "raw_output": parsed or None, # full payload — lets frontend render comparison/heatmap/code
108
+ "created_at": datetime.now(timezone.utc).isoformat(),
109
+ }
110
+
111
+ # output_type must always be present so the frontend can pick the right renderer
112
+ if not record.get("output_type"):
113
+ record["output_type"] = "report"
114
+
115
+ # Strip remaining None values so Supabase doesn't complain about unknown columns
116
+ record = {k: v for k, v in record.items() if v is not None}
117
+
118
+ await db.table("analyses").insert(record).execute()
119
+ return record
backend/routes/charts.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from auth import get_user_id
3
+ from db import get_db
4
+ from collections import defaultdict
5
+ from datetime import datetime
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ def date_key(iso: str) -> str:
11
+ return iso[:10] if iso else ""
12
+
13
+
14
+ @router.get("/")
15
+ async def get_chart_data(user_id: str = Depends(get_user_id)):
16
+ db = await get_db()
17
+
18
+ workouts_res = await (
19
+ db.table("watch_data")
20
+ .select("*")
21
+ .eq("user_id", user_id)
22
+ .eq("type", "workout")
23
+ .order("timestamp")
24
+ .execute()
25
+ )
26
+ readings_res = await (
27
+ db.table("watch_data")
28
+ .select("timestamp,heart_rate,steps,calories_burned,sleep_hours,spo2,hrv")
29
+ .eq("user_id", user_id)
30
+ .eq("type", "reading")
31
+ .order("timestamp")
32
+ .execute()
33
+ )
34
+
35
+ workouts = workouts_res.data or []
36
+ readings = readings_res.data or []
37
+
38
+ # calories burned per day (workouts)
39
+ cal_by_day: dict = defaultdict(float)
40
+ for w in workouts:
41
+ if w.get("calories_burned"):
42
+ cal_by_day[date_key(w["timestamp"])] += w["calories_burned"]
43
+
44
+ # avg heart rate per day (readings)
45
+ hr_by_day: dict = defaultdict(list)
46
+ for r in readings:
47
+ if r.get("heart_rate"):
48
+ hr_by_day[date_key(r["timestamp"])].append(r["heart_rate"])
49
+ hr_avg_by_day = {d: round(sum(v) / len(v), 1) for d, v in hr_by_day.items()}
50
+
51
+ # steps per day
52
+ steps_by_day: dict = defaultdict(int)
53
+ for r in readings:
54
+ if r.get("steps"):
55
+ steps_by_day[date_key(r["timestamp"])] += r["steps"]
56
+
57
+ # sleep hours per day
58
+ sleep_by_day: dict = {}
59
+ for r in readings:
60
+ if r.get("sleep_hours"):
61
+ sleep_by_day[date_key(r["timestamp"])] = r["sleep_hours"]
62
+
63
+ # workout type breakdown
64
+ type_counts: dict = defaultdict(int)
65
+ for w in workouts:
66
+ if w.get("workout_type"):
67
+ type_counts[w["workout_type"]] += 1
68
+
69
+ # recent workouts list (last 10)
70
+ recent = sorted(workouts, key=lambda x: x.get("timestamp", ""), reverse=True)[:10]
71
+
72
+ return {
73
+ "calories": {
74
+ "labels": sorted(cal_by_day.keys()),
75
+ "values": [cal_by_day[d] for d in sorted(cal_by_day.keys())],
76
+ },
77
+ "heart_rate": {
78
+ "labels": sorted(hr_avg_by_day.keys()),
79
+ "values": [hr_avg_by_day[d] for d in sorted(hr_avg_by_day.keys())],
80
+ },
81
+ "steps": {
82
+ "labels": sorted(steps_by_day.keys()),
83
+ "values": [steps_by_day[d] for d in sorted(steps_by_day.keys())],
84
+ },
85
+ "sleep": {
86
+ "labels": sorted(sleep_by_day.keys()),
87
+ "values": [sleep_by_day[d] for d in sorted(sleep_by_day.keys())],
88
+ },
89
+ "workout_types": {
90
+ "labels": list(type_counts.keys()),
91
+ "values": list(type_counts.values()),
92
+ },
93
+ "recent_workouts": recent,
94
+ }
backend/routes/gps.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from pydantic import BaseModel
3
+ from typing import Optional
4
+ from datetime import datetime, timezone
5
+ from auth import get_user_id
6
+ from db import get_db
7
+ from calories import estimate_calories
8
+ import math
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ class Coordinate(BaseModel):
14
+ lat: float
15
+ lng: float
16
+ timestamp: str
17
+ altitude: Optional[float] = None
18
+ speed: Optional[float] = None
19
+
20
+
21
+ class RoutePayload(BaseModel):
22
+ workout_type: str
23
+ coordinates: list[Coordinate]
24
+ started_at: str
25
+ ended_at: str
26
+ # Sent by the watch so distance/pace/calories are all computed here, not on
27
+ # the device.
28
+ avg_heart_rate: Optional[float] = None
29
+ max_heart_rate: Optional[float] = None
30
+ # True when the client posts ONLY a route (no separate workout) — the watch
31
+ # and the web GPS tracker. Tells us to mirror a workout row into watch_data.
32
+ # Clients that post their own workout (manual entry, Strava/Nike/Garmin
33
+ # imports) leave this false so the workout isn't counted twice.
34
+ record_workout: bool = False
35
+ notes: Optional[str] = None
36
+
37
+
38
+ def haversine(lat1, lng1, lat2, lng2) -> float:
39
+ R = 6371000
40
+ p = math.pi / 180
41
+ a = (math.sin((lat2 - lat1) * p / 2) ** 2 +
42
+ math.cos(lat1 * p) * math.cos(lat2 * p) *
43
+ math.sin((lng2 - lng1) * p / 2) ** 2)
44
+ return 2 * R * math.asin(math.sqrt(a))
45
+
46
+
47
+ def calc_distance(coords: list[Coordinate]) -> float:
48
+ total = 0.0
49
+ for i in range(1, len(coords)):
50
+ total += haversine(coords[i-1].lat, coords[i-1].lng, coords[i].lat, coords[i].lng)
51
+ return round(total, 1)
52
+
53
+
54
+ def calc_duration(started_at: str, ended_at: str) -> int:
55
+ try:
56
+ start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
57
+ end = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
58
+ return max(0, int((end - start).total_seconds()))
59
+ except Exception:
60
+ return 0
61
+
62
+
63
+ def calc_pace(distance_m: float, duration_s: int) -> Optional[str]:
64
+ if distance_m < 1 or duration_s < 1:
65
+ return None
66
+ mins_per_km = (duration_s / 60) / (distance_m / 1000)
67
+ m = int(mins_per_km)
68
+ s = int((mins_per_km - m) * 60)
69
+ return f"{m}:{s:02d} /km"
70
+
71
+
72
+ @router.post("/")
73
+ async def save_route(payload: RoutePayload, user_id: str = Depends(get_user_id)):
74
+ """Save a completed GPS route."""
75
+ if len(payload.coordinates) < 2:
76
+ raise HTTPException(status_code=400, detail="Route needs at least 2 coordinates")
77
+
78
+ db = await get_db()
79
+ distance = calc_distance(payload.coordinates)
80
+ duration = calc_duration(payload.started_at, payload.ended_at)
81
+ pace = calc_pace(distance, duration)
82
+ duration_min = round(duration / 60.0, 2)
83
+
84
+ calories = estimate_calories(duration_min, payload.workout_type)
85
+
86
+ record = {
87
+ "user_id": user_id,
88
+ "workout_type": payload.workout_type,
89
+ "coordinates": [c.model_dump() for c in payload.coordinates],
90
+ "distance_meters": distance,
91
+ "duration_seconds": duration,
92
+ "pace": pace,
93
+ "calories_burned": calories,
94
+ "started_at": payload.started_at,
95
+ "ended_at": payload.ended_at,
96
+ "notes": payload.notes,
97
+ "created_at": datetime.now(timezone.utc).isoformat(),
98
+ }
99
+
100
+ result = await db.table("routes").insert(record).execute()
101
+ if not result.data:
102
+ raise HTTPException(status_code=500, detail="Route insert failed")
103
+
104
+ # Route-only clients (the watch, the web GPS tracker) ask us to also record
105
+ # the workout — distance, pace and calories all computed here, server-side,
106
+ # so the device doesn't have to. Clients that post their own workout leave
107
+ # record_workout false to avoid double-counting.
108
+ if payload.record_workout:
109
+ workout_row = {
110
+ "user_id": user_id,
111
+ "type": "workout",
112
+ "device": "gps_route",
113
+ "timestamp": payload.started_at,
114
+ "workout_type": payload.workout_type,
115
+ "duration_minutes": duration_min,
116
+ "avg_heart_rate": payload.avg_heart_rate,
117
+ "max_heart_rate": payload.max_heart_rate,
118
+ "calories_burned": calories,
119
+ "distance_meters": distance,
120
+ }
121
+ try:
122
+ await db.table("watch_data").insert(workout_row).execute()
123
+ except Exception as e:
124
+ # The route saved fine; don't fail the request if the mirror does.
125
+ print(f"[routes] workout mirror insert failed: {e}")
126
+
127
+ return result.data[0]
128
+
129
+
130
+ @router.get("/")
131
+ async def get_routes(user_id: str = Depends(get_user_id), limit: int = 20):
132
+ """Fetch the user's past routes, newest first."""
133
+ db = await get_db()
134
+ result = await (
135
+ db.table("routes")
136
+ .select("*")
137
+ .eq("user_id", user_id)
138
+ .order("started_at", desc=True)
139
+ .limit(limit)
140
+ .execute()
141
+ )
142
+ return {"routes": result.data}
143
+
144
+
145
+ @router.get("/{route_id}")
146
+ async def get_route(route_id: str, user_id: str = Depends(get_user_id)):
147
+ """Fetch a single route by ID."""
148
+ db = await get_db()
149
+ result = await (
150
+ db.table("routes")
151
+ .select("*")
152
+ .eq("id", route_id)
153
+ .eq("user_id", user_id)
154
+ .single()
155
+ .execute()
156
+ )
157
+ if not result.data:
158
+ raise HTTPException(status_code=404, detail="Route not found")
159
+ return result.data
backend/routes/integrations.py ADDED
@@ -0,0 +1,1186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import csv
3
+ import hashlib
4
+ import hmac
5
+ import io
6
+ import json
7
+ import math
8
+ import os
9
+ import urllib.parse
10
+ import xml.etree.ElementTree as ET
11
+ import zipfile
12
+
13
+ import httpx
14
+ from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
15
+ from fastapi.responses import RedirectResponse
16
+ from auth import get_user_id
17
+ from db import get_db
18
+ from datetime import datetime, timezone, timedelta
19
+
20
+ router = APIRouter()
21
+
22
+ STRAVA_CLIENT_ID = os.getenv("STRAVA_CLIENT_ID", "")
23
+ STRAVA_CLIENT_SECRET = os.getenv("STRAVA_CLIENT_SECRET", "")
24
+ BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000")
25
+ FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173")
26
+
27
+ _OAUTH_SECRET = os.getenv("OAUTH_STATE_SECRET", os.urandom(32).hex())
28
+
29
+ STRAVA_AUTH_URL = "https://www.strava.com/oauth/authorize"
30
+ STRAVA_TOKEN_URL = "https://www.strava.com/oauth/token"
31
+ STRAVA_ACTIVITIES = "https://www.strava.com/api/v3/athlete/activities"
32
+ STRAVA_STREAMS_URL = "https://www.strava.com/api/v3/activities/{id}/streams"
33
+
34
+ NRC_TYPE_MAP = {
35
+ "nike.run.gps": "running",
36
+ "nike.run.manual": "running",
37
+ "nike.run.treadmill": "running",
38
+ "nike.sport.walk": "walking",
39
+ "nike.sport.hike": "hiking",
40
+ "nike.sport.cycle": "cycling",
41
+ "nike.sport.swim": "swimming",
42
+ "run": "running",
43
+ "walk": "walking",
44
+ "hike": "hiking",
45
+ "cycle": "cycling",
46
+ "swim": "swimming",
47
+ }
48
+
49
+ STRAVA_TYPE_MAP = {
50
+ "Run": "running", "VirtualRun": "running",
51
+ "Ride": "cycling", "VirtualRide": "cycling", "EBikeRide": "cycling",
52
+ "Walk": "walking",
53
+ "Hike": "hiking",
54
+ "WeightTraining": "weightlifting",
55
+ "Swim": "swimming",
56
+ "Workout": "other", "Crossfit": "other", "Elliptical": "other",
57
+ "RockClimbing": "hiking", "Yoga": "other", "Pilates": "other",
58
+ }
59
+
60
+
61
+ @router.get("/status")
62
+ async def integration_status(user_id: str = Depends(get_user_id)):
63
+ db = await get_db()
64
+ result = await (
65
+ db.table("user_integrations")
66
+ .select("provider")
67
+ .eq("user_id", user_id)
68
+ .execute()
69
+ )
70
+ return {row["provider"]: True for row in (result.data or [])}
71
+
72
+
73
+ def _make_state(user_id: str) -> str:
74
+ sig = hmac.new(_OAUTH_SECRET.encode(), user_id.encode(), hashlib.sha256).hexdigest()
75
+ return f"{user_id}.{sig}"
76
+
77
+
78
+ def _verify_state(state: str) -> str | None:
79
+ """Return user_id if state is valid, else None."""
80
+ try:
81
+ user_id, sig = state.rsplit(".", 1)
82
+ except ValueError:
83
+ return None
84
+ expected = hmac.new(_OAUTH_SECRET.encode(), user_id.encode(), hashlib.sha256).hexdigest()
85
+ if hmac.compare_digest(sig, expected):
86
+ return user_id
87
+ return None
88
+
89
+
90
+ @router.get("/strava/connect")
91
+ async def strava_connect(user_id: str = Depends(get_user_id)):
92
+ callback = f"{BACKEND_URL}/integrations/strava/callback"
93
+ url = (
94
+ f"{STRAVA_AUTH_URL}"
95
+ f"?client_id={STRAVA_CLIENT_ID}"
96
+ f"&redirect_uri={callback}"
97
+ f"&response_type=code"
98
+ f"&scope=activity:read_all"
99
+ f"&state={_make_state(user_id)}"
100
+ f"&approval_prompt=auto"
101
+ )
102
+ return {"url": url}
103
+
104
+
105
+ @router.get("/strava/callback")
106
+ async def strava_callback(code: str = "", state: str = "", error: str = ""):
107
+ if error or not code:
108
+ return RedirectResponse(f"{FRONTEND_URL}/log?error={error or 'access_denied'}")
109
+
110
+ user_id = _verify_state(state)
111
+ if not user_id:
112
+ return RedirectResponse(f"{FRONTEND_URL}/log?error=invalid_state")
113
+ async with httpx.AsyncClient() as client:
114
+ resp = await client.post(STRAVA_TOKEN_URL, data={
115
+ "client_id": STRAVA_CLIENT_ID,
116
+ "client_secret": STRAVA_CLIENT_SECRET,
117
+ "code": code,
118
+ "grant_type": "authorization_code",
119
+ })
120
+ if resp.status_code != 200:
121
+ return RedirectResponse(f"{FRONTEND_URL}/log?error=token_exchange_failed")
122
+ data = resp.json()
123
+
124
+ db = await get_db()
125
+ row = {
126
+ "user_id": user_id,
127
+ "provider": "strava",
128
+ "access_token": data["access_token"],
129
+ "refresh_token": data.get("refresh_token"),
130
+ "expires_at": datetime.fromtimestamp(data["expires_at"], tz=timezone.utc).isoformat(),
131
+ "athlete_id": str(data.get("athlete", {}).get("id", "")),
132
+ "updated_at": datetime.now(timezone.utc).isoformat(),
133
+ }
134
+ await db.table("user_integrations").upsert(row, on_conflict="user_id,provider").execute()
135
+ return RedirectResponse(f"{FRONTEND_URL}/log?connected=strava")
136
+
137
+
138
+ @router.post("/strava/sync")
139
+ async def strava_sync(user_id: str = Depends(get_user_id)):
140
+ """Fetch latest Strava activities. Saves workouts to watch_data and GPS routes to routes table."""
141
+ db = await get_db()
142
+
143
+ integration = await (
144
+ db.table("user_integrations")
145
+ .select("*")
146
+ .eq("user_id", user_id)
147
+ .eq("provider", "strava")
148
+ .single()
149
+ .execute()
150
+ )
151
+ if not integration.data:
152
+ return {"synced": 0, "routes_saved": 0, "error": "Strava not connected"}
153
+
154
+ token = await _refresh_strava_token_if_needed(db, integration.data)
155
+
156
+ async with httpx.AsyncClient(timeout=30) as client:
157
+ resp = await client.get(
158
+ STRAVA_ACTIVITIES,
159
+ headers={"Authorization": f"Bearer {token}"},
160
+ params={"per_page": 30},
161
+ )
162
+ if resp.status_code != 200:
163
+ return {"synced": 0, "routes_saved": 0, "error": "Failed to fetch Strava activities"}
164
+ activities = resp.json()
165
+
166
+ workout_rows = []
167
+ route_rows = []
168
+
169
+ for a in activities:
170
+ start_date = a.get("start_date", "")
171
+ if not start_date:
172
+ continue # skip activities without a date — we can't place them in time
173
+ workout_type = STRAVA_TYPE_MAP.get(a.get("type", ""), "other")
174
+
175
+ # ── Workout record ──────────────────────────────────────────
176
+ workout_rows.append({
177
+ "user_id": user_id,
178
+ "type": "workout",
179
+ "device": "strava",
180
+ "timestamp": start_date,
181
+ "workout_type": workout_type,
182
+ "duration_minutes": round(a.get("moving_time", 0) / 60, 1),
183
+ "distance_meters": a.get("distance"),
184
+ "calories_burned": a.get("calories"),
185
+ "avg_heart_rate": a.get("average_heartrate"),
186
+ "max_heart_rate": a.get("max_heartrate"),
187
+ "ending_heart_rate": None, # not in activity summary; streams would be needed
188
+ "notes": a.get("name"),
189
+ })
190
+
191
+ # ── GPS route — only for activities that have a map ─────────
192
+ if a.get("map", {}).get("summary_polyline"):
193
+ streams_resp = await client.get(
194
+ STRAVA_STREAMS_URL.format(id=a["id"]),
195
+ headers={"Authorization": f"Bearer {token}"},
196
+ params={"keys": "latlng,time,heartrate", "key_by_type": "true"},
197
+ )
198
+ if streams_resp.status_code == 200:
199
+ streams = streams_resp.json()
200
+ latlng = streams.get("latlng", {}).get("data", [])
201
+ times = streams.get("time", {}).get("data", [])
202
+ hr_stream = streams.get("heartrate", {}).get("data", [])
203
+
204
+ if len(latlng) >= 2:
205
+ try:
206
+ start_dt = datetime.fromisoformat(start_date.replace("Z", "+00:00"))
207
+ except Exception:
208
+ continue # can't build a route without a valid start time
209
+
210
+ elapsed = a.get("elapsed_time", 0)
211
+ ended_dt = start_dt + timedelta(seconds=elapsed)
212
+
213
+ coords = []
214
+ for i, pt in enumerate(latlng):
215
+ offset_s = times[i] if i < len(times) else 0
216
+ ts = (start_dt + timedelta(seconds=offset_s)).isoformat()
217
+ c = {"lat": pt[0], "lng": pt[1], "timestamp": ts}
218
+ if i < len(hr_stream) and hr_stream[i]:
219
+ c["heart_rate"] = hr_stream[i]
220
+ coords.append(c)
221
+
222
+ # ending HR = last HR value in stream
223
+ ending_hr = None
224
+ for val in reversed(hr_stream):
225
+ if val:
226
+ ending_hr = val
227
+ break
228
+ # Back-fill ending_heart_rate onto the workout row
229
+ workout_rows[-1]["ending_heart_rate"] = ending_hr
230
+
231
+ route_rows.append({
232
+ "user_id": user_id,
233
+ "workout_type": workout_type,
234
+ "coordinates": coords,
235
+ "distance_meters": a.get("distance"),
236
+ "duration_seconds": a.get("moving_time"),
237
+ "started_at": start_date,
238
+ "ended_at": ended_dt.isoformat(),
239
+ "notes": a.get("name"),
240
+ })
241
+
242
+ if workout_rows:
243
+ await db.table("watch_data").insert(workout_rows).execute()
244
+ if route_rows:
245
+ await db.table("routes").insert(route_rows).execute()
246
+
247
+ return {
248
+ "synced": len(workout_rows),
249
+ "routes_saved": len(route_rows),
250
+ }
251
+
252
+
253
+ @router.post("/nike/import")
254
+ async def nike_import(
255
+ file: UploadFile = File(...),
256
+ user_id: str = Depends(get_user_id),
257
+ ):
258
+ """Parse a Nike Run Club data-export ZIP or JSON and save activities to watch_data."""
259
+ content = await file.read()
260
+ activities: list[dict] = []
261
+
262
+ filename = (file.filename or "").lower()
263
+ if filename.endswith(".zip"):
264
+ try:
265
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
266
+ for name in zf.namelist():
267
+ if not name.lower().endswith(".json"):
268
+ continue
269
+ with zf.open(name) as f:
270
+ try:
271
+ data = json.load(f)
272
+ if isinstance(data, list):
273
+ activities.extend(data)
274
+ elif isinstance(data, dict) and ("startEpochMs" in data or "type" in data):
275
+ activities.append(data)
276
+ except Exception:
277
+ pass
278
+ except zipfile.BadZipFile:
279
+ return {"error": "Invalid ZIP file", "imported": 0, "routes_saved": 0}
280
+ else:
281
+ try:
282
+ data = json.loads(content)
283
+ activities = data if isinstance(data, list) else [data]
284
+ except Exception:
285
+ return {"error": "Invalid JSON file", "imported": 0, "routes_saved": 0}
286
+
287
+ workout_rows: list[dict] = []
288
+ route_rows: list[dict] = []
289
+
290
+ for a in activities:
291
+ # ── Timestamp ─────────────────────────────────────────────────────
292
+ start_ms = a.get("startEpochMs") or a.get("start_epoch_ms")
293
+ if start_ms:
294
+ start_dt = datetime.fromtimestamp(start_ms / 1000, tz=timezone.utc)
295
+ else:
296
+ continue # skip entries with no timestamp — we can't place them in time
297
+
298
+ # ── Duration ──────────────────────────────────────────────────────
299
+ active_ms = a.get("activeTime") or a.get("active_duration_ms") or 0
300
+ duration_min = round(active_ms / 60000, 1) if active_ms else None
301
+
302
+ # ── Activity type ─────────────────────────────────────────────────
303
+ workout_type = NRC_TYPE_MAP.get(a.get("type", "").lower(), "other")
304
+
305
+ # ── Tags (NRC stores extra fields here) ───────────────────────────
306
+ tags = a.get("tags", {})
307
+
308
+ def _tag(key: str):
309
+ return tags.get(key) or tags.get(f"com.nike.{key}")
310
+
311
+ # ── Distance ──────────────────────────────────────────────────────
312
+ distance_m = None
313
+ dist_raw = _tag("distance") or a.get("distance")
314
+ if dist_raw is not None:
315
+ try:
316
+ if isinstance(dist_raw, dict):
317
+ val = float(dist_raw.get("value", 0))
318
+ unit = dist_raw.get("unit", "KM").upper()
319
+ distance_m = round(val * 1000 if unit in ("KM", "KILOMETERS") else val * 1609.34, 1)
320
+ else:
321
+ distance_m = round(float(dist_raw) * 1000, 1) # NRC tags are in km
322
+ except (ValueError, TypeError):
323
+ pass
324
+
325
+ # ── Calories ──────────────────────────────────────────────────────
326
+ calories = None
327
+ cal_raw = _tag("calories") or a.get("calories")
328
+ if cal_raw is not None:
329
+ try:
330
+ calories = float(cal_raw.get("value", 0) if isinstance(cal_raw, dict) else cal_raw)
331
+ except (ValueError, TypeError):
332
+ pass
333
+
334
+ # ── Heart rate + GPS from metrics ─────────────────────────────────
335
+ avg_hr = max_hr = ending_hr = None
336
+ lat_entries: list[dict] = []
337
+ lng_entries: list[dict] = []
338
+
339
+ for metric in a.get("metrics", []):
340
+ mtype = metric.get("type", "").upper()
341
+ values = metric.get("values", [])
342
+ if not values:
343
+ continue
344
+
345
+ if mtype == "HEART_RATE":
346
+ hr_vals = [v["value"] for v in values if v.get("value") is not None]
347
+ if hr_vals:
348
+ avg_hr = round(sum(hr_vals) / len(hr_vals))
349
+ max_hr = int(max(hr_vals))
350
+ ending_hr = int(hr_vals[-1])
351
+ elif mtype == "LATITUDE":
352
+ lat_entries = values
353
+ elif mtype == "LONGITUDE":
354
+ lng_entries = values
355
+
356
+ # Also check summaries block for distance/calories if still missing
357
+ for s in a.get("summaries", []):
358
+ stype = s.get("metric", "").upper()
359
+ val = s.get("value")
360
+ if val is None:
361
+ continue
362
+ if stype == "DISTANCE" and distance_m is None:
363
+ distance_m = round(float(val) * 1000, 1)
364
+ elif stype == "CALORIES" and calories is None:
365
+ calories = float(val)
366
+
367
+ # ── Workout row ───────────────────────────────────────────────────
368
+ workout_rows.append({
369
+ "user_id": user_id,
370
+ "type": "workout",
371
+ "device": "nike_run_club",
372
+ "timestamp": start_dt.isoformat(),
373
+ "workout_type": workout_type,
374
+ "duration_minutes": duration_min,
375
+ "distance_meters": distance_m,
376
+ "calories_burned": calories,
377
+ "avg_heart_rate": avg_hr,
378
+ "max_heart_rate": max_hr,
379
+ "ending_heart_rate": ending_hr,
380
+ "notes": _tag("name") or a.get("name") or a.get("title"),
381
+ })
382
+
383
+ # ── GPS route ─────────────────────────────────────────────────────
384
+ if len(lat_entries) >= 2 and len(lng_entries) >= 2:
385
+ coords = []
386
+ for i, lat_entry in enumerate(lat_entries):
387
+ if i >= len(lng_entries):
388
+ break
389
+ ts_ms = lat_entry.get("startEpochMs") or start_ms
390
+ coords.append({
391
+ "lat": lat_entry.get("value"),
392
+ "lng": lng_entries[i].get("value"),
393
+ "timestamp": datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat(),
394
+ })
395
+ if len(coords) >= 2:
396
+ ended_dt = start_dt + timedelta(milliseconds=active_ms or 0)
397
+ route_rows.append({
398
+ "user_id": user_id,
399
+ "workout_type": workout_type,
400
+ "coordinates": coords,
401
+ "distance_meters": distance_m,
402
+ "duration_seconds": active_ms // 1000 if active_ms else None,
403
+ "started_at": start_dt.isoformat(),
404
+ "ended_at": ended_dt.isoformat(),
405
+ "notes": _tag("name") or a.get("name"),
406
+ })
407
+
408
+ db = await get_db()
409
+ if workout_rows:
410
+ await db.table("watch_data").insert(workout_rows).execute()
411
+ if route_rows:
412
+ await db.table("routes").insert(route_rows).execute()
413
+
414
+ return {"imported": len(workout_rows), "routes_saved": len(route_rows)}
415
+
416
+
417
+ async def _refresh_strava_token_if_needed(db, integration: dict) -> str:
418
+ expires_at = datetime.fromisoformat(integration["expires_at"])
419
+ if datetime.now(timezone.utc) < expires_at:
420
+ return integration["access_token"]
421
+
422
+ async with httpx.AsyncClient() as client:
423
+ resp = await client.post(STRAVA_TOKEN_URL, data={
424
+ "client_id": STRAVA_CLIENT_ID,
425
+ "client_secret": STRAVA_CLIENT_SECRET,
426
+ "refresh_token": integration["refresh_token"],
427
+ "grant_type": "refresh_token",
428
+ })
429
+
430
+ if resp.status_code != 200:
431
+ raise HTTPException(status_code=502, detail="Failed to refresh Strava token")
432
+
433
+ data = resp.json()
434
+ if "access_token" not in data:
435
+ raise HTTPException(status_code=502, detail="Strava token response missing access_token")
436
+
437
+ await (
438
+ db.table("user_integrations")
439
+ .update({
440
+ "access_token": data["access_token"],
441
+ "refresh_token": data.get("refresh_token", integration["refresh_token"]),
442
+ "expires_at": datetime.fromtimestamp(data["expires_at"], tz=timezone.utc).isoformat(),
443
+ "updated_at": datetime.now(timezone.utc).isoformat(),
444
+ })
445
+ .eq("id", integration["id"])
446
+ .execute()
447
+ )
448
+ return data["access_token"]
449
+
450
+
451
+ # ── Shared helpers ────────────────────────────────────────────────────────────
452
+
453
+ def _haversine(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
454
+ R = 6371000
455
+ p = math.pi / 180
456
+ a = (math.sin((lat2 - lat1) * p / 2) ** 2 +
457
+ math.cos(lat1 * p) * math.cos(lat2 * p) *
458
+ math.sin((lng2 - lng1) * p / 2) ** 2)
459
+ return 2 * R * math.asin(math.sqrt(max(0.0, min(1.0, a))))
460
+
461
+
462
+ def _classify_activity(name: str) -> str:
463
+ n = name.lower()
464
+ if any(w in n for w in ("run", "jog")): return "running"
465
+ if any(w in n for w in ("cycl", "bike", "rid")): return "cycling"
466
+ if any(w in n for w in ("walk",)): return "walking"
467
+ if any(w in n for w in ("hike", "trail")): return "hiking"
468
+ if any(w in n for w in ("swim",)): return "swimming"
469
+ if any(w in n for w in ("weight", "strength", "lift", "gym")): return "weightlifting"
470
+ return "other"
471
+
472
+
473
+ def _strip_ns(root: ET.Element) -> ET.Element:
474
+ """Remove XML namespace prefixes so we can find tags by local name."""
475
+ for el in root.iter():
476
+ if "}" in el.tag:
477
+ el.tag = el.tag.split("}", 1)[1]
478
+ return root
479
+
480
+
481
+ def _parse_apple_date(s: str) -> datetime:
482
+ s = s.strip()
483
+ for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%z"):
484
+ try:
485
+ return datetime.strptime(s, fmt)
486
+ except ValueError:
487
+ pass
488
+ raise ValueError(f"Cannot parse Apple Health date: {s!r}")
489
+
490
+
491
+ # ── Google Health API (Fitbit wearable data via Google OAuth) ─────────────────
492
+ # Fitbit Web API was deprecated; new apps register at Google Cloud Console.
493
+ # Docs: https://developers.google.com/health/api
494
+
495
+ GOOGLE_HEALTH_CLIENT_ID = os.getenv("GOOGLE_HEALTH_CLIENT_ID", "")
496
+ GOOGLE_HEALTH_CLIENT_SECRET = os.getenv("GOOGLE_HEALTH_CLIENT_SECRET", "")
497
+ GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
498
+ GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
499
+ GOOGLE_HEALTH_BASE = "https://health.googleapis.com/v4"
500
+
501
+ GOOGLE_HEALTH_SCOPES = " ".join([
502
+ "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly",
503
+ "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly",
504
+ "https://www.googleapis.com/auth/googlehealth.sleep.readonly",
505
+ ])
506
+
507
+ GOOGLE_EXERCISE_TYPE_MAP = {
508
+ "RUNNING": "running",
509
+ "WALKING": "walking",
510
+ "BIKING": "cycling",
511
+ "SWIMMING": "swimming",
512
+ "HIKING": "hiking",
513
+ "YOGA": "other",
514
+ "PILATES": "other",
515
+ "WORKOUT": "other",
516
+ "HIIT": "other",
517
+ "WEIGHTLIFTING": "weightlifting",
518
+ "STRENGTH_TRAINING": "weightlifting",
519
+ "OTHER": "other",
520
+ }
521
+
522
+
523
+ def _parse_google_time(t) -> str | None:
524
+ """Accept RFC 3339 string or proto Timestamp dict {seconds, nanos}."""
525
+ if isinstance(t, str):
526
+ return t.replace("Z", "+00:00")
527
+ if isinstance(t, dict):
528
+ secs = t.get("seconds") or t.get("epochSeconds")
529
+ if secs:
530
+ return datetime.fromtimestamp(int(secs), tz=timezone.utc).isoformat()
531
+ return None
532
+
533
+
534
+ @router.get("/fitbit/connect")
535
+ async def fitbit_connect(user_id: str = Depends(get_user_id)):
536
+ callback = f"{BACKEND_URL}/integrations/fitbit/callback"
537
+ url = (
538
+ f"{GOOGLE_AUTH_URL}"
539
+ f"?client_id={GOOGLE_HEALTH_CLIENT_ID}"
540
+ f"&response_type=code"
541
+ f"&scope={urllib.parse.quote(GOOGLE_HEALTH_SCOPES)}"
542
+ f"&redirect_uri={urllib.parse.quote(callback, safe='')}"
543
+ f"&state={_make_state(user_id)}"
544
+ f"&access_type=offline"
545
+ f"&prompt=consent"
546
+ )
547
+ return {"url": url}
548
+
549
+
550
+ @router.get("/fitbit/callback")
551
+ async def fitbit_callback(code: str = "", state: str = "", error: str = ""):
552
+ if error or not code:
553
+ return RedirectResponse(f"{FRONTEND_URL}/log?error={error or 'access_denied'}")
554
+ user_id = _verify_state(state)
555
+ if not user_id:
556
+ return RedirectResponse(f"{FRONTEND_URL}/log?error=invalid_state")
557
+
558
+ callback = f"{BACKEND_URL}/integrations/fitbit/callback"
559
+ async with httpx.AsyncClient() as client:
560
+ resp = await client.post(GOOGLE_TOKEN_URL, data={
561
+ "code": code,
562
+ "grant_type": "authorization_code",
563
+ "redirect_uri": callback,
564
+ "client_id": GOOGLE_HEALTH_CLIENT_ID,
565
+ "client_secret": GOOGLE_HEALTH_CLIENT_SECRET,
566
+ })
567
+ if resp.status_code != 200:
568
+ return RedirectResponse(f"{FRONTEND_URL}/log?error=token_exchange_failed")
569
+ data = resp.json()
570
+
571
+ db = await get_db()
572
+ await db.table("user_integrations").upsert({
573
+ "user_id": user_id,
574
+ "provider": "fitbit",
575
+ "access_token": data["access_token"],
576
+ "refresh_token": data.get("refresh_token"),
577
+ "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 3600))).isoformat(),
578
+ "updated_at": datetime.now(timezone.utc).isoformat(),
579
+ }, on_conflict="user_id,provider").execute()
580
+ return RedirectResponse(f"{FRONTEND_URL}/log?connected=fitbit")
581
+
582
+
583
+ @router.post("/fitbit/sync")
584
+ async def fitbit_sync(user_id: str = Depends(get_user_id)):
585
+ db = await get_db()
586
+ integration = await (
587
+ db.table("user_integrations").select("*")
588
+ .eq("user_id", user_id).eq("provider", "fitbit").single().execute()
589
+ )
590
+ if not integration.data:
591
+ return {"synced": 0, "error": "Fitbit not connected"}
592
+
593
+ token = await _refresh_fitbit_token_if_needed(db, integration.data)
594
+ after = (datetime.now(timezone.utc) - timedelta(days=90)).strftime("%Y-%m-%dT%H:%M:%SZ")
595
+ headers = {"Authorization": f"Bearer {token}"}
596
+
597
+ async with httpx.AsyncClient(timeout=30) as client:
598
+ ex_resp = await client.get(
599
+ f"{GOOGLE_HEALTH_BASE}/users/me/dataTypes/exercise/dataPoints",
600
+ headers=headers,
601
+ params={"filter": f'exercise.interval.startTime >= "{after}"', "pageSize": 100},
602
+ )
603
+ sl_resp = await client.get(
604
+ f"{GOOGLE_HEALTH_BASE}/users/me/dataTypes/sleep/dataPoints",
605
+ headers=headers,
606
+ params={"filter": f'sleep.interval.startTime >= "{after}"', "pageSize": 100},
607
+ )
608
+
609
+ exercises = ex_resp.json().get("dataPoints", []) if ex_resp.status_code == 200 else []
610
+ sleeps = sl_resp.json().get("dataPoints", []) if sl_resp.status_code == 200 else []
611
+
612
+ rows: list[dict] = []
613
+
614
+ for pt in exercises:
615
+ ex = (pt.get("data") or {}).get("exercise", {})
616
+ ivl = ex.get("interval", {})
617
+ start = _parse_google_time(ivl.get("startTime"))
618
+ if not start:
619
+ continue
620
+ try:
621
+ start_dt = datetime.fromisoformat(start)
622
+ except ValueError:
623
+ continue
624
+
625
+ dur_min = None
626
+ active = ex.get("activeDuration", "")
627
+ if isinstance(active, str) and active.endswith("s"):
628
+ try:
629
+ dur_min = round(float(active[:-1]) / 60, 1)
630
+ except ValueError:
631
+ pass
632
+ if dur_min is None:
633
+ end_str = _parse_google_time(ivl.get("endTime"))
634
+ if end_str:
635
+ try:
636
+ dur_min = round((datetime.fromisoformat(end_str) - start_dt).total_seconds() / 60, 1)
637
+ except ValueError:
638
+ pass
639
+
640
+ metrics = ex.get("metricsSummary", {})
641
+ dist_mm = metrics.get("distanceMillimeters")
642
+ rows.append({
643
+ "user_id": user_id,
644
+ "type": "workout",
645
+ "device": "fitbit",
646
+ "timestamp": start_dt.isoformat(),
647
+ "workout_type": GOOGLE_EXERCISE_TYPE_MAP.get(ex.get("exerciseType", ""), "other"),
648
+ "duration_minutes": dur_min,
649
+ "distance_meters": round(float(dist_mm) / 1000, 1) if dist_mm else None,
650
+ "calories_burned": metrics.get("caloriesKcal"),
651
+ "avg_heart_rate": metrics.get("averageHeartRateBeatsPerMinute"),
652
+ })
653
+
654
+ for pt in sleeps:
655
+ sl = (pt.get("data") or {}).get("sleep", {})
656
+ ivl = sl.get("interval", {})
657
+ start = _parse_google_time(ivl.get("startTime"))
658
+ end = _parse_google_time(ivl.get("endTime"))
659
+ if not start:
660
+ continue
661
+ dur_hrs = None
662
+ if end:
663
+ try:
664
+ s = datetime.fromisoformat(start)
665
+ e = datetime.fromisoformat(end)
666
+ dur_hrs = round((e - s).total_seconds() / 3600, 2)
667
+ except ValueError:
668
+ pass
669
+ rows.append({
670
+ "user_id": user_id,
671
+ "type": "reading",
672
+ "device": "fitbit",
673
+ "timestamp": start,
674
+ "sleep_hours": dur_hrs,
675
+ })
676
+
677
+ if rows:
678
+ await db.table("watch_data").insert(rows).execute()
679
+ return {
680
+ "synced": len([r for r in rows if r["type"] == "workout"]),
681
+ "sleep_synced": len([r for r in rows if r["type"] == "reading"]),
682
+ }
683
+
684
+
685
+ async def _refresh_fitbit_token_if_needed(db, integration: dict) -> str:
686
+ expires_at = datetime.fromisoformat(integration["expires_at"])
687
+ if datetime.now(timezone.utc) < expires_at:
688
+ return integration["access_token"]
689
+
690
+ async with httpx.AsyncClient() as client:
691
+ resp = await client.post(GOOGLE_TOKEN_URL, data={
692
+ "grant_type": "refresh_token",
693
+ "refresh_token": integration["refresh_token"],
694
+ "client_id": GOOGLE_HEALTH_CLIENT_ID,
695
+ "client_secret": GOOGLE_HEALTH_CLIENT_SECRET,
696
+ })
697
+ if resp.status_code != 200:
698
+ raise HTTPException(status_code=502, detail="Failed to refresh Fitbit token")
699
+
700
+ data = resp.json()
701
+ await (
702
+ db.table("user_integrations").update({
703
+ "access_token": data["access_token"],
704
+ "refresh_token": data.get("refresh_token", integration["refresh_token"]),
705
+ "expires_at": (datetime.now(timezone.utc) + timedelta(seconds=data.get("expires_in", 3600))).isoformat(),
706
+ "updated_at": datetime.now(timezone.utc).isoformat(),
707
+ }).eq("id", integration["id"]).execute()
708
+ )
709
+ return data["access_token"]
710
+
711
+
712
+ # ── Garmin (GPX / TCX file import) ───────────────────────────────────────────
713
+
714
+ @router.post("/garmin/import")
715
+ async def garmin_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
716
+ content = await file.read()
717
+ filename = (file.filename or "").lower()
718
+ if filename.endswith(".gpx"):
719
+ workout_rows, route_rows = _parse_gpx(content, user_id)
720
+ elif filename.endswith(".tcx"):
721
+ workout_rows, route_rows = _parse_tcx(content, user_id)
722
+ else:
723
+ return {"error": "Upload a .gpx or .tcx file exported from Garmin Connect.", "imported": 0, "routes_saved": 0}
724
+
725
+ db = await get_db()
726
+ if workout_rows:
727
+ await db.table("watch_data").insert(workout_rows).execute()
728
+ if route_rows:
729
+ await db.table("routes").insert(route_rows).execute()
730
+ return {"imported": len(workout_rows), "routes_saved": len(route_rows)}
731
+
732
+
733
+ def _parse_gpx(content: bytes, user_id: str) -> tuple[list, list]:
734
+ try:
735
+ root = _strip_ns(ET.fromstring(content))
736
+ except ET.ParseError:
737
+ return [], []
738
+
739
+ workout_rows, route_rows = [], []
740
+
741
+ for trk in root.findall(".//trk"):
742
+ name_el = trk.find("name")
743
+ name = name_el.text if name_el is not None else None
744
+ type_el = trk.find("type")
745
+ type_hint = (type_el.text if type_el is not None else name) or ""
746
+ workout_type = _classify_activity(type_hint)
747
+
748
+ coords, hr_vals = [], []
749
+ for pt in trk.findall(".//trkpt"):
750
+ lat, lon = pt.get("lat"), pt.get("lon")
751
+ time_el = pt.find("time")
752
+ if lat is None or lon is None or time_el is None:
753
+ continue
754
+ coord: dict = {"lat": float(lat), "lng": float(lon), "timestamp": time_el.text}
755
+ ele_el = pt.find("ele")
756
+ if ele_el is not None:
757
+ try:
758
+ coord["altitude"] = float(ele_el.text)
759
+ except (ValueError, TypeError):
760
+ pass
761
+ hr_el = pt.find(".//hr") or pt.find(".//HeartRateBpm/Value")
762
+ if hr_el is not None and hr_el.text:
763
+ try:
764
+ v = int(hr_el.text)
765
+ hr_vals.append(v)
766
+ coord["heart_rate"] = v
767
+ except (ValueError, TypeError):
768
+ pass
769
+ coords.append(coord)
770
+
771
+ if len(coords) < 2:
772
+ continue
773
+
774
+ try:
775
+ start_dt = datetime.fromisoformat(coords[0]["timestamp"].replace("Z", "+00:00"))
776
+ end_dt = datetime.fromisoformat(coords[-1]["timestamp"].replace("Z", "+00:00"))
777
+ except (ValueError, KeyError):
778
+ continue
779
+
780
+ duration_s = int((end_dt - start_dt).total_seconds())
781
+ dist_m = sum(_haversine(coords[i-1]["lat"], coords[i-1]["lng"],
782
+ coords[i]["lat"], coords[i]["lng"])
783
+ for i in range(1, len(coords)))
784
+
785
+ workout_rows.append({
786
+ "user_id": user_id, "type": "workout", "device": "garmin",
787
+ "timestamp": start_dt.isoformat(),
788
+ "workout_type": workout_type,
789
+ "duration_minutes": round(duration_s / 60, 1),
790
+ "distance_meters": round(dist_m, 1),
791
+ "avg_heart_rate": round(sum(hr_vals) / len(hr_vals)) if hr_vals else None,
792
+ "max_heart_rate": max(hr_vals) if hr_vals else None,
793
+ "ending_heart_rate": hr_vals[-1] if hr_vals else None,
794
+ "notes": name,
795
+ })
796
+ route_rows.append({
797
+ "user_id": user_id, "workout_type": workout_type,
798
+ "coordinates": coords, "distance_meters": round(dist_m, 1),
799
+ "duration_seconds": duration_s,
800
+ "started_at": start_dt.isoformat(), "ended_at": end_dt.isoformat(),
801
+ "notes": name,
802
+ })
803
+
804
+ return workout_rows, route_rows
805
+
806
+
807
+ def _parse_tcx(content: bytes, user_id: str) -> tuple[list, list]:
808
+ try:
809
+ root = _strip_ns(ET.fromstring(content))
810
+ except ET.ParseError:
811
+ return [], []
812
+
813
+ workout_rows, route_rows = [], []
814
+
815
+ for activity in root.findall(".//Activity"):
816
+ sport = activity.get("Sport", "other")
817
+ workout_type = _classify_activity(sport)
818
+ id_el = activity.find("Id")
819
+ if id_el is None:
820
+ continue
821
+ try:
822
+ start_dt = datetime.fromisoformat(id_el.text.replace("Z", "+00:00"))
823
+ except (ValueError, AttributeError):
824
+ continue
825
+
826
+ total_time_s = 0
827
+ total_dist_m = 0.0
828
+ total_cal = 0
829
+ hr_vals, coords = [], []
830
+
831
+ for lap in activity.findall(".//Lap"):
832
+ for tag, target in [("TotalTimeSeconds", None), ("DistanceMeters", None),
833
+ ("Calories", None)]:
834
+ el = lap.find(tag)
835
+ if el is not None and el.text:
836
+ try:
837
+ val = float(el.text)
838
+ if tag == "TotalTimeSeconds": total_time_s += int(val)
839
+ elif tag == "DistanceMeters": total_dist_m += val
840
+ elif tag == "Calories": total_cal += int(val)
841
+ except (ValueError, TypeError):
842
+ pass
843
+
844
+ for tp in lap.findall(".//Trackpoint"):
845
+ time_el = tp.find("Time")
846
+ pos_el = tp.find("Position")
847
+ hr_el = tp.find(".//HeartRateBpm/Value") or tp.find("HeartRateBpm/Value")
848
+ if pos_el is not None and time_el is not None:
849
+ lat_el = pos_el.find("LatitudeDegrees")
850
+ lng_el = pos_el.find("LongitudeDegrees")
851
+ if lat_el is not None and lng_el is not None:
852
+ try:
853
+ coord: dict = {
854
+ "lat": float(lat_el.text),
855
+ "lng": float(lng_el.text),
856
+ "timestamp": time_el.text,
857
+ }
858
+ if hr_el is not None and hr_el.text:
859
+ v = int(hr_el.text)
860
+ hr_vals.append(v)
861
+ coord["heart_rate"] = v
862
+ coords.append(coord)
863
+ except (ValueError, TypeError):
864
+ pass
865
+
866
+ end_dt = start_dt + timedelta(seconds=total_time_s)
867
+ workout_rows.append({
868
+ "user_id": user_id, "type": "workout", "device": "garmin",
869
+ "timestamp": start_dt.isoformat(),
870
+ "workout_type": workout_type,
871
+ "duration_minutes": round(total_time_s / 60, 1),
872
+ "distance_meters": round(total_dist_m, 1) if total_dist_m else None,
873
+ "calories_burned": total_cal if total_cal else None,
874
+ "avg_heart_rate": round(sum(hr_vals) / len(hr_vals)) if hr_vals else None,
875
+ "max_heart_rate": max(hr_vals) if hr_vals else None,
876
+ "ending_heart_rate": hr_vals[-1] if hr_vals else None,
877
+ })
878
+ if len(coords) >= 2:
879
+ route_rows.append({
880
+ "user_id": user_id, "workout_type": workout_type,
881
+ "coordinates": coords,
882
+ "distance_meters": round(total_dist_m, 1) if total_dist_m else None,
883
+ "duration_seconds": total_time_s,
884
+ "started_at": start_dt.isoformat(), "ended_at": end_dt.isoformat(),
885
+ })
886
+
887
+ return workout_rows, route_rows
888
+
889
+
890
+ # ── Apple Health (XML / ZIP export) ──────────────────────────────────────────
891
+
892
+ APPLE_WORKOUT_MAP = {
893
+ "HKWorkoutActivityTypeRunning": "running",
894
+ "HKWorkoutActivityTypeCycling": "cycling",
895
+ "HKWorkoutActivityTypeWalking": "walking",
896
+ "HKWorkoutActivityTypeHiking": "hiking",
897
+ "HKWorkoutActivityTypeSwimming": "swimming",
898
+ "HKWorkoutActivityTypeTraditionalStrengthTraining": "weightlifting",
899
+ "HKWorkoutActivityTypeFunctionalStrengthTraining": "weightlifting",
900
+ "HKWorkoutActivityTypeHighIntensityIntervalTraining": "other",
901
+ "HKWorkoutActivityTypeYoga": "other",
902
+ "HKWorkoutActivityTypeCrossTraining": "other",
903
+ "HKWorkoutActivityTypeElliptical": "other",
904
+ "HKWorkoutActivityTypePilates": "other",
905
+ }
906
+
907
+
908
+ @router.post("/apple/import")
909
+ async def apple_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
910
+ content = await file.read()
911
+ filename = (file.filename or "").lower()
912
+
913
+ if filename.endswith(".zip"):
914
+ try:
915
+ with zipfile.ZipFile(io.BytesIO(content)) as zf:
916
+ xml_name = next(
917
+ (n for n in zf.namelist() if n.lower().endswith("export.xml")), None
918
+ )
919
+ if xml_name is None:
920
+ return {"error": "No export.xml found in ZIP.", "imported": 0}
921
+ xml_bytes = zf.read(xml_name)
922
+ except zipfile.BadZipFile:
923
+ return {"error": "Invalid ZIP file.", "imported": 0}
924
+ elif filename.endswith(".xml"):
925
+ xml_bytes = content
926
+ else:
927
+ return {"error": "Upload export.xml or the ZIP from Health app → Export All Health Data.", "imported": 0}
928
+
929
+ workout_rows, reading_rows = _parse_apple_health(xml_bytes, user_id)
930
+ db = await get_db()
931
+ if workout_rows:
932
+ await db.table("watch_data").insert(workout_rows).execute()
933
+ if reading_rows:
934
+ await db.table("watch_data").insert(reading_rows).execute()
935
+ return {"imported": len(workout_rows), "readings_synced": len(reading_rows)}
936
+
937
+
938
+ def _parse_apple_health(xml_bytes: bytes, user_id: str) -> tuple[list, list]:
939
+ try:
940
+ root = ET.fromstring(xml_bytes)
941
+ except ET.ParseError:
942
+ return [], []
943
+
944
+ workout_rows: list[dict] = []
945
+ hr_by_day: dict[str, list[float]] = {}
946
+ steps_by_day: dict[str, int] = {}
947
+ sleep_by_day: dict[str, float] = {}
948
+
949
+ for w in root.findall("Workout"):
950
+ activity_type = w.get("workoutActivityType", "")
951
+ start_str = w.get("startDate", "")
952
+ end_str = w.get("endDate", "")
953
+ if not start_str:
954
+ continue
955
+ try:
956
+ start_dt = _parse_apple_date(start_str)
957
+ end_dt = _parse_apple_date(end_str) if end_str else start_dt
958
+ except ValueError:
959
+ continue
960
+
961
+ duration_s = int((end_dt - start_dt).total_seconds())
962
+ calories = avg_hr = max_hr = dist_m = None
963
+
964
+ for stat in w.findall("WorkoutStatistics"):
965
+ stype = stat.get("type", "")
966
+ if "ActiveEnergyBurned" in stype:
967
+ try:
968
+ calories = float(stat.get("sum") or stat.get("average") or 0) or None
969
+ except (ValueError, TypeError):
970
+ pass
971
+ elif stype == "HKQuantityTypeIdentifierHeartRate":
972
+ try:
973
+ avg_hr = round(float(stat.get("average") or 0)) or None
974
+ max_hr = round(float(stat.get("maximum") or 0)) or None
975
+ except (ValueError, TypeError):
976
+ pass
977
+ elif "Distance" in stype:
978
+ try:
979
+ val = float(stat.get("sum") or 0)
980
+ unit = stat.get("unit", "km").lower()
981
+ dist_m = round(val * (1000 if "km" in unit else 1609.34 if "mi" in unit else 1), 1) or None
982
+ except (ValueError, TypeError):
983
+ pass
984
+
985
+ workout_rows.append({
986
+ "user_id": user_id, "type": "workout", "device": "apple_health",
987
+ "timestamp": start_dt.isoformat(),
988
+ "workout_type": APPLE_WORKOUT_MAP.get(activity_type, "other"),
989
+ "duration_minutes": round(duration_s / 60, 1),
990
+ "distance_meters": dist_m,
991
+ "calories_burned": calories,
992
+ "avg_heart_rate": avg_hr,
993
+ "max_heart_rate": max_hr,
994
+ })
995
+
996
+ for rec in root.findall("Record"):
997
+ rtype = rec.get("type", "")
998
+ start_str = rec.get("startDate", "")
999
+ if not start_str:
1000
+ continue
1001
+ try:
1002
+ dt = _parse_apple_date(start_str)
1003
+ day = dt.strftime("%Y-%m-%d")
1004
+ except ValueError:
1005
+ continue
1006
+ val = rec.get("value", "")
1007
+
1008
+ if rtype == "HKQuantityTypeIdentifierHeartRate":
1009
+ try:
1010
+ hr_by_day.setdefault(day, []).append(float(val))
1011
+ except (ValueError, TypeError):
1012
+ pass
1013
+ elif rtype == "HKQuantityTypeIdentifierStepCount":
1014
+ try:
1015
+ steps_by_day[day] = steps_by_day.get(day, 0) + int(float(val))
1016
+ except (ValueError, TypeError):
1017
+ pass
1018
+ elif rtype == "HKCategoryTypeIdentifierSleepAnalysis" and val == "HKCategoryValueSleepAnalysisAsleep":
1019
+ end_str2 = rec.get("endDate", "")
1020
+ if end_str2:
1021
+ try:
1022
+ end_dt2 = _parse_apple_date(end_str2)
1023
+ hours = (end_dt2 - dt).total_seconds() / 3600
1024
+ sleep_by_day[day] = sleep_by_day.get(day, 0) + hours
1025
+ except ValueError:
1026
+ pass
1027
+
1028
+ reading_rows: list[dict] = []
1029
+ for day in sorted(set(list(hr_by_day) + list(steps_by_day) + list(sleep_by_day))):
1030
+ row: dict = {"user_id": user_id, "type": "reading", "device": "apple_health",
1031
+ "timestamp": f"{day}T00:00:00+00:00"}
1032
+ hrs = hr_by_day.get(day)
1033
+ if hrs:
1034
+ row["heart_rate"] = round(sum(hrs) / len(hrs))
1035
+ if day in steps_by_day:
1036
+ row["steps"] = steps_by_day[day]
1037
+ if day in sleep_by_day:
1038
+ row["sleep_hours"] = round(sleep_by_day[day], 2)
1039
+ reading_rows.append(row)
1040
+
1041
+ return workout_rows, reading_rows
1042
+
1043
+
1044
+ # ── Google Fit (Takeout ZIP import) ──────────────────────────────────────────
1045
+
1046
+ @router.post("/google/import")
1047
+ async def google_fit_import(file: UploadFile = File(...), user_id: str = Depends(get_user_id)):
1048
+ """
1049
+ Import from Google Takeout → Fit data export.
1050
+ Go to takeout.google.com, select only 'Fit', export as ZIP, then upload here.
1051
+ """
1052
+ content = await file.read()
1053
+ if not (file.filename or "").lower().endswith(".zip"):
1054
+ return {"error": "Upload the ZIP file downloaded from takeout.google.com.", "imported": 0}
1055
+
1056
+ try:
1057
+ zf = zipfile.ZipFile(io.BytesIO(content))
1058
+ except zipfile.BadZipFile:
1059
+ return {"error": "Invalid ZIP file.", "imported": 0}
1060
+
1061
+ workout_rows: list[dict] = []
1062
+ reading_rows: list[dict] = []
1063
+
1064
+ with zf:
1065
+ names = zf.namelist()
1066
+
1067
+ # ── Session JSON files (individual activities) ─────────────────
1068
+ session_files = [n for n in names
1069
+ if "Activities" in n and n.endswith(".json")]
1070
+ for fname in session_files:
1071
+ try:
1072
+ data = json.loads(zf.read(fname))
1073
+ except (json.JSONDecodeError, KeyError):
1074
+ continue
1075
+ if not isinstance(data, dict):
1076
+ continue
1077
+
1078
+ start_str = data.get("startTime") or data.get("start_time_millis")
1079
+ end_str = data.get("endTime") or data.get("end_time_millis")
1080
+ if not start_str:
1081
+ continue
1082
+
1083
+ try:
1084
+ if isinstance(start_str, (int, float)):
1085
+ start_dt = datetime.fromtimestamp(start_str / 1000, tz=timezone.utc)
1086
+ end_dt = datetime.fromtimestamp((end_str or start_str) / 1000, tz=timezone.utc)
1087
+ else:
1088
+ start_dt = datetime.fromisoformat(start_str.replace("Z", "+00:00"))
1089
+ end_dt = datetime.fromisoformat((end_str or start_str).replace("Z", "+00:00"))
1090
+ except (ValueError, TypeError):
1091
+ continue
1092
+
1093
+ activity_type = str(data.get("activityType", data.get("activity_type", 0)))
1094
+ workout_type = _google_fit_activity_type(activity_type)
1095
+ duration_s = int((end_dt - start_dt).total_seconds())
1096
+
1097
+ calories = dist_m = avg_hr = None
1098
+ for seg in data.get("activitySegment", {}).get("activityConfidence", []):
1099
+ pass # presence only; stats are in aggregate fields below
1100
+ try:
1101
+ calories = float(data.get("calories") or data.get("caloriesExpended") or 0) or None
1102
+ except (ValueError, TypeError):
1103
+ pass
1104
+ try:
1105
+ dist_m = float(data.get("distance") or data.get("distanceMeters") or 0) or None
1106
+ except (ValueError, TypeError):
1107
+ pass
1108
+
1109
+ # Heart rate from aggregate key
1110
+ hr_data = data.get("heartRate") or {}
1111
+ try:
1112
+ avg_hr = round(float(hr_data.get("avg") or hr_data.get("average") or 0)) or None
1113
+ except (ValueError, TypeError):
1114
+ pass
1115
+
1116
+ workout_rows.append({
1117
+ "user_id": user_id, "type": "workout", "device": "google_fit",
1118
+ "timestamp": start_dt.isoformat(),
1119
+ "workout_type": workout_type,
1120
+ "duration_minutes": round(duration_s / 60, 1),
1121
+ "distance_meters": dist_m,
1122
+ "calories_burned": calories,
1123
+ "avg_heart_rate": avg_hr,
1124
+ })
1125
+
1126
+ # ── Daily Summary CSV ──────────────────────────────────────────
1127
+ csv_file = next(
1128
+ (n for n in names if "Daily" in n and n.endswith(".csv")), None
1129
+ )
1130
+ if csv_file:
1131
+ try:
1132
+ text = zf.read(csv_file).decode("utf-8-sig")
1133
+ reader = csv.DictReader(io.StringIO(text))
1134
+ for row in reader:
1135
+ day = (row.get("Date") or "").strip()
1136
+ if not day:
1137
+ continue
1138
+ reading: dict = {"user_id": user_id, "type": "reading",
1139
+ "device": "google_fit",
1140
+ "timestamp": f"{day}T00:00:00+00:00"}
1141
+ for col, field in [
1142
+ ("Step count", "steps"),
1143
+ ("Calories (kcal)", "calories_burned"),
1144
+ ("Average heart rate (bpm)", "heart_rate"),
1145
+ ("Sleep duration", None),
1146
+ ]:
1147
+ val = (row.get(col) or "").strip()
1148
+ if not val:
1149
+ continue
1150
+ if col == "Sleep duration":
1151
+ try:
1152
+ h, m, s = (val + ":0:0").split(":")[:3]
1153
+ reading["sleep_hours"] = round(int(h) + int(m) / 60 + int(s) / 3600, 2)
1154
+ except (ValueError, TypeError):
1155
+ pass
1156
+ elif field:
1157
+ try:
1158
+ reading[field] = round(float(val), 1)
1159
+ except (ValueError, TypeError):
1160
+ pass
1161
+ reading_rows.append(reading)
1162
+ except Exception:
1163
+ pass
1164
+
1165
+ db = await get_db()
1166
+ if workout_rows:
1167
+ await db.table("watch_data").insert(workout_rows).execute()
1168
+ if reading_rows:
1169
+ await db.table("watch_data").insert(reading_rows).execute()
1170
+ return {"imported": len(workout_rows), "readings_synced": len(reading_rows)}
1171
+
1172
+
1173
+ _GOOGLE_ACTIVITY_TYPES: dict[str, str] = {
1174
+ "7": "cycling", "11": "cycling",
1175
+ "1": "other", "17": "hiking",
1176
+ "37": "running", "38": "running",
1177
+ "39": "running", "93": "running",
1178
+ "41": "other", "79": "walking",
1179
+ "80": "walking", "45": "other",
1180
+ "97": "weightlifting", "20": "other",
1181
+ "82": "swimming",
1182
+ }
1183
+
1184
+
1185
+ def _google_fit_activity_type(code: str) -> str:
1186
+ return _GOOGLE_ACTIVITY_TYPES.get(str(code), _classify_activity(code))
backend/routes/user.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from auth import get_user_id
3
+ from db import get_db
4
+ from datetime import datetime, timezone, timedelta
5
+
6
+ router = APIRouter()
7
+
8
+
9
+ @router.get("/me")
10
+ async def get_me(user_id: str = Depends(get_user_id)):
11
+ """Return basic stats for the authenticated user."""
12
+ db = await get_db()
13
+
14
+ readings = await db.table("watch_data").select("id", count="exact").eq("user_id", user_id).eq("type", "reading").execute()
15
+ workouts = await db.table("watch_data").select("id", count="exact").eq("user_id", user_id).eq("type", "workout").execute()
16
+ analyses = await db.table("analyses").select("id", count="exact").eq("user_id", user_id).execute()
17
+
18
+ return {
19
+ "user_id": user_id,
20
+ "readings": readings.count,
21
+ "workouts": workouts.count,
22
+ "analyses": analyses.count,
23
+ }
24
+
25
+
26
+ @router.get("/history")
27
+ async def get_history(user_id: str = Depends(get_user_id), limit: int = 20):
28
+ """Return the user's past AI analyses, newest first — consumed by the phone."""
29
+ db = await get_db()
30
+ result = await (
31
+ db.table("analyses")
32
+ .select("*")
33
+ .eq("user_id", user_id)
34
+ .order("created_at", desc=True)
35
+ .limit(limit)
36
+ .execute()
37
+ )
38
+ return {"analyses": result.data}
39
+
40
+
41
+ @router.get("/summary")
42
+ async def get_summary(user_id: str = Depends(get_user_id)):
43
+ """Weekly comparison stats, streak, and personal records."""
44
+ db = await get_db()
45
+ now = datetime.now(timezone.utc)
46
+ seven_ago = now - timedelta(days=7)
47
+ fourteen_ago = now - timedelta(days=14)
48
+ sixty_ago = now - timedelta(days=60)
49
+
50
+ # ── Last 14 days for week-over-week comparison ─────────────────────
51
+ recent = await (
52
+ db.table("watch_data").select("*")
53
+ .eq("user_id", user_id)
54
+ .gte("timestamp", fourteen_ago.isoformat())
55
+ .execute()
56
+ )
57
+ rows = recent.data or []
58
+ seven_str = seven_ago.isoformat()
59
+ this_week = [r for r in rows if (r.get("timestamp") or "") >= seven_str]
60
+ last_week = [r for r in rows if (r.get("timestamp") or "") < seven_str]
61
+
62
+ def week_stats(week_rows):
63
+ workouts = [r for r in week_rows if r.get("type") == "workout"]
64
+ readings = [r for r in week_rows if r.get("type") == "reading"]
65
+ hrs = [r.get("avg_heart_rate") or r.get("heart_rate") for r in week_rows
66
+ if r.get("avg_heart_rate") or r.get("heart_rate")]
67
+ steps = [r.get("steps") or 0 for r in readings]
68
+ sleep = [r.get("sleep_hours") for r in readings if r.get("sleep_hours")]
69
+ return {
70
+ "workouts": len(workouts),
71
+ "distance_km": round(sum(r.get("distance_meters") or 0 for r in workouts) / 1000, 1),
72
+ "calories": round(sum(r.get("calories_burned") or 0 for r in workouts)),
73
+ "avg_hr": round(sum(hrs) / len(hrs)) if hrs else 0,
74
+ "total_steps": sum(steps),
75
+ "avg_sleep": round(sum(sleep) / len(sleep), 1) if sleep else 0,
76
+ }
77
+
78
+ # ── Streak (consecutive days with a workout) ───────────────────────
79
+ streak_res = await (
80
+ db.table("watch_data").select("timestamp")
81
+ .eq("user_id", user_id).eq("type", "workout")
82
+ .gte("timestamp", sixty_ago.isoformat())
83
+ .execute()
84
+ )
85
+ active_days = {(r.get("timestamp") or "")[:10] for r in (streak_res.data or []) if r.get("timestamp")}
86
+ streak, check = 0, now.date()
87
+ while str(check) in active_days:
88
+ streak += 1
89
+ check -= timedelta(days=1)
90
+
91
+ # ── Personal records ───────────────────────────────────────────────
92
+ w_all = await (db.table("watch_data").select("distance_meters,calories_burned,heart_rate,steps,timestamp")
93
+ .eq("user_id", user_id).execute())
94
+ r_all = await (db.table("routes").select("distance_meters,duration_seconds")
95
+ .eq("user_id", user_id).execute())
96
+
97
+ w_rows = w_all.data or []
98
+ workout_rows = [r for r in w_rows if r.get("distance_meters") or r.get("calories_burned")]
99
+ reading_rows = [r for r in w_rows if r.get("heart_rate") or r.get("steps")]
100
+
101
+ max_dist_m = max((r.get("distance_meters") or 0 for r in workout_rows), default=0)
102
+ max_cal = max((r.get("calories_burned") or 0 for r in workout_rows), default=0)
103
+ max_hr = max((r.get("heart_rate") or 0 for r in reading_rows), default=0)
104
+
105
+ steps_by_day: dict = {}
106
+ for r in reading_rows:
107
+ day = (r.get("timestamp") or "")[:10]
108
+ if day:
109
+ steps_by_day[day] = steps_by_day.get(day, 0) + (r.get("steps") or 0)
110
+ max_steps_day = max(steps_by_day.values(), default=0)
111
+
112
+ best_pace_str, best_pace_val = None, float("inf")
113
+ for r in (r_all.data or []):
114
+ dist, dur = r.get("distance_meters") or 0, r.get("duration_seconds") or 0
115
+ if dist > 500 and dur > 0:
116
+ pace = (dur / 60) / (dist / 1000)
117
+ if pace < best_pace_val:
118
+ best_pace_val = pace
119
+ m, s = int(pace), int((pace % 1) * 60)
120
+ best_pace_str = f"{m}:{s:02d}"
121
+
122
+ return {
123
+ "streak": streak,
124
+ "this_week": week_stats(this_week),
125
+ "last_week": week_stats(last_week),
126
+ "records": {
127
+ "longest_km": round(max_dist_m / 1000, 1) if max_dist_m else None,
128
+ "max_calories": round(max_cal) if max_cal else None,
129
+ "max_hr": round(max_hr) if max_hr else None,
130
+ "max_steps": max_steps_day if max_steps_day else None,
131
+ "best_pace": best_pace_str,
132
+ },
133
+ }
134
+
135
+
136
+ @router.get("/data")
137
+ async def get_watch_data(user_id: str = Depends(get_user_id), limit: int = 100):
138
+ """Return raw watch data for the user, newest first."""
139
+ db = await get_db()
140
+ result = await (
141
+ db.table("watch_data")
142
+ .select("*")
143
+ .eq("user_id", user_id)
144
+ .order("timestamp", desc=True)
145
+ .limit(limit)
146
+ .execute()
147
+ )
148
+ return {"data": result.data}
backend/routes/watch.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from auth import get_user_id
3
+ from db import get_db
4
+ from models.watch import WatchSyncPayload
5
+ from calories import estimate_calories
6
+
7
+ router = APIRouter()
8
+
9
+
10
+ @router.post("/sync")
11
+ async def sync_watch_data(payload: WatchSyncPayload, user_id: str = Depends(get_user_id)):
12
+ """Receive a batch of watch readings and workouts from the phone/watch app."""
13
+ db = await get_db()
14
+ rows = []
15
+
16
+ for reading in payload.readings:
17
+ rows.append({
18
+ "user_id": user_id,
19
+ "type": "reading",
20
+ "device": payload.device,
21
+ **reading.model_dump(),
22
+ })
23
+
24
+ # The watch no longer computes calories (it can't know the user's weight), so
25
+ # we estimate them here. Only for watch-originated workouts — manual web
26
+ # entries intentionally leave calories blank unless the user types them.
27
+ is_watch = (payload.device or "").startswith("fitness_watch")
28
+
29
+ for workout in payload.workouts:
30
+ row = {
31
+ "user_id": user_id,
32
+ "type": "workout",
33
+ "device": payload.device,
34
+ **workout.model_dump(),
35
+ }
36
+ if is_watch and row.get("calories_burned") is None:
37
+ row["calories_burned"] = estimate_calories(
38
+ row.get("duration_minutes") or 0, row.get("workout_type"))
39
+ rows.append(row)
40
+
41
+ if not rows:
42
+ raise HTTPException(status_code=400, detail="No data provided")
43
+
44
+ for row in rows:
45
+ if row.get("timestamp"):
46
+ row["timestamp"] = row["timestamp"].isoformat()
47
+
48
+ await db.table("watch_data").insert(rows).execute()
49
+ return {"synced": len(rows)}