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 +85 -0
- garsync-0.1.2/README.md +61 -0
- garsync-0.1.2/pyproject.toml +62 -0
- garsync-0.1.2/src/garsync/__init__.py +3 -0
- garsync-0.1.2/src/garsync/__main__.py +4 -0
- garsync-0.1.2/src/garsync/api/__init__.py +5 -0
- garsync-0.1.2/src/garsync/api/deps.py +38 -0
- garsync-0.1.2/src/garsync/api/main.py +83 -0
- garsync-0.1.2/src/garsync/api/routes/__init__.py +1 -0
- garsync-0.1.2/src/garsync/api/routes/activities.py +38 -0
- garsync-0.1.2/src/garsync/api/routes/biometrics.py +26 -0
- garsync-0.1.2/src/garsync/api/routes/sleep.py +26 -0
- garsync-0.1.2/src/garsync/api/routes/stats.py +124 -0
- garsync-0.1.2/src/garsync/api/routes/sync.py +38 -0
- garsync-0.1.2/src/garsync/api/schemas.py +121 -0
- garsync-0.1.2/src/garsync/cli.py +108 -0
- garsync-0.1.2/src/garsync/client.py +169 -0
- garsync-0.1.2/src/garsync/db/__init__.py +20 -0
- garsync-0.1.2/src/garsync/db/connection.py +19 -0
- garsync-0.1.2/src/garsync/db/repository.py +400 -0
- garsync-0.1.2/src/garsync/db/schema.py +88 -0
- garsync-0.1.2/src/garsync/exporter.py +20 -0
- garsync-0.1.2/src/garsync/models.py +47 -0
- garsync-0.1.2/src/garsync/pipeline.py +128 -0
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
|
+
[](https://github.com/mlorentedev/garsync/actions)
|
|
27
|
+
[](https://mlorentedev.github.io/garsync/)
|
|
28
|
+
[](https://www.python.org/downloads/release/python-3120/)
|
|
29
|
+
[](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
|
+
|
garsync-0.1.2/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# GarSync
|
|
2
|
+
|
|
3
|
+
[](https://github.com/mlorentedev/garsync/actions)
|
|
4
|
+
[](https://mlorentedev.github.io/garsync/)
|
|
5
|
+
[](https://www.python.org/downloads/release/python-3120/)
|
|
6
|
+
[](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,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
|
+
)
|