diff --git a/backend/app/api/routes/v1/__init__.py b/backend/app/api/routes/v1/__init__.py index 84d600db..f9edf545 100644 --- a/backend/app/api/routes/v1/__init__.py +++ b/backend/app/api/routes/v1/__init__.py @@ -19,6 +19,7 @@ from .summaries import router as summaries_router from .suunto_debug import router as suunto_debug_router from .sync_data import router as sync_data_router +from .sync_events import router as sync_events_router from .timeseries import router as timeseries_router from .token import router as token_router from .user_invitation_code import router as user_invitation_code_router @@ -39,6 +40,7 @@ # New unified vendor workouts endpoint v1_router.include_router(vendor_workouts_router, prefix="/providers", tags=["providers workouts"]) v1_router.include_router(sync_data_router, prefix="/providers", tags=["sync data"]) +v1_router.include_router(sync_events_router, tags=["sync events"]) # Suunto debug endpoints for raw API access v1_router.include_router(suunto_debug_router, prefix="/debug", tags=["debug"]) v1_router.include_router(users_router, tags=["users"]) diff --git a/backend/app/api/routes/v1/sync_events.py b/backend/app/api/routes/v1/sync_events.py new file mode 100644 index 00000000..e097e0aa --- /dev/null +++ b/backend/app/api/routes/v1/sync_events.py @@ -0,0 +1,105 @@ +"""SSE (Server-Sent Events) endpoint for real-time sync progress. + +Subscribes to a per-user Redis Pub/Sub channel and streams every sync +event to the browser over an open HTTP connection. +""" + +import asyncio +import json +from collections.abc import AsyncGenerator +from logging import getLogger +from typing import Annotated +from uuid import UUID + +import redis.asyncio as aioredis +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse + +from app.config import settings +from app.integrations.sync_events import SYNC_CHANNEL_PREFIX +from app.utils.auth import verify_query_token + +logger = getLogger(__name__) + +router = APIRouter() + + +async def _event_generator(user_id: str) -> AsyncGenerator[str, None]: + """Yield SSE-formatted messages from a Redis Pub/Sub channel. + + The ``timeout=15.0`` parameter in ``pubsub.get_message()`` controls + the keep-alive interval, NOT message latency. + + - If a sync event arrives (e.g., at t=0.1s), Redis delivers it **immediately**. + - If NO event arrives for 15 seconds, ``get_message()`` returns None. + - We then yield a ``: keepalive`` comment to prevent the browser/proxy + from closing the idle connection. + + This ensures instant updates while maintaining a stable long-lived connection. + """ + channel = f"{SYNC_CHANNEL_PREFIX}:{user_id}" + + redis_client = aioredis.from_url(settings.redis_url, decode_responses=True) + pubsub = redis_client.pubsub() + + try: + await pubsub.subscribe(channel) + + while True: + # Wait up to 15 s for a message, then send a keep-alive comment + # This does NOT delay messages - they are returned as soon as published. + message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=15.0) + + if message and message["type"] == "message": + raw = message["data"] + + # Parse to extract event type for SSE "event:" field + try: + payload = json.loads(raw) + event_type = payload.get("type", "message") + except (json.JSONDecodeError, TypeError): + event_type = "message" + raw = json.dumps({"type": "message", "data": raw}) + + yield f"event: {event_type}\ndata: {raw}\n\n" + + # If this was a terminal event, close the stream + if event_type in ("sync:completed", "sync:error"): + return + else: + # SSE keep-alive comment (ignored by EventSource) + yield ": keepalive\n\n" + + except asyncio.CancelledError: + pass + finally: + await pubsub.unsubscribe(channel) + await pubsub.close() + await redis_client.aclose() + + +@router.get("/users/{user_id}/sync/events") +async def sync_events_stream( + user_id: UUID, + _developer_id: Annotated[str, Depends(verify_query_token)], +) -> StreamingResponse: + """Stream real-time sync progress events via Server-Sent Events (SSE). + + **Authentication:** + Pass your JWT token as a ``token`` query parameter + (``EventSource`` does not support custom headers). + + **Event types emitted:** + (See ``SyncEventType`` in frontend types for full list) + + The stream closes automatically after a terminal event. + """ + return StreamingResponse( + _event_generator(str(user_id)), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/app/integrations/celery/tasks/sync_vendor_data_task.py b/backend/app/integrations/celery/tasks/sync_vendor_data_task.py index a9259150..75e87a90 100644 --- a/backend/app/integrations/celery/tasks/sync_vendor_data_task.py +++ b/backend/app/integrations/celery/tasks/sync_vendor_data_task.py @@ -5,6 +5,7 @@ from uuid import UUID from app.database import SessionLocal +from app.integrations.sync_events import publish_sync_event from app.repositories.user_connection_repository import UserConnectionRepository from app.schemas import ProviderSyncResult, SyncVendorDataResult from app.services.providers.factory import ProviderFactory @@ -56,6 +57,8 @@ def sync_vendor_data( errors={"user_id": f"Invalid UUID format: {str(e)}"}, ).model_dump() + task_id = sync_vendor_data.request.id or "unknown" + result = SyncVendorDataResult( user_id=user_uuid, start_date=start_date, @@ -78,6 +81,12 @@ def sync_vendor_data( task="sync_vendor_data", ) result.message = "No active provider connections found" + publish_sync_event( + user_id, + "sync:completed", + task_id=task_id, + data={"message": "No active provider connections found", "providers_synced": []}, + ) return result.model_dump() log_structured( @@ -88,7 +97,15 @@ def sync_vendor_data( task="sync_vendor_data", ) - for connection in connections: + provider_names = [c.provider for c in connections] + publish_sync_event( + user_id, + "sync:started", + task_id=task_id, + data={"providers": provider_names, "total_providers": len(connections)}, + ) + + for idx, connection in enumerate(connections): provider_name = connection.provider log_structured( logger, @@ -98,16 +115,37 @@ def sync_vendor_data( task="sync_vendor_data", ) + publish_sync_event( + user_id, + "sync:provider:started", + task_id=task_id, + provider=provider_name, + data={"index": idx, "total": len(connections)}, + ) + try: strategy = factory.get_provider(provider_name) provider_result = ProviderSyncResult(success=True, params={}) # Sync workouts if strategy.workouts: + publish_sync_event( + user_id, + "sync:provider:workouts:started", + task_id=task_id, + provider=provider_name, + ) params = _build_sync_params(provider_name, start_date, end_date) try: success = strategy.workouts.load_data(db, user_uuid, **params) provider_result.params["workouts"] = {"success": success, **params} + publish_sync_event( + user_id, + "sync:provider:workouts:completed", + task_id=task_id, + provider=provider_name, + data={"success": success}, + ) except Exception as e: log_structured( logger, @@ -123,6 +161,13 @@ def sync_vendor_data( extra={"user_id": user_id, "provider": provider_name, "task": "sync_vendor_data"}, ) provider_result.params["workouts"] = {"success": False, "error": str(e)} + publish_sync_event( + user_id, + "sync:provider:workouts:error", + task_id=task_id, + provider=provider_name, + data={"error": "An error occurred syncing workouts"}, + ) # Sync 247 data (sleep, recovery, activity) and SAVE to database if hasattr(strategy, "data_247") and strategy.data_247: @@ -140,6 +185,12 @@ def sync_vendor_data( with suppress(ValueError): end_dt = datetime.fromisoformat(end_date.replace("Z", "+00:00")) + publish_sync_event( + user_id, + "sync:provider:247:started", + task_id=task_id, + provider=provider_name, + ) try: # Use load_and_save_all if available (saves data to DB) # Otherwise fallback to load_all_247_data (just returns data) @@ -168,6 +219,13 @@ def sync_vendor_data( provider="sync_vendor_data", task="sync_vendor_data", ) + publish_sync_event( + user_id, + "sync:provider:247:completed", + task_id=task_id, + provider=provider_name, + data={"success": True}, + ) except Exception as e: log_structured( logger, @@ -183,19 +241,49 @@ def sync_vendor_data( extra={"user_id": user_id, "provider": provider_name, "task": "sync_vendor_data"}, ) provider_result.params["data_247"] = {"success": False, "error": str(e)} + publish_sync_event( + user_id, + "sync:provider:247:error", + task_id=task_id, + provider=provider_name, + data={"error": "An error occurred syncing 24/7 data"}, + ) user_connection_repo.update_last_synced_at(db, connection) result.providers_synced[provider_name] = provider_result + + # Compute success based on individual component results + sync_success = True + for component_result in provider_result.params.values(): + # Each component result is a dict with a "success" key + if isinstance(component_result, dict) and not component_result.get("success", False): + sync_success = False + break + log_structured( logger, "info", - f"Successfully synced {provider_name} for user {user_id}", + f"Successfully synced {provider_name} for user {user_id} (success={sync_success})", provider="sync_vendor_data", task="sync_vendor_data", ) + publish_sync_event( + user_id, + "sync:provider:completed", + task_id=task_id, + provider=provider_name, + data={"success": sync_success, "index": idx, "total": len(connections)}, + ) except Exception as e: + publish_sync_event( + user_id, + "sync:provider:error", + task_id=task_id, + provider=provider_name, + data={"error": "An error occurred syncing provider data"}, + ) log_and_capture_error( e, logger, @@ -205,6 +293,15 @@ def sync_vendor_data( result.errors[provider_name] = str(e) continue + publish_sync_event( + user_id, + "sync:completed", + task_id=task_id, + data={ + "providers_synced": list(result.providers_synced.keys()), + "errors": result.errors if result.errors else None, + }, + ) return result.model_dump() except Exception as e: @@ -215,6 +312,12 @@ def sync_vendor_data( extra={"user_id": user_id, "task": "sync_vendor_data"}, ) result.errors["general"] = str(e) + publish_sync_event( + user_id, + "sync:error", + task_id=task_id, + data={"error": str(e)}, + ) return result.model_dump() diff --git a/backend/app/integrations/sync_events.py b/backend/app/integrations/sync_events.py new file mode 100644 index 00000000..f6efc873 --- /dev/null +++ b/backend/app/integrations/sync_events.py @@ -0,0 +1,58 @@ +"""Redis Pub/Sub sync event publisher for real-time SSE updates. + +Celery tasks call publish_sync_event() to notify connected SSE clients +of sync progress. Events are published to a per-user Redis channel. +""" + +import json +from datetime import datetime, timezone +from logging import getLogger +from typing import Any + +from app.integrations.redis_client import get_redis_client + +logger = getLogger(__name__) + +SYNC_CHANNEL_PREFIX = "sync:events" + + +def _channel_for_user(user_id: str) -> str: + """Return the Redis Pub/Sub channel name for a given user.""" + return f"{SYNC_CHANNEL_PREFIX}:{user_id}" + + +def publish_sync_event( + user_id: str, + event_type: str, + *, + task_id: str | None = None, + provider: str | None = None, + data: dict[str, Any] | None = None, +) -> None: + """Publish a sync progress event to the user's Redis Pub/Sub channel. + + Args: + user_id: UUID of the user (as string). + event_type: Event type identifier (e.g. "sync:started"). + task_id: Celery task ID, if applicable. + provider: Provider name being synced, if applicable. + data: Additional event payload. + """ + channel = _channel_for_user(user_id) + message: dict[str, Any] = { + "type": event_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + if task_id: + message["task_id"] = task_id + if provider: + message["provider"] = provider + if data: + message["data"] = data + + try: + redis_client = get_redis_client() + redis_client.publish(channel, json.dumps(message)) + except Exception: + # Publishing is best-effort — never break the sync task + logger.debug("Failed to publish sync event %s for user %s", event_type, user_id, exc_info=True) diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 233efe31..a272b65d 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -1,7 +1,7 @@ from typing import Annotated from uuid import UUID -from fastapi import Depends, Header, HTTPException, status +from fastapi import Depends, Header, HTTPException, Query, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt @@ -15,6 +15,42 @@ developer_repository = DeveloperRepository(Developer) +def _decode_and_validate_token(token: str) -> dict: + """Decode JWT token and validate common claims. + + Common validation logic for both HTTP Bearer and Query param tokens. + """ + try: + payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) + + # Reject SDK-scoped tokens - they can only access /sdk/ endpoints + if payload.get("scope") == "sdk": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="SDK tokens cannot access this endpoint", + headers={"WWW-Authenticate": "Bearer"}, + ) + + developer_id: str = payload.get("sub") + if not developer_id: + # Common credential validation error for get_current_developer + # This matches legacy behavior where missing 'sub' raises generic 401 + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return payload + except JWTError as exc: + # Common credential validation error + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) from exc + + async def get_current_developer( db: DbSession, token: Annotated[str | None, Depends(oauth2_scheme)], @@ -32,22 +68,8 @@ async def get_current_developer( if not token: raise credentials_exception - try: - payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm]) - - # Reject SDK-scoped tokens - they can ONLY access /sdk/ endpoints - if payload.get("scope") == "sdk": - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="SDK tokens cannot access this endpoint", - headers={"WWW-Authenticate": "Bearer"}, - ) - - developer_id: str = payload.get("sub") - if developer_id is None: - raise credentials_exception - except JWTError: - raise credentials_exception + payload = _decode_and_validate_token(token) + developer_id = payload.get("sub") developer = developer_repository.get(db, UUID(developer_id)) if not developer: @@ -140,3 +162,31 @@ async def get_sdk_auth( SDKAuthDep = Annotated[SDKAuthContext, Depends(get_sdk_auth)] + + +def verify_query_token( + token: Annotated[str | None, Query(description="JWT Bearer token for authentication")], +) -> str: + """Validate a JWT token passed as a query parameter (for SSE/WebSocket). + + EventSource and native WebSocket APIs do not support custom HTTP headers, + so the token is passed as ``?token=`` instead of the usual Authorization header. + + Unlike ``get_current_developer``, this returns the subject ID directly + without database lookup, for minimal overhead during high-frequency checks. + + Returns: + The developer ID (``sub`` claim) from the token. + + Raises: + HTTPException(401): If token is missing, invalid, expired, or has wrong scope. + """ + if not token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication required: provide token query parameter", + ) + + # Use shared validation logic + payload = _decode_and_validate_token(token) + return payload["sub"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 746eba8a..74f81919 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -5,12 +5,12 @@ description = "" requires-python = ">=3.13" dependencies = [ "cryptography>=45.0.4", - "pydantic-settings>=2.10.1", + "pydantic-settings>=2.13.1", "email-validator>=2.2.0", "psycopg>=3.2.9", - "sqlalchemy>=2.0.43", - "fastapi>=0.120.4", - "fastapi-cli>=0.0.8", + "sqlalchemy>=2.0.47", + "fastapi>=0.135.1", + "fastapi-cli>=0.0.24", "celery>=5.5.3", "flower>=2.0.1", "redis>=7.0.1", @@ -18,7 +18,7 @@ dependencies = [ "python-multipart>=0.0.20", "python-jose[cryptography]>=3.5.0", "httpx>=0.28.1", - "alembic>=1.17.1", + "alembic>=1.18.4", "boto3>=1.40.67", "requests>=2.32.5", "bcrypt>=5.0.0", diff --git a/backend/tests/api/v1/test_auth.py b/backend/tests/api/v1/test_auth.py index 6e9eb199..b08b785a 100644 --- a/backend/tests/api/v1/test_auth.py +++ b/backend/tests/api/v1/test_auth.py @@ -105,7 +105,7 @@ def test_login_empty_credentials(self, client: TestClient, api_v1_prefix: str) - ) # Assert - assert response.status_code == 401 + assert response.status_code == 400 class TestLogout: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 8eea0333..77d6e8de 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -21,6 +21,9 @@ os.environ["ENV"] = "test" os.environ["SECRET_KEY"] = "test-secret-key-for-testing-only" os.environ["MASTER_KEY"] = "dGVzdC1tYXN0ZXIta2V5LWZvci10ZXN0aW5nLW9ubHk=" # base64 test key +# Ensure tests running locally (make test) connect to localhost services (forwarded by docker) +# This overrides config/.env which sets REDIS_HOST=redis for internal docker networking +os.environ["REDIS_HOST"] = "localhost" from app.database import BaseDbModel, _get_db_dependency from app.main import api diff --git a/backend/uv.lock b/backend/uv.lock index 9e0be945..04678502 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -4,16 +4,16 @@ requires-python = ">=3.13" [[package]] name = "alembic" -version = "1.17.2" +version = "1.18.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mako" }, { name = "sqlalchemy" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, ] [[package]] @@ -539,31 +539,32 @@ wheels = [ [[package]] name = "fastapi" -version = "0.123.0" +version = "0.135.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, { name = "pydantic" }, { name = "starlette" }, { name = "typing-extensions" }, + { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/c7/d3956d7c2da2b66188eacc8db0919635b28313a30334dd78cba4c366caf0/fastapi-0.123.0.tar.gz", hash = "sha256:1410678b3c44418245eec85088b15140d894074b86e66061017e2b492c09b138", size = 347702, upload-time = "2025-11-30T14:49:17.848Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/17/17/62c82beab6536ea72576f90b84a3dbe6bcceb88d3d46afc4d05c376f0231/fastapi-0.123.0-py3-none-any.whl", hash = "sha256:cb56e69e874afa897bd3416c8a3dbfdae1730d0a308d4c63303f3f4b44136ae4", size = 110865, upload-time = "2025-11-30T14:49:16.164Z" }, + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] [[package]] name = "fastapi-cli" -version = "0.0.16" +version = "0.0.24" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/75/9407a6b452be4c988feacec9c9d2f58d8f315162a6c7258d5a649d933ebe/fastapi_cli-0.0.16.tar.gz", hash = "sha256:e8a2a1ecf7a4e062e3b2eec63ae34387d1e142d4849181d936b23c4bdfe29073", size = 19447, upload-time = "2025-11-10T19:01:07.856Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/55/43/678528c19318394320ee43757648d5e0a8070cf391b31f69d931e5c840d2/fastapi_cli-0.0.16-py3-none-any.whl", hash = "sha256:addcb6d130b5b9c91adbbf3f2947fe115991495fdb442fe3e51b5fc6327df9f4", size = 12312, upload-time = "2025-11-10T19:01:06.728Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" }, ] [[package]] @@ -901,19 +902,19 @@ dev = [ [package.metadata] requires-dist = [ - { name = "alembic", specifier = ">=1.17.1" }, + { name = "alembic", specifier = ">=1.18.4" }, { name = "bcrypt", specifier = ">=5.0.0" }, { name = "boto3", specifier = ">=1.40.67" }, { name = "celery", specifier = ">=5.5.3" }, { name = "cryptography", specifier = ">=45.0.4" }, { name = "email-validator", specifier = ">=2.2.0" }, - { name = "fastapi", specifier = ">=0.120.4" }, - { name = "fastapi-cli", specifier = ">=0.0.8" }, + { name = "fastapi", specifier = ">=0.135.1" }, + { name = "fastapi-cli", specifier = ">=0.0.24" }, { name = "flower", specifier = ">=2.0.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "isodate", specifier = ">=0.7.2" }, { name = "psycopg", specifier = ">=3.2.9" }, - { name = "pydantic-settings", specifier = ">=2.10.1" }, + { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" }, { name = "python-multipart", specifier = ">=0.0.20" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -921,7 +922,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, { name = "resend", specifier = ">=2.0.0" }, { name = "sentry-sdk", extras = ["fastapi"], specifier = ">=2.42.1" }, - { name = "sqlalchemy", specifier = ">=2.0.43" }, + { name = "sqlalchemy", specifier = ">=2.0.47" }, ] [package.metadata.requires-dev] @@ -1135,16 +1136,16 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] @@ -1453,23 +1454,41 @@ wheels = [ [[package]] name = "sqlalchemy" -version = "2.0.44" +version = "2.0.47" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" }, - { url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" }, - { url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" }, - { url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" }, - { url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" }, - { url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" }, - { url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" }, - { url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/e5/0af64ce7d8f60ec5328c10084e2f449e7912a9b8bdbefdcfb44454a25f49/sqlalchemy-2.0.47-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:456a135b790da5d3c6b53d0ef71ac7b7d280b7f41eb0c438986352bf03ca7143", size = 2152551, upload-time = "2026-02-24T17:05:47.675Z" }, + { url = "https://files.pythonhosted.org/packages/63/79/746b8d15f6940e2ac469ce22d7aa5b1124b1ab820bad9b046eb3000c88a6/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09a2f7698e44b3135433387da5d8846cf7cc7c10e5425af7c05fee609df978b6", size = 3278782, upload-time = "2026-02-24T17:18:10.012Z" }, + { url = "https://files.pythonhosted.org/packages/91/b1/bd793ddb34345d1ed43b13ab2d88c95d7d4eb2e28f5b5a99128b9cc2bca2/sqlalchemy-2.0.47-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bbc72e6a177c78d724f9106aaddc0d26a2ada89c6332b5935414eccf04cbd5", size = 3295155, upload-time = "2026-02-24T17:27:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/97/84/7213def33f94e5ca6f5718d259bc9f29de0363134648425aa218d4356b23/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:75460456b043b78b6006e41bdf5b86747ee42eafaf7fffa3b24a6e9a456a2092", size = 3226834, upload-time = "2026-02-24T17:18:11.465Z" }, + { url = "https://files.pythonhosted.org/packages/ef/06/456810204f4dc29b5f025b1b0a03b4bd6b600ebf3c1040aebd90a257fa33/sqlalchemy-2.0.47-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5d9adaa616c3bc7d80f9ded57cd84b51d6617cad6a5456621d858c9f23aaee01", size = 3265001, upload-time = "2026-02-24T17:27:24.813Z" }, + { url = "https://files.pythonhosted.org/packages/fb/20/df3920a4b2217dbd7390a5bd277c1902e0393f42baaf49f49b3c935e7328/sqlalchemy-2.0.47-cp313-cp313-win32.whl", hash = "sha256:76e09f974382a496a5ed985db9343628b1cb1ac911f27342e4cc46a8bac10476", size = 2113647, upload-time = "2026-02-24T17:22:55.747Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/7873ddf69918efbfabd7211829f4bd8019739d0a719253112d305d3ba51d/sqlalchemy-2.0.47-cp313-cp313-win_amd64.whl", hash = "sha256:0664089b0bf6724a0bfb49a0cf4d4da24868a0a5c8e937cd7db356d5dcdf2c66", size = 2139425, upload-time = "2026-02-24T17:22:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/54/fa/61ad9731370c90ac7ea5bf8f5eaa12c48bb4beec41c0fa0360becf4ac10d/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed0c967c701ae13da98eb220f9ddab3044ab63504c1ba24ad6a59b26826ad003", size = 3558809, upload-time = "2026-02-24T17:12:15.232Z" }, + { url = "https://files.pythonhosted.org/packages/33/d5/221fac96f0529391fe374875633804c866f2b21a9c6d3a6ca57d9c12cfd7/sqlalchemy-2.0.47-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3537943a61fd25b241e976426a0c6814434b93cf9b09d39e8e78f3c9eb9a487", size = 3525480, upload-time = "2026-02-24T17:27:59.602Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/8247d53998c3673e4a8d1958eba75c6f5cc3b39082029d400bb1f2a911ae/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:57f7e336a64a0dba686c66392d46b9bc7af2c57d55ce6dc1697b4ef32b043ceb", size = 3466569, upload-time = "2026-02-24T17:12:16.94Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b5/c1f0eea1bac6790845f71420a7fe2f2a0566203aa57543117d4af3b77d1c/sqlalchemy-2.0.47-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dff735a621858680217cb5142b779bad40ef7322ddbb7c12062190db6879772e", size = 3475770, upload-time = "2026-02-24T17:28:02.034Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ed/2f43f92474ea0c43c204657dc47d9d002cd738b96ca2af8e6d29a9b5e42d/sqlalchemy-2.0.47-cp313-cp313t-win32.whl", hash = "sha256:3893dc096bb3cca9608ea3487372ffcea3ae9b162f40e4d3c51dd49db1d1b2dc", size = 2141300, upload-time = "2026-02-24T17:14:37.024Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/8b73f9f1695b6e92f7aaf1711135a1e3bbeb78bca9eded35cb79180d3c6d/sqlalchemy-2.0.47-cp313-cp313t-win_amd64.whl", hash = "sha256:b5103427466f4b3e61f04833ae01f9a914b1280a2a8bcde3a9d7ab11f3755b42", size = 2173053, upload-time = "2026-02-24T17:14:38.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, + { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, ] [[package]] diff --git a/frontend/src/components/user/connection-card.tsx b/frontend/src/components/user/connection-card.tsx index 16befd8c..4f7c691f 100644 --- a/frontend/src/components/user/connection-card.tsx +++ b/frontend/src/components/user/connection-card.tsx @@ -10,7 +10,7 @@ import { XCircle, } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; -import { UserConnection } from '@/lib/api/types'; +import { UserConnection, SyncProgress } from '@/lib/api/types'; import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -28,10 +28,15 @@ import { useGarminCancelBackfill, useRetryGarminBackfill, } from '@/hooks/api/use-health'; +import { SyncProgressOverlay } from '@/components/user/sync-progress-overlay'; interface ConnectionCardProps { connection: UserConnection; className?: string; + /** Real-time sync progress from SSE (passed from parent ProfileSection) */ + syncProgress?: SyncProgress; + /** Callback to start SSE listening when a sync is triggered */ + onSyncStarted?: () => void; } // Format data type name for display (e.g., "bodyComps" -> "Body Comps") @@ -48,7 +53,12 @@ function parseScopeString(scope: string): string[] { return scope.split(/[,\s]+/).filter(Boolean); } -export function ConnectionCard({ connection, className }: ConnectionCardProps) { +export function ConnectionCard({ + connection, + className, + syncProgress, + onSyncStarted, +}: ConnectionCardProps) { const { mutate: synchronizeDataFromProvider, isPending: isSynchronizing } = useSynchronizeDataFromProvider(connection.provider, connection.user_id); @@ -336,26 +346,38 @@ export function ConnectionCard({ connection, className }: ConnectionCardProps) { {/* Sync button - only for non-Garmin providers */} {connection.provider !== 'garmin' && ( -
- + +
+ +
)} diff --git a/frontend/src/components/user/profile-section.tsx b/frontend/src/components/user/profile-section.tsx index 23586a3c..6b55fe14 100644 --- a/frontend/src/components/user/profile-section.tsx +++ b/frontend/src/components/user/profile-section.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; import { Link as LinkIcon, Check, Copy, Pencil } from 'lucide-react'; import { useUserConnections } from '@/hooks/api/use-health'; +import { useSyncEvents } from '@/hooks/api/use-sync-events'; import { useUser, useUpdateUser } from '@/hooks/api/use-users'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -26,6 +27,7 @@ export function ProfileSection({ userId }: ProfileSectionProps) { const { data: connections, isLoading: connectionsLoading } = useUserConnections(userId); const { mutate: updateUser, isPending: isUpdating } = useUpdateUser(); + const { progress: syncProgress, startListening } = useSyncEvents(userId); const [copied, setCopied] = useState(false); const [copiedUserId, setCopiedUserId] = useState(false); @@ -193,7 +195,12 @@ export function ProfileSection({ userId }: ProfileSectionProps) { ) : connections && connections.length > 0 ? (
{connections.map((connection) => ( - + ))}
) : ( diff --git a/frontend/src/components/user/sync-progress-overlay.tsx b/frontend/src/components/user/sync-progress-overlay.tsx new file mode 100644 index 00000000..f733ed76 --- /dev/null +++ b/frontend/src/components/user/sync-progress-overlay.tsx @@ -0,0 +1,99 @@ +import { CheckCircle2, Loader2, XCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { SyncProgress } from '@/lib/api/types'; + +interface SyncProgressOverlayProps { + progress: SyncProgress; + className?: string; +} + +/** + * Inline progress indicator shown inside a ConnectionCard while a sync + * is running. Displays the current step, a small progress bar, and the + * list of completed / errored providers. + */ +export function SyncProgressOverlay({ + progress, + className, +}: SyncProgressOverlayProps) { + if (!progress.active && progress.events.length === 0) return null; + + const pct = + progress.totalProviders > 0 + ? Math.round( + ((progress.completedProviders.length + + progress.errorProviders.length) / + progress.totalProviders) * + 100 + ) + : 0; + + const isTerminal = !progress.active && progress.events.length > 0; + const hasErrors = progress.errorProviders.length > 0; + const lastEvent = progress.events[progress.events.length - 1]; + const isError = lastEvent?.type === 'sync:error'; + + return ( +
+ {/* Status line */} +
+ {progress.active ? ( + + ) : isError || (isTerminal && hasErrors) ? ( + + ) : ( + + )} + + + {progress.message} + +
+ + {/* Progress bar (visible while active or just finished) */} + {(progress.active || isTerminal) && progress.totalProviders > 0 && ( +
+
+
+ )} + + {/* Provider pill list */} + {progress.providers.length > 1 && ( +
+ {progress.providers.map((p) => { + const done = progress.completedProviders.includes(p); + const err = progress.errorProviders.includes(p); + const current = progress.currentProvider === p && progress.active; + return ( + + {current && } + {done && } + {err && } + {p} + + ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/hooks/api/use-sync-events.ts b/frontend/src/hooks/api/use-sync-events.ts new file mode 100644 index 00000000..bb338e7f --- /dev/null +++ b/frontend/src/hooks/api/use-sync-events.ts @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { API_CONFIG, API_ENDPOINTS } from '@/lib/api/config'; +import { getToken } from '@/lib/auth/session'; +import type { SyncEvent, SyncProgress } from '@/lib/api/types'; +import { queryClient } from '@/lib/query/client'; +import { queryKeys } from '@/lib/query/keys'; +import { buildSyncMessage, getStepFromEvent } from '@/lib/utils/sync-messages'; + +const INITIAL_PROGRESS: SyncProgress = { + active: false, + taskId: null, + providers: [], + currentProvider: null, + message: '', + currentStep: null, + currentIndex: 0, + totalProviders: 0, + events: [], + completedProviders: [], + errorProviders: [], +}; + +/** + * Hook that manages an SSE connection for real-time sync progress. + * + * Returns: + * - `progress`: current SyncProgress state + * - `startListening(userId)`: opens an EventSource for the given user + * - `stopListening()`: manually close the connection + */ +export function useSyncEvents(userId: string) { + const [progress, setProgress] = useState(INITIAL_PROGRESS); + const eventSourceRef = useRef(null); + + const stopListening = useCallback(() => { + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } + }, []); + + // Clean up on unmount + useEffect(() => { + return () => stopListening(); + }, [stopListening]); + + const startListening = useCallback(() => { + // Close any existing connection + stopListening(); + + const token = getToken(); + if (!token) return; + + const url = `${API_CONFIG.baseUrl}${API_ENDPOINTS.syncEvents(userId)}?token=${encodeURIComponent(token)}`; + const es = new EventSource(url); + eventSourceRef.current = es; + + // Reset progress + setProgress({ ...INITIAL_PROGRESS, active: true }); + + const handleMessage = (e: MessageEvent) => { + try { + const event: SyncEvent = JSON.parse(e.data); + + setProgress((prev) => { + const events = [...prev.events, event]; + const next: SyncProgress = { ...prev, events }; + + switch (event.type) { + case 'sync:started': + next.providers = (event.data?.providers as string[]) ?? []; + next.totalProviders = + (event.data?.total_providers as number) ?? 0; + next.taskId = event.task_id ?? null; + break; + + case 'sync:provider:started': + next.currentProvider = event.provider ?? null; + next.currentIndex = + (event.data?.index as number) ?? prev.currentIndex; + next.currentStep = null; + break; + + case 'sync:provider:workouts:started': + case 'sync:provider:workouts:completed': + case 'sync:provider:workouts:error': + case 'sync:provider:247:started': + case 'sync:provider:247:completed': + case 'sync:provider:247:error': + next.currentStep = getStepFromEvent(event.type); + break; + + case 'sync:provider:completed': + if (event.provider) { + next.completedProviders = [ + ...prev.completedProviders, + event.provider, + ]; + } + next.currentStep = null; + break; + + case 'sync:provider:error': + if (event.provider) { + next.errorProviders = [...prev.errorProviders, event.provider]; + } + next.currentStep = null; + break; + + case 'sync:completed': + next.active = false; + next.currentProvider = null; + next.currentStep = null; + break; + + case 'sync:error': + next.active = false; + next.currentProvider = null; + next.currentStep = null; + break; + } + + next.message = buildSyncMessage(event); + return next; + }); + + // Handle side effects outside setProgress + if (event.type === 'sync:completed') { + queryClient.invalidateQueries({ + queryKey: queryKeys.connections.all(userId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.health.workouts(userId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.health.activitySummaries(userId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.health.sleepSessions(userId), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.health.bodySummary(userId), + }); + } + + // Close EventSource on terminal events + if (event.type === 'sync:completed' || event.type === 'sync:error') { + es.close(); + eventSourceRef.current = null; + } + } catch { + // Ignore malformed messages + } + }; + + const ALL_EVENTS = [ + 'sync:started', + 'sync:provider:started', + 'sync:provider:workouts:started', + 'sync:provider:workouts:completed', + 'sync:provider:workouts:error', + 'sync:provider:247:started', + 'sync:provider:247:completed', + 'sync:provider:247:error', + 'sync:provider:completed', + 'sync:provider:error', + 'sync:completed', + 'sync:error', + ]; + + for (const eventName of ALL_EVENTS) { + es.addEventListener(eventName, handleMessage); + } + + es.onerror = () => { + // EventSource auto-reconnects on most errors. + // Reset progress to inactive in non-open states + if (es.readyState !== EventSource.OPEN) { + setProgress({ ...INITIAL_PROGRESS, active: false }); + } + + // If the connection is closed by server, readyState becomes CLOSED. + if (es.readyState === EventSource.CLOSED) { + eventSourceRef.current = null; + } + }; + }, [userId, stopListening]); + + const reset = useCallback(() => { + stopListening(); + setProgress(INITIAL_PROGRESS); + }, [stopListening]); + + return { progress, startListening, stopListening, reset }; +} diff --git a/frontend/src/lib/api/config.ts b/frontend/src/lib/api/config.ts index c62df41b..63ada105 100644 --- a/frontend/src/lib/api/config.ts +++ b/frontend/src/lib/api/config.ts @@ -40,6 +40,7 @@ export const API_ENDPOINTS = { // Provider workouts endpoints providerSynchronization: (provider: string, userId: string) => `/api/v1/providers/${provider}/users/${userId}/sync`, + syncEvents: (userId: string) => `/api/v1/users/${userId}/sync/events`, providerWorkouts: (provider: string, userId: string) => `/api/v1/providers/${provider}/users/${userId}/workouts`, providerWorkoutDetail: ( diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index a6b8a303..7460bc1a 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -570,6 +570,54 @@ export interface SyncResponse { message: string; } +// SSE Sync Event Types (real-time sync progress via Server-Sent Events) +export type SyncEventType = + | 'sync:started' + | 'sync:provider:started' + | 'sync:provider:workouts:started' + | 'sync:provider:workouts:completed' + | 'sync:provider:workouts:error' + | 'sync:provider:247:started' + | 'sync:provider:247:completed' + | 'sync:provider:247:error' + | 'sync:provider:completed' + | 'sync:provider:error' + | 'sync:completed' + | 'sync:error'; + +export interface SyncEvent { + type: SyncEventType; + timestamp: string; + task_id?: string; + provider?: string; + data?: Record; +} + +export interface SyncProgress { + /** Whether a sync is currently in progress */ + active: boolean; + /** Currently active task ID */ + taskId: string | null; + /** All providers involved in this sync */ + providers: string[]; + /** Provider currently being synced */ + currentProvider: string | null; + /** Human-readable status message */ + message: string; + /** What step is currently running (workouts / 247 / null) */ + currentStep: string | null; + /** Index of the provider currently being synced (0-based) */ + currentIndex: number; + /** Total number of providers to sync */ + totalProviders: number; + /** List of all SSE events received */ + events: SyncEvent[]; + /** Providers that finished successfully */ + completedProviders: string[]; + /** Providers that encountered errors */ + errorProviders: string[]; +} + // Garmin Backfill Types (webhook-based, multi-window sequential sync) export interface BackfillWindowStatus { [dataType: string]: 'done' | 'pending' | 'timed_out' | 'failed'; diff --git a/frontend/src/lib/utils/sync-messages.ts b/frontend/src/lib/utils/sync-messages.ts new file mode 100644 index 00000000..e3e098cc --- /dev/null +++ b/frontend/src/lib/utils/sync-messages.ts @@ -0,0 +1,49 @@ +import type { SyncEvent } from '@/lib/api/types'; + +/** + * Convert a raw SSE sync event into a human-readable status message. + */ +export function buildSyncMessage(event: SyncEvent): string { + const provider = event.provider || 'Provider'; + const data = event.data || {}; + + switch (event.type) { + case 'sync:started': { + const count = (data.total_providers as number) ?? 0; + return `Starting sync for ${count} provider${count !== 1 ? 's' : ''}…`; + } + case 'sync:provider:started': + return `Connecting to ${provider}…`; + case 'sync:provider:workouts:started': + return `Fetching workouts from ${provider}…`; + case 'sync:provider:workouts:completed': + return `Workouts from ${provider} downloaded`; + case 'sync:provider:workouts:error': + return `Failed to fetch workouts from ${provider}`; + case 'sync:provider:247:started': + return `Fetching health data (sleep, activity) from ${provider}…`; + case 'sync:provider:247:completed': + return `Health data from ${provider} downloaded`; + case 'sync:provider:247:error': + return `Failed to fetch health data from ${provider}`; + case 'sync:provider:completed': + return `${provider} sync complete`; + case 'sync:provider:error': + return `${provider} sync failed`; + case 'sync:completed': + return 'Sync completed successfully'; + case 'sync:error': + return `Sync error: ${(data.error as string) ?? 'Unknown error'}`; + default: + return ''; + } +} + +/** + * Determine the current high-level step from the event type. + */ +export function getStepFromEvent(eventType: string): 'workouts' | '247' | null { + if (eventType.includes('workouts')) return 'workouts'; + if (eventType.includes('247')) return '247'; + return null; +}