garsync 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
garsync-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: garsync
3
+ Version: 0.1.2
4
+ Summary:
5
+ Author: Manu
6
+ Author-email: mlorentedev@gmail.com
7
+ Requires-Python: >=3.12
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: fastapi (>=0.115.0,<1.0.0)
13
+ Requires-Dist: garminconnect (>=0.2.38,<0.3.0)
14
+ Requires-Dist: pandas (<3)
15
+ Requires-Dist: plotly (>=6.5.2,<7.0.0)
16
+ Requires-Dist: pydantic (>=2.12.5,<3.0.0)
17
+ Requires-Dist: rich (>=14.3.3,<15.0.0)
18
+ Requires-Dist: streamlit (>=1.54.0,<2.0.0)
19
+ Requires-Dist: tenacity (>=9.1.4,<10.0.0)
20
+ Requires-Dist: typer (>=0.24.1,<0.25.0)
21
+ Requires-Dist: uvicorn[standard] (>=0.34.0,<1.0.0)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # GarSync
25
+
26
+ [![CI](https://github.com/mlorentedev/garsync/actions/workflows/ci.yml/badge.svg)](https://github.com/mlorentedev/garsync/actions)
27
+ [![Docs](https://img.shields.io/badge/docs-live-brightgreen.svg)](https://mlorentedev.github.io/garsync/)
28
+ [![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/release/python-3120/)
29
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
30
+
31
+ Personal fitness data pipeline that syncs Garmin Connect data to a local SQLite database and visualizes it through a modern Astro dashboard.
32
+
33
+ > **Explore the full documentation at: [mlorentedev.github.io/garsync](https://mlorentedev.github.io/garsync/)**
34
+
35
+ ## The Problem
36
+
37
+ Garmin Connect provides great data, but it's locked in a proprietary cloud.
38
+ - **Data Silos:** Hard to export full history for custom analysis.
39
+ - **Limited Visualization:** You are stuck with the official app's charts.
40
+ - **No Local Ownership:** If you lose access or the service is down, your training history is gone.
41
+
42
+ ## Value Proposition
43
+
44
+ | Feature | Garmin Connect App | GarSync |
45
+ |---|---|---|
46
+ | **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
47
+ | **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
48
+ | **Access** | Web/Mobile Only | REST API + SQL + CLI |
49
+ | **Automation** | Manual Export | Scheduled Incremental Sync |
50
+
51
+ ## Quick Start
52
+
53
+ ### 1. Prerequisites
54
+ - Python 3.12+ and [Poetry](https://python-poetry.org/)
55
+ - Node.js 22+ (for the dashboard)
56
+ - [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age) (for secrets)
57
+
58
+ ### 2. Setup
59
+ ```bash
60
+ make setup
61
+ ```
62
+
63
+ ### 3. Configure Secrets
64
+ Edit your Garmin credentials using SOPS:
65
+ ```bash
66
+ sops secrets.env.enc
67
+ ```
68
+
69
+ ### 4. Sync & Launch
70
+ ```bash
71
+ make sync DAYS=30
72
+ make dev
73
+ ```
74
+ Visit `http://localhost:4321` (Dev UI).
75
+
76
+ ## Architecture
77
+
78
+ ```
79
+ Garmin Connect Cloud → GarSync CLI → SQLite DB → FastAPI → Astro Dashboard
80
+ ```
81
+
82
+ ## License
83
+
84
+ MIT — see [LICENSE](LICENSE).
85
+
@@ -0,0 +1,61 @@
1
+ # GarSync
2
+
3
+ [![CI](https://github.com/mlorentedev/garsync/actions/workflows/ci.yml/badge.svg)](https://github.com/mlorentedev/garsync/actions)
4
+ [![Docs](https://img.shields.io/badge/docs-live-brightgreen.svg)](https://mlorentedev.github.io/garsync/)
5
+ [![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/release/python-3120/)
6
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
7
+
8
+ Personal fitness data pipeline that syncs Garmin Connect data to a local SQLite database and visualizes it through a modern Astro dashboard.
9
+
10
+ > **Explore the full documentation at: [mlorentedev.github.io/garsync](https://mlorentedev.github.io/garsync/)**
11
+
12
+ ## The Problem
13
+
14
+ Garmin Connect provides great data, but it's locked in a proprietary cloud.
15
+ - **Data Silos:** Hard to export full history for custom analysis.
16
+ - **Limited Visualization:** You are stuck with the official app's charts.
17
+ - **No Local Ownership:** If you lose access or the service is down, your training history is gone.
18
+
19
+ ## Value Proposition
20
+
21
+ | Feature | Garmin Connect App | GarSync |
22
+ |---|---|---|
23
+ | **Data Ownership** | Proprietary Cloud | Local SQLite (Full Control) |
24
+ | **Customization** | Fixed Dashboards | Extensible Astro + Chart.js |
25
+ | **Access** | Web/Mobile Only | REST API + SQL + CLI |
26
+ | **Automation** | Manual Export | Scheduled Incremental Sync |
27
+
28
+ ## Quick Start
29
+
30
+ ### 1. Prerequisites
31
+ - Python 3.12+ and [Poetry](https://python-poetry.org/)
32
+ - Node.js 22+ (for the dashboard)
33
+ - [SOPS](https://github.com/getsops/sops) + [Age](https://github.com/FiloSottile/age) (for secrets)
34
+
35
+ ### 2. Setup
36
+ ```bash
37
+ make setup
38
+ ```
39
+
40
+ ### 3. Configure Secrets
41
+ Edit your Garmin credentials using SOPS:
42
+ ```bash
43
+ sops secrets.env.enc
44
+ ```
45
+
46
+ ### 4. Sync & Launch
47
+ ```bash
48
+ make sync DAYS=30
49
+ make dev
50
+ ```
51
+ Visit `http://localhost:4321` (Dev UI).
52
+
53
+ ## Architecture
54
+
55
+ ```
56
+ Garmin Connect Cloud → GarSync CLI → SQLite DB → FastAPI → Astro Dashboard
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,62 @@
1
+ [project]
2
+ name = "garsync"
3
+ version = "0.1.2"
4
+ description = ""
5
+ authors = [
6
+ {name = "Manu",email = "mlorentedev@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12"
10
+ dependencies = [
11
+ "garminconnect (>=0.2.38,<0.3.0)",
12
+ "pydantic (>=2.12.5,<3.0.0)",
13
+ "tenacity (>=9.1.4,<10.0.0)",
14
+ "typer (>=0.24.1,<0.25.0)",
15
+ "rich (>=14.3.3,<15.0.0)",
16
+ "pandas (<3)",
17
+ "streamlit (>=1.54.0,<2.0.0)",
18
+ "plotly (>=6.5.2,<7.0.0)",
19
+ "fastapi (>=0.115.0,<1.0.0)",
20
+ "uvicorn[standard] (>=0.34.0,<1.0.0)",
21
+ ]
22
+
23
+
24
+ [build-system]
25
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
26
+ build-backend = "poetry.core.masonry.api"
27
+
28
+ [project.scripts]
29
+ garsync = "garsync.cli:app"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "typer (>=0.24.1,<0.25.0)",
34
+ "rich (>=14.3.3,<15.0.0)",
35
+ "pydantic (>=2.12.5,<3.0.0)",
36
+ "pytest (>=9.0.2,<10.0.0)",
37
+ "pytest-cov (>=7.0.0,<8.0.0)",
38
+ "mypy (>=1.19.1,<2.0.0)",
39
+ "ruff (>=0.15.4,<0.16.0)",
40
+ "pytest-mock (>=3.0.0,<4.0.0)",
41
+ "httpx (>=0.28.0,<1.0.0)"
42
+ ]
43
+
44
+ [tool.mypy]
45
+ strict = true
46
+
47
+ [[tool.mypy.overrides]]
48
+ module = "garminconnect.*"
49
+ ignore_missing_imports = true
50
+
51
+ [[tool.mypy.overrides]]
52
+ module = "garsync.client"
53
+ disallow_untyped_calls = false
54
+ disallow_untyped_defs = false
55
+
56
+ [tool.pytest.ini_options]
57
+ testpaths = ["tests"]
58
+ addopts = "-q"
59
+
60
+ [tool.ruff]
61
+ target-version = "py312"
62
+ line-length = 100
@@ -0,0 +1,3 @@
1
+ """Garmin Sync Microservice"""
2
+
3
+ __version__ = "0.1.2"
@@ -0,0 +1,4 @@
1
+ from garsync.cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -0,0 +1,5 @@
1
+ """FastAPI application package for garsync."""
2
+
3
+ from garsync.api.main import create_app
4
+
5
+ __all__ = ["create_app"]
@@ -0,0 +1,38 @@
1
+ """FastAPI dependency injection for database and repositories."""
2
+
3
+ import sqlite3
4
+
5
+ from fastapi import Request
6
+
7
+ from garsync.db.repository import (
8
+ ActivityRepository,
9
+ BiometricsRepository,
10
+ SleepRepository,
11
+ SyncLogRepository,
12
+ )
13
+
14
+
15
+ def get_db(request: Request) -> sqlite3.Connection:
16
+ """Get the SQLite connection from app state."""
17
+ conn: sqlite3.Connection = request.app.state.db
18
+ return conn
19
+
20
+
21
+ def get_activity_repo(request: Request) -> ActivityRepository:
22
+ """Factory for ActivityRepository."""
23
+ return ActivityRepository(get_db(request))
24
+
25
+
26
+ def get_biometrics_repo(request: Request) -> BiometricsRepository:
27
+ """Factory for BiometricsRepository."""
28
+ return BiometricsRepository(get_db(request))
29
+
30
+
31
+ def get_sleep_repo(request: Request) -> SleepRepository:
32
+ """Factory for SleepRepository."""
33
+ return SleepRepository(get_db(request))
34
+
35
+
36
+ def get_sync_log_repo(request: Request) -> SyncLogRepository:
37
+ """Factory for SyncLogRepository."""
38
+ return SyncLogRepository(get_db(request))
@@ -0,0 +1,83 @@
1
+ """FastAPI application factory for garsync."""
2
+
3
+ import os
4
+ import sqlite3
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from fastapi import FastAPI, Request, Response, status
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from fastapi.staticfiles import StaticFiles
13
+
14
+ from garsync.api.routes import activities, biometrics, sleep, stats, sync
15
+ from garsync.db.connection import get_connection
16
+ from garsync.db.schema import init_db
17
+
18
+
19
+ @asynccontextmanager
20
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
21
+ """Manage database connection lifecycle."""
22
+ db_path = os.environ.get("GARSYNC_DB_PATH", "data/garsync.db")
23
+ conn = get_connection(db_path)
24
+ init_db(conn)
25
+ app.state.db = conn
26
+ yield
27
+ conn.close()
28
+
29
+
30
+ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
31
+ """Create and configure the FastAPI application.
32
+
33
+ Args:
34
+ conn: Optional pre-configured connection (for testing).
35
+ If provided, lifespan is skipped.
36
+ """
37
+ if conn is not None:
38
+ app = FastAPI(title="GarSync API", version="0.1.0")
39
+ app.state.db = conn
40
+ else:
41
+ app = FastAPI(title="GarSync API", version="0.1.0", lifespan=lifespan)
42
+
43
+ # API Key Middleware
44
+ # API Key Middleware
45
+ api_key = os.environ.get("GARSYNC_API_KEY", "dev_key")
46
+
47
+ @app.middleware("http")
48
+ async def api_key_auth(request: Request, call_next: Any) -> Response:
49
+ if request.url.path.startswith("/api/"):
50
+ request_key = request.headers.get("X-API-KEY")
51
+ if request_key != api_key:
52
+ # Direct response for middleware
53
+ from fastapi.responses import JSONResponse
54
+
55
+ return JSONResponse(
56
+ status_code=status.HTTP_403_FORBIDDEN,
57
+ content={"detail": "Invalid or missing API Key"},
58
+ )
59
+ res: Response = await call_next(request)
60
+ return res
61
+
62
+ app.add_middleware(
63
+ CORSMiddleware,
64
+ allow_origins=["*"],
65
+ allow_credentials=True,
66
+ allow_methods=["*"],
67
+ allow_headers=["*"],
68
+ )
69
+
70
+ app.include_router(activities.router)
71
+ app.include_router(biometrics.router)
72
+ app.include_router(sleep.router)
73
+ app.include_router(stats.router)
74
+ app.include_router(sync.router)
75
+
76
+ static_dir = Path(__file__).resolve().parents[3] / "frontend" / "dist"
77
+ if static_dir.is_dir():
78
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
79
+
80
+ return app
81
+
82
+
83
+ app = create_app()
@@ -0,0 +1 @@
1
+ """Route modules for the garsync API."""
@@ -0,0 +1,38 @@
1
+ """Activity endpoints."""
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter, Depends, Query
6
+
7
+ from garsync.api.deps import get_activity_repo
8
+ from garsync.api.schemas import ActivityItem, PaginatedActivities
9
+ from garsync.db.repository import ActivityRepository
10
+
11
+ router = APIRouter(prefix="/api", tags=["activities"])
12
+
13
+
14
+ @router.get("/activities", response_model=PaginatedActivities)
15
+ def list_activities(
16
+ page: int = Query(default=1, ge=1),
17
+ limit: int = Query(default=20, ge=1, le=100),
18
+ start_date: Optional[str] = Query(default=None),
19
+ end_date: Optional[str] = Query(default=None),
20
+ activity_type: Optional[str] = Query(default=None),
21
+ repo: ActivityRepository = Depends(get_activity_repo),
22
+ ) -> PaginatedActivities:
23
+ """List activities with pagination and optional filters."""
24
+ rows, total = repo.get_paginated(
25
+ page=page,
26
+ limit=limit,
27
+ start_date=start_date,
28
+ end_date=end_date,
29
+ activity_type=activity_type,
30
+ )
31
+ items = [ActivityItem(**dict(row)) for row in rows]
32
+ return PaginatedActivities(
33
+ items=items,
34
+ total=total,
35
+ page=page,
36
+ limit=limit,
37
+ has_more=(page * limit) < total,
38
+ )
@@ -0,0 +1,26 @@
1
+ """Biometrics endpoints."""
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+
5
+ from garsync.api.deps import get_biometrics_repo
6
+ from garsync.api.schemas import BiometricItem, BiometricsResponse
7
+ from garsync.db.repository import BiometricsRepository
8
+
9
+ router = APIRouter(prefix="/api", tags=["biometrics"])
10
+
11
+
12
+ @router.get("/biometrics", response_model=BiometricsResponse)
13
+ def list_biometrics(
14
+ start_date: str = Query(),
15
+ end_date: str = Query(),
16
+ repo: BiometricsRepository = Depends(get_biometrics_repo),
17
+ ) -> BiometricsResponse:
18
+ """List biometrics within a date range."""
19
+ rows = repo.get_by_date_range(start_date, end_date)
20
+ metrics = [BiometricItem(**dict(row)) for row in rows]
21
+ return BiometricsResponse(
22
+ metrics=metrics,
23
+ start_date=start_date,
24
+ end_date=end_date,
25
+ count=len(metrics),
26
+ )
@@ -0,0 +1,26 @@
1
+ """Sleep endpoints."""
2
+
3
+ from fastapi import APIRouter, Depends, Query
4
+
5
+ from garsync.api.deps import get_sleep_repo
6
+ from garsync.api.schemas import SleepItem, SleepResponse
7
+ from garsync.db.repository import SleepRepository
8
+
9
+ router = APIRouter(prefix="/api", tags=["sleep"])
10
+
11
+
12
+ @router.get("/sleep", response_model=SleepResponse)
13
+ def list_sleep(
14
+ start_date: str = Query(),
15
+ end_date: str = Query(),
16
+ repo: SleepRepository = Depends(get_sleep_repo),
17
+ ) -> SleepResponse:
18
+ """List sleep sessions within a date range."""
19
+ rows = repo.get_by_date_range(start_date, end_date)
20
+ sessions = [SleepItem(**dict(row)) for row in rows]
21
+ return SleepResponse(
22
+ sleep_sessions=sessions,
23
+ start_date=start_date,
24
+ end_date=end_date,
25
+ count=len(sessions),
26
+ )
@@ -0,0 +1,124 @@
1
+ """Stats endpoints — summary and heatmap."""
2
+
3
+ from datetime import date, timedelta
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter, Depends, Query
7
+
8
+ from garsync.api.deps import get_activity_repo, get_biometrics_repo, get_sleep_repo
9
+ from garsync.api.schemas import (
10
+ HeatmapDay,
11
+ HeatmapResponse,
12
+ HeatmapStatistics,
13
+ SummaryStats,
14
+ )
15
+ from garsync.db.repository import (
16
+ ActivityRepository,
17
+ BiometricsRepository,
18
+ SleepRepository,
19
+ )
20
+
21
+ router = APIRouter(prefix="/api/stats", tags=["stats"])
22
+
23
+
24
+ def _resolve_dates(
25
+ period: str,
26
+ start_date: Optional[str],
27
+ end_date: Optional[str],
28
+ ) -> tuple[str, str]:
29
+ """Resolve start/end dates from period or explicit params."""
30
+ today = date.today()
31
+ if start_date and end_date:
32
+ return start_date, end_date
33
+ if period == "week":
34
+ start = today - timedelta(days=7)
35
+ elif period == "month":
36
+ start = today - timedelta(days=30)
37
+ else:
38
+ start = today - timedelta(days=30)
39
+ return start.isoformat(), today.isoformat()
40
+
41
+
42
+ @router.get("/summary", response_model=SummaryStats)
43
+ def summary(
44
+ period: str = Query(default="week"),
45
+ start_date: Optional[str] = Query(default=None),
46
+ end_date: Optional[str] = Query(default=None),
47
+ activity_repo: ActivityRepository = Depends(get_activity_repo),
48
+ biometrics_repo: BiometricsRepository = Depends(get_biometrics_repo),
49
+ sleep_repo: SleepRepository = Depends(get_sleep_repo),
50
+ ) -> SummaryStats:
51
+ """Get aggregated stats for a period."""
52
+ sd, ed = _resolve_dates(period, start_date, end_date)
53
+
54
+ activity_stats = activity_repo.get_summary_stats(sd, ed)
55
+ bio_stats = biometrics_repo.get_avg_stats(sd, ed)
56
+ sleep_stats = sleep_repo.get_avg_stats(sd, ed)
57
+
58
+ return SummaryStats(
59
+ period=period,
60
+ start_date=sd,
61
+ end_date=ed,
62
+ total_activities=activity_stats["total_activities"] if activity_stats else 0,
63
+ total_duration_seconds=activity_stats["total_duration_seconds"] if activity_stats else 0.0,
64
+ total_distance_meters=activity_stats["total_distance_meters"] if activity_stats else 0.0,
65
+ total_calories=activity_stats["total_calories"] if activity_stats else 0.0,
66
+ avg_duration_seconds=activity_stats["avg_duration_seconds"] if activity_stats else None,
67
+ avg_distance_meters=activity_stats["avg_distance_meters"] if activity_stats else None,
68
+ avg_heart_rate=activity_stats["avg_heart_rate"] if activity_stats else None,
69
+ avg_resting_heart_rate=bio_stats["avg_resting_heart_rate"] if bio_stats else None,
70
+ avg_stress=bio_stats["avg_stress"] if bio_stats else None,
71
+ avg_body_battery_high=bio_stats["avg_body_battery_high"] if bio_stats else None,
72
+ avg_sleep_seconds=sleep_stats["avg_sleep_seconds"] if sleep_stats else None,
73
+ avg_sleep_score=sleep_stats["avg_sleep_score"] if sleep_stats else None,
74
+ )
75
+
76
+
77
+ def _compute_intensity(count: int, max_count: int) -> int:
78
+ """Map activity count to 0-5 intensity level."""
79
+ if count == 0 or max_count == 0:
80
+ return 0
81
+ ratio = count / max_count
82
+ if ratio <= 0.2:
83
+ return 1
84
+ if ratio <= 0.4:
85
+ return 2
86
+ if ratio <= 0.6:
87
+ return 3
88
+ if ratio <= 0.8:
89
+ return 4
90
+ return 5
91
+
92
+
93
+ @router.get("/heatmap", response_model=HeatmapResponse)
94
+ def heatmap(
95
+ year: Optional[int] = Query(default=None),
96
+ activity_type: Optional[str] = Query(default=None),
97
+ repo: ActivityRepository = Depends(get_activity_repo),
98
+ ) -> HeatmapResponse:
99
+ """Get activity heatmap data for a year."""
100
+ target_year = year or date.today().year
101
+ rows = repo.get_heatmap_data(target_year, activity_type)
102
+
103
+ max_count = max((row["activity_count"] for row in rows), default=0)
104
+ days = [
105
+ HeatmapDay(
106
+ date=row["date"],
107
+ activity_count=row["activity_count"],
108
+ total_duration=row["total_duration"],
109
+ total_calories=row["total_calories"],
110
+ intensity_level=_compute_intensity(row["activity_count"], max_count),
111
+ )
112
+ for row in rows
113
+ ]
114
+
115
+ total_activities = sum(d.activity_count for d in days)
116
+ return HeatmapResponse(
117
+ year=target_year,
118
+ days=days,
119
+ statistics=HeatmapStatistics(
120
+ total_active_days=len(days),
121
+ total_activities=total_activities,
122
+ max_daily_count=max_count,
123
+ ),
124
+ )
@@ -0,0 +1,38 @@
1
+ """Sync status endpoint."""
2
+
3
+ from fastapi import APIRouter, Depends
4
+
5
+ from garsync.api.deps import (
6
+ get_activity_repo,
7
+ get_biometrics_repo,
8
+ get_sleep_repo,
9
+ get_sync_log_repo,
10
+ )
11
+ from garsync.api.schemas import SyncStatus
12
+ from garsync.db.repository import (
13
+ ActivityRepository,
14
+ BiometricsRepository,
15
+ SleepRepository,
16
+ SyncLogRepository,
17
+ )
18
+
19
+ router = APIRouter(prefix="/api", tags=["sync"])
20
+
21
+
22
+ @router.get("/sync/status", response_model=SyncStatus)
23
+ def sync_status(
24
+ activity_repo: ActivityRepository = Depends(get_activity_repo),
25
+ biometrics_repo: BiometricsRepository = Depends(get_biometrics_repo),
26
+ sleep_repo: SleepRepository = Depends(get_sleep_repo),
27
+ sync_log_repo: SyncLogRepository = Depends(get_sync_log_repo),
28
+ ) -> SyncStatus:
29
+ """Get current sync status and record counts."""
30
+ latest = sync_log_repo.get_latest()
31
+ return SyncStatus(
32
+ last_sync_time=latest["created_at"] if latest else None,
33
+ last_sync_status=latest["status"] if latest else None,
34
+ total_activities=activity_repo.count(),
35
+ total_biometrics=biometrics_repo.count(),
36
+ total_sleep=sleep_repo.count(),
37
+ total_sync_logs=sync_log_repo.count(),
38
+ )