garsync 0.1.4__tar.gz → 0.2.0__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.
Files changed (28) hide show
  1. {garsync-0.1.4 → garsync-0.2.0}/PKG-INFO +10 -10
  2. {garsync-0.1.4 → garsync-0.2.0}/pyproject.toml +23 -19
  3. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/__init__.py +1 -1
  4. garsync-0.2.0/src/garsync/api/auth.py +166 -0
  5. garsync-0.2.0/src/garsync/api/main.py +191 -0
  6. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/activities.py +6 -6
  7. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/biometrics.py +7 -5
  8. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/sleep.py +7 -5
  9. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/stats.py +14 -11
  10. garsync-0.2.0/src/garsync/api/schemas.py +119 -0
  11. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/cli.py +9 -9
  12. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/client.py +4 -5
  13. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/repository.py +27 -28
  14. garsync-0.2.0/src/garsync/models.py +47 -0
  15. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/pipeline.py +5 -5
  16. garsync-0.1.4/src/garsync/api/main.py +0 -83
  17. garsync-0.1.4/src/garsync/api/schemas.py +0 -121
  18. garsync-0.1.4/src/garsync/models.py +0 -47
  19. {garsync-0.1.4 → garsync-0.2.0}/README.md +0 -0
  20. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/__main__.py +0 -0
  21. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/__init__.py +0 -0
  22. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/deps.py +0 -0
  23. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/__init__.py +0 -0
  24. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/api/routes/sync.py +0 -0
  25. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/__init__.py +2 -2
  26. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/connection.py +0 -0
  27. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/db/schema.py +0 -0
  28. {garsync-0.1.4 → garsync-0.2.0}/src/garsync/exporter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: garsync
3
- Version: 0.1.4
3
+ Version: 0.2.0
4
4
  Summary:
5
5
  Author: Manu
6
6
  Author-email: mlorentedev@gmail.com
@@ -9,16 +9,16 @@ Classifier: Programming Language :: Python :: 3
9
9
  Classifier: Programming Language :: Python :: 3.12
10
10
  Classifier: Programming Language :: Python :: 3.13
11
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)
12
+ Requires-Dist: fastapi (>=0.141.1,<1.0.0)
13
+ Requires-Dist: garminconnect (>=0.3.11,<0.4.0)
14
+ Requires-Dist: pandas (<4)
15
+ Requires-Dist: plotly (>=7.0.0,<8.0.0)
16
+ Requires-Dist: pydantic (>=2.13.4,<3.0.0)
17
+ Requires-Dist: rich (>=15.0.0,<16.0.0)
18
+ Requires-Dist: streamlit (>=1.62.0,<2.0.0)
19
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)
20
+ Requires-Dist: typer (>=0.27.1,<0.28.0)
21
+ Requires-Dist: uvicorn[standard] (>=0.52.4,<1.0.0)
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # GarSync
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "garsync"
3
- version = "0.1.4"
3
+ version = "0.2.0"
4
4
  description = ""
5
5
  authors = [
6
6
  {name = "Manu",email = "mlorentedev@gmail.com"}
@@ -8,16 +8,16 @@ authors = [
8
8
  readme = "README.md"
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
- "garminconnect (>=0.2.38,<0.3.0)",
12
- "pydantic (>=2.12.5,<3.0.0)",
11
+ "garminconnect (>=0.3.11,<0.4.0)",
12
+ "pydantic (>=2.13.4,<3.0.0)",
13
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)",
14
+ "typer (>=0.27.1,<0.28.0)",
15
+ "rich (>=15.0.0,<16.0.0)",
16
+ "pandas (<4)",
17
+ "streamlit (>=1.62.0,<2.0.0)",
18
+ "plotly (>=7.0.0,<8.0.0)",
19
+ "fastapi (>=0.141.1,<1.0.0)",
20
+ "uvicorn[standard] (>=0.52.4,<1.0.0)",
21
21
  ]
22
22
 
23
23
 
@@ -30,15 +30,15 @@ garsync = "garsync.cli:app"
30
30
 
31
31
  [dependency-groups]
32
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)"
33
+ "typer (>=0.27.1,<0.28.0)",
34
+ "rich (>=15.0.0,<16.0.0)",
35
+ "pydantic (>=2.13.4,<3.0.0)",
36
+ "pytest (>=9.1.1,<10.0.0)",
37
+ "pytest-cov (>=7.1.0,<8.0.0)",
38
+ "mypy (>=2.3.1,<3.0.0)",
39
+ "ruff (>=0.16.5,<0.17.0)",
40
+ "pytest-mock (>=3.15.1,<4.0.0)",
41
+ "httpx (>=0.28.1,<1.0.0)"
42
42
  ]
43
43
 
44
44
  [tool.mypy]
@@ -60,3 +60,7 @@ addopts = "-q"
60
60
  [tool.ruff]
61
61
  target-version = "py312"
62
62
  line-length = 100
63
+
64
+ [tool.ruff.lint]
65
+ [tool.ruff.lint.flake8-bugbear]
66
+ extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"]
@@ -1,3 +1,3 @@
1
1
  """Garmin Sync Microservice"""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.2.0"
@@ -0,0 +1,166 @@
1
+ """SEC-001 auth primitives: signed session cookies, login rate limiter, login page.
2
+
3
+ Stdlib only (hmac/hashlib/secrets) — no new dependencies. The session signing key
4
+ IS the access password: rotating the password invalidates all sessions (accepted
5
+ in specs/SEC-001/proposal.md).
6
+ """
7
+
8
+ import hashlib
9
+ import hmac
10
+ import time
11
+
12
+ SESSION_COOKIE_NAME = "garsync_session"
13
+ SESSION_TTL_SECONDS = 7 * 24 * 3600
14
+
15
+ LOGIN_MAX_FAILURES = 5
16
+ LOGIN_WINDOW_SECONDS = 300
17
+
18
+ _LOGIN_PAGE_HTML = """<!doctype html>
19
+ <html lang="en">
20
+ <head>
21
+ <meta charset="UTF-8" />
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
23
+ <meta name="robots" content="noindex, nofollow" />
24
+ <meta name="color-scheme" content="dark" />
25
+ <title>Sign in</title>
26
+ <style>
27
+ :root {
28
+ color-scheme: dark;
29
+ --bg: #0f172a;
30
+ --card: #1e293b;
31
+ --line: #334155;
32
+ --text: #f8fafc;
33
+ --muted: #94a3b8;
34
+ --accent: #60a5fa;
35
+ }
36
+ * { box-sizing: border-box; }
37
+ body {
38
+ margin: 0; min-height: 100vh; min-height: 100dvh; padding: 1.5rem 1rem;
39
+ display: flex; align-items: center; justify-content: center;
40
+ background: var(--bg); color: var(--text);
41
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
42
+ font-size: 16px; line-height: 1.5; -webkit-font-smoothing: antialiased;
43
+ }
44
+ main {
45
+ width: 100%; max-width: 22rem; padding: 1.75rem;
46
+ background: var(--card); border: 1px solid var(--line);
47
+ border-radius: 0.75rem; box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.35);
48
+ }
49
+ h1 { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 700; color: var(--accent); }
50
+ .hint { margin: 0 0 1.25rem; color: var(--muted); font-size: 0.875rem; }
51
+ label {
52
+ display: block; margin-bottom: 0.375rem;
53
+ font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
54
+ letter-spacing: 0.05em; color: var(--muted);
55
+ }
56
+ input[type="password"] {
57
+ width: 100%; padding: 0.625rem 0.75rem; font: inherit;
58
+ color: var(--text); background: var(--bg);
59
+ border: 1px solid var(--line); border-radius: 0.5rem; letter-spacing: 0.02em;
60
+ }
61
+ input[type="password"]:hover { border-color: var(--muted); }
62
+ :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
63
+ button {
64
+ width: 100%; margin-top: 1rem; padding: 0.625rem 1rem;
65
+ border: 0; border-radius: 0.5rem; font: inherit; font-weight: 600;
66
+ background: #2563eb; color: var(--text); cursor: pointer;
67
+ }
68
+ button:hover { background: #1d4ed8; }
69
+ .error {
70
+ margin: 0 0 1.25rem; padding: 0.625rem 0.75rem; font-size: 0.875rem;
71
+ color: #fca5a5; background: rgb(127 29 29 / 0.28);
72
+ border: 1px solid #7f1d1d; border-left-width: 4px; border-radius: 0.5rem;
73
+ }
74
+ .error:empty { display: none; }
75
+ footer { margin-top: 1.25rem; color: var(--muted); font-size: 0.75rem; }
76
+ @media (min-width: 40rem) { main { padding: 2rem; } }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <main>
81
+ <h1>Sign in</h1>
82
+ <p class="hint">This dashboard is private. Enter the access password to continue.</p>
83
+
84
+ <div class="error" role="alert">{{ERROR}}</div>
85
+
86
+ <form method="POST" action="/login">
87
+ <label for="password">Password</label>
88
+ <input
89
+ id="password"
90
+ name="password"
91
+ type="password"
92
+ autocomplete="current-password"
93
+ autocapitalize="off"
94
+ spellcheck="false"
95
+ maxlength="256"
96
+ required
97
+ autofocus
98
+ />
99
+ <button type="submit">Sign in</button>
100
+ </form>
101
+
102
+ <footer>Repeated failed attempts are temporarily blocked.</footer>
103
+ </main>
104
+ </body>
105
+ </html>
106
+ """
107
+
108
+
109
+ def render_login_page(error: str = "") -> str:
110
+ """Render the login page with the error banner filled (empty collapses it)."""
111
+ return _LOGIN_PAGE_HTML.replace("{{ERROR}}", error)
112
+
113
+
114
+ def make_session_token(access_password: str, now: float | None = None) -> str:
115
+ """Return '<expiry>.<hmac>' — signature keyed by the access password."""
116
+ current = time.time() if now is None else now
117
+ expiry = int(current + SESSION_TTL_SECONDS)
118
+ payload = f"garsync-session|{expiry}"
119
+ sig = hmac.new(access_password.encode(), payload.encode(), hashlib.sha256).hexdigest()
120
+ return f"{expiry}.{sig}"
121
+
122
+
123
+ def verify_session_token(token: str | None, access_password: str, now: float | None = None) -> bool:
124
+ """Constant-time token verification; False on tamper, bad format or expiry."""
125
+ if not token or not access_password:
126
+ return False
127
+ parts = token.split(".", 1)
128
+ if len(parts) != 2 or not parts[0].isdigit():
129
+ return False
130
+ expiry_str, provided_sig = parts
131
+ expected = hmac.new(
132
+ access_password.encode(),
133
+ f"garsync-session|{expiry_str}".encode(),
134
+ hashlib.sha256,
135
+ ).hexdigest()
136
+ if not hmac.compare_digest(provided_sig, expected):
137
+ return False
138
+ current = time.time() if now is None else now
139
+ return int(expiry_str) > current
140
+
141
+
142
+ class LoginRateLimiter:
143
+ """In-memory per-client-IP limiter for failed logins (single-process apps)."""
144
+
145
+ def __init__(
146
+ self, max_failures: int = LOGIN_MAX_FAILURES, window_seconds: int = LOGIN_WINDOW_SECONDS
147
+ ) -> None:
148
+ self.max_failures = max_failures
149
+ self.window_seconds = window_seconds
150
+ self._failures: dict[str, list[float]] = {}
151
+
152
+ def _purge(self, client_ip: str) -> list[float]:
153
+ cutoff = time.time() - self.window_seconds
154
+ recent = [t for t in self._failures.get(client_ip, []) if t > cutoff]
155
+ self._failures[client_ip] = recent
156
+ return recent
157
+
158
+ def is_blocked(self, client_ip: str) -> bool:
159
+ return len(self._purge(client_ip)) >= self.max_failures
160
+
161
+ def record_failure(self, client_ip: str) -> None:
162
+ self._purge(client_ip)
163
+ self._failures[client_ip].append(time.time())
164
+
165
+ def reset(self, client_ip: str) -> None:
166
+ self._failures.pop(client_ip, None)
@@ -0,0 +1,191 @@
1
+ """FastAPI application factory for garsync."""
2
+
3
+ import hmac
4
+ import logging
5
+ import os
6
+ import sqlite3
7
+ from collections.abc import AsyncGenerator, Awaitable, Callable
8
+ from contextlib import asynccontextmanager
9
+ from pathlib import Path
10
+ from urllib.parse import parse_qs
11
+
12
+ from fastapi import FastAPI, Request, Response, status
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from garsync.api.auth import (
18
+ SESSION_COOKIE_NAME,
19
+ SESSION_TTL_SECONDS,
20
+ LoginRateLimiter,
21
+ make_session_token,
22
+ render_login_page,
23
+ verify_session_token,
24
+ )
25
+ from garsync.api.routes import activities, biometrics, sleep, stats, sync
26
+ from garsync.db.connection import get_connection
27
+ from garsync.db.schema import init_db
28
+
29
+ logger = logging.getLogger("garsync.auth")
30
+
31
+
32
+ @asynccontextmanager
33
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
34
+ """Manage database connection lifecycle."""
35
+ db_path = os.environ.get("GARSYNC_DB_PATH", "data/garsync.db")
36
+ conn = get_connection(db_path)
37
+ init_db(conn)
38
+ app.state.db = conn
39
+ yield
40
+ conn.close()
41
+
42
+
43
+ def create_app(conn: sqlite3.Connection | None = None) -> FastAPI:
44
+ """Create and configure the FastAPI application.
45
+
46
+ Args:
47
+ conn: Optional pre-configured connection (for testing).
48
+ If provided, lifespan is skipped.
49
+ """
50
+ if conn is not None:
51
+ app = FastAPI(title="GarSync API", version="0.1.0")
52
+ app.state.db = conn
53
+ else:
54
+ app = FastAPI(title="GarSync API", version="0.1.0", lifespan=lifespan)
55
+
56
+ access_password = os.environ.get("GARSYNC_ACCESS_PASSWORD")
57
+ api_key = os.environ.get("GARSYNC_API_KEY")
58
+ auth_active = bool(access_password or api_key)
59
+ insecure_cookies = os.environ.get("GARSYNC_INSECURE_COOKIES") == "1"
60
+ limiter = LoginRateLimiter()
61
+
62
+ if not auth_active:
63
+ logger.warning(
64
+ "Auth is DISABLED: neither GARSYNC_ACCESS_PASSWORD nor GARSYNC_API_KEY "
65
+ "is set. The API and dashboard are UNPROTECTED. This is only acceptable "
66
+ "on a trusted local network."
67
+ )
68
+ elif not access_password:
69
+ logger.warning(
70
+ "Dashboard is UNAUTHENTICATED: GARSYNC_ACCESS_PASSWORD is not set. "
71
+ "Only /api/* requires the API key. Set GARSYNC_ACCESS_PASSWORD before "
72
+ "deploying publicly."
73
+ )
74
+
75
+ @app.middleware("http")
76
+ async def auth_gate(
77
+ request: Request, call_next: Callable[[Request], Awaitable[Response]]
78
+ ) -> Response:
79
+ """SEC-001 gate: session cookie for pages, session-or-API-key for /api/*.
80
+
81
+ 401 = no credentials presented; 403 = credentials presented but invalid.
82
+ """
83
+ if not auth_active:
84
+ return await call_next(request)
85
+
86
+ path = request.url.path
87
+ if path in ("/login", "/logout"):
88
+ return await call_next(request)
89
+
90
+ if path.startswith("/api/"):
91
+ if api_key:
92
+ provided = request.headers.get("X-API-KEY")
93
+ if provided is not None:
94
+ if hmac.compare_digest(provided, api_key):
95
+ return await call_next(request)
96
+ return JSONResponse(
97
+ status_code=status.HTTP_403_FORBIDDEN,
98
+ content={"detail": "Invalid API key"},
99
+ )
100
+ if access_password and verify_session_token(
101
+ request.cookies.get(SESSION_COOKIE_NAME), access_password
102
+ ):
103
+ return await call_next(request)
104
+ return JSONResponse(
105
+ status_code=status.HTTP_401_UNAUTHORIZED,
106
+ content={"detail": "Authentication required"},
107
+ )
108
+
109
+ if access_password:
110
+ if verify_session_token(request.cookies.get(SESSION_COOKIE_NAME), access_password):
111
+ return await call_next(request)
112
+ return RedirectResponse("/login", status_code=status.HTTP_302_FOUND)
113
+
114
+ return await call_next(request)
115
+
116
+ @app.get("/login", include_in_schema=False)
117
+ async def login_page(request: Request) -> Response:
118
+ error = "Invalid password. Try again." if "error" in request.query_params else ""
119
+ return HTMLResponse(render_login_page(error))
120
+
121
+ @app.post("/login", include_in_schema=False)
122
+ async def login_submit(request: Request) -> Response:
123
+ client_ip = request.client.host if request.client else "unknown"
124
+ if limiter.is_blocked(client_ip):
125
+ return HTMLResponse(
126
+ render_login_page("Too many failed attempts. Try again later."),
127
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
128
+ )
129
+
130
+ form = parse_qs((await request.body()).decode())
131
+ supplied = form.get("password", [""])[0]
132
+
133
+ if access_password and hmac.compare_digest(supplied, access_password):
134
+ limiter.reset(client_ip)
135
+ response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
136
+ response.set_cookie(
137
+ SESSION_COOKIE_NAME,
138
+ make_session_token(access_password),
139
+ max_age=SESSION_TTL_SECONDS,
140
+ httponly=True,
141
+ samesite="lax",
142
+ secure=not insecure_cookies,
143
+ )
144
+ return response
145
+
146
+ limiter.record_failure(client_ip)
147
+ return HTMLResponse(
148
+ render_login_page("Invalid password. Try again."),
149
+ status_code=status.HTTP_401_UNAUTHORIZED,
150
+ )
151
+
152
+ @app.post("/logout", include_in_schema=False)
153
+ async def logout() -> Response:
154
+ response = RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER)
155
+ response.delete_cookie(SESSION_COOKIE_NAME)
156
+ return response
157
+
158
+ allowed_origins = [
159
+ origin.strip()
160
+ for origin in os.environ.get("GARSYNC_ALLOWED_ORIGINS", "").split(",")
161
+ if origin.strip()
162
+ ]
163
+ if "*" in allowed_origins:
164
+ msg = (
165
+ "GARSYNC_ALLOWED_ORIGINS must list explicit origins; '*' is not allowed "
166
+ "because the API uses credentialed (cookie / X-API-KEY) requests."
167
+ )
168
+ raise ValueError(msg)
169
+ if allowed_origins:
170
+ app.add_middleware(
171
+ CORSMiddleware,
172
+ allow_origins=allowed_origins,
173
+ allow_credentials=True,
174
+ allow_methods=["GET", "POST"],
175
+ allow_headers=["X-API-KEY", "Content-Type"],
176
+ )
177
+
178
+ app.include_router(activities.router)
179
+ app.include_router(biometrics.router)
180
+ app.include_router(sleep.router)
181
+ app.include_router(stats.router)
182
+ app.include_router(sync.router)
183
+
184
+ static_dir = Path(__file__).resolve().parents[3] / "frontend" / "dist"
185
+ if static_dir.is_dir():
186
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")
187
+
188
+ return app
189
+
190
+
191
+ app = create_app()
@@ -1,6 +1,6 @@
1
1
  """Activity endpoints."""
2
2
 
3
- from typing import Optional
3
+ import datetime
4
4
 
5
5
  from fastapi import APIRouter, Depends, Query
6
6
 
@@ -15,17 +15,17 @@ router = APIRouter(prefix="/api", tags=["activities"])
15
15
  def list_activities(
16
16
  page: int = Query(default=1, ge=1),
17
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),
18
+ start_date: datetime.date | None = Query(default=None),
19
+ end_date: datetime.date | None = Query(default=None),
20
+ activity_type: str | None = Query(default=None),
21
21
  repo: ActivityRepository = Depends(get_activity_repo),
22
22
  ) -> PaginatedActivities:
23
23
  """List activities with pagination and optional filters."""
24
24
  rows, total = repo.get_paginated(
25
25
  page=page,
26
26
  limit=limit,
27
- start_date=start_date,
28
- end_date=end_date,
27
+ start_date=start_date.isoformat() if start_date else None,
28
+ end_date=end_date.isoformat() if end_date else None,
29
29
  activity_type=activity_type,
30
30
  )
31
31
  items = [ActivityItem(**dict(row)) for row in rows]
@@ -1,5 +1,7 @@
1
1
  """Biometrics endpoints."""
2
2
 
3
+ import datetime
4
+
3
5
  from fastapi import APIRouter, Depends, Query
4
6
 
5
7
  from garsync.api.deps import get_biometrics_repo
@@ -11,16 +13,16 @@ router = APIRouter(prefix="/api", tags=["biometrics"])
11
13
 
12
14
  @router.get("/biometrics", response_model=BiometricsResponse)
13
15
  def list_biometrics(
14
- start_date: str = Query(),
15
- end_date: str = Query(),
16
+ start_date: datetime.date = Query(),
17
+ end_date: datetime.date = Query(),
16
18
  repo: BiometricsRepository = Depends(get_biometrics_repo),
17
19
  ) -> BiometricsResponse:
18
20
  """List biometrics within a date range."""
19
- rows = repo.get_by_date_range(start_date, end_date)
21
+ rows = repo.get_by_date_range(start_date.isoformat(), end_date.isoformat())
20
22
  metrics = [BiometricItem(**dict(row)) for row in rows]
21
23
  return BiometricsResponse(
22
24
  metrics=metrics,
23
- start_date=start_date,
24
- end_date=end_date,
25
+ start_date=start_date.isoformat(),
26
+ end_date=end_date.isoformat(),
25
27
  count=len(metrics),
26
28
  )
@@ -1,5 +1,7 @@
1
1
  """Sleep endpoints."""
2
2
 
3
+ import datetime
4
+
3
5
  from fastapi import APIRouter, Depends, Query
4
6
 
5
7
  from garsync.api.deps import get_sleep_repo
@@ -11,16 +13,16 @@ router = APIRouter(prefix="/api", tags=["sleep"])
11
13
 
12
14
  @router.get("/sleep", response_model=SleepResponse)
13
15
  def list_sleep(
14
- start_date: str = Query(),
15
- end_date: str = Query(),
16
+ start_date: datetime.date = Query(),
17
+ end_date: datetime.date = Query(),
16
18
  repo: SleepRepository = Depends(get_sleep_repo),
17
19
  ) -> SleepResponse:
18
20
  """List sleep sessions within a date range."""
19
- rows = repo.get_by_date_range(start_date, end_date)
21
+ rows = repo.get_by_date_range(start_date.isoformat(), end_date.isoformat())
20
22
  sessions = [SleepItem(**dict(row)) for row in rows]
21
23
  return SleepResponse(
22
24
  sleep_sessions=sessions,
23
- start_date=start_date,
24
- end_date=end_date,
25
+ start_date=start_date.isoformat(),
26
+ end_date=end_date.isoformat(),
25
27
  count=len(sessions),
26
28
  )
@@ -1,7 +1,6 @@
1
1
  """Stats endpoints — summary and heatmap."""
2
2
 
3
- from datetime import date, timedelta
4
- from typing import Optional
3
+ from datetime import UTC, date, datetime, timedelta
5
4
 
6
5
  from fastapi import APIRouter, Depends, Query
7
6
 
@@ -23,11 +22,11 @@ router = APIRouter(prefix="/api/stats", tags=["stats"])
23
22
 
24
23
  def _resolve_dates(
25
24
  period: str,
26
- start_date: Optional[str],
27
- end_date: Optional[str],
25
+ start_date: str | None,
26
+ end_date: str | None,
28
27
  ) -> tuple[str, str]:
29
28
  """Resolve start/end dates from period or explicit params."""
30
- today = date.today()
29
+ today = datetime.now(UTC).date()
31
30
  if start_date and end_date:
32
31
  return start_date, end_date
33
32
  if period == "week":
@@ -42,14 +41,18 @@ def _resolve_dates(
42
41
  @router.get("/summary", response_model=SummaryStats)
43
42
  def summary(
44
43
  period: str = Query(default="week"),
45
- start_date: Optional[str] = Query(default=None),
46
- end_date: Optional[str] = Query(default=None),
44
+ start_date: date | None = Query(default=None),
45
+ end_date: date | None = Query(default=None),
47
46
  activity_repo: ActivityRepository = Depends(get_activity_repo),
48
47
  biometrics_repo: BiometricsRepository = Depends(get_biometrics_repo),
49
48
  sleep_repo: SleepRepository = Depends(get_sleep_repo),
50
49
  ) -> SummaryStats:
51
50
  """Get aggregated stats for a period."""
52
- sd, ed = _resolve_dates(period, start_date, end_date)
51
+ sd, ed = _resolve_dates(
52
+ period,
53
+ start_date.isoformat() if start_date else None,
54
+ end_date.isoformat() if end_date else None,
55
+ )
53
56
 
54
57
  activity_stats = activity_repo.get_summary_stats(sd, ed)
55
58
  bio_stats = biometrics_repo.get_avg_stats(sd, ed)
@@ -92,12 +95,12 @@ def _compute_intensity(count: int, max_count: int) -> int:
92
95
 
93
96
  @router.get("/heatmap", response_model=HeatmapResponse)
94
97
  def heatmap(
95
- year: Optional[int] = Query(default=None),
96
- activity_type: Optional[str] = Query(default=None),
98
+ year: int | None = Query(default=None),
99
+ activity_type: str | None = Query(default=None),
97
100
  repo: ActivityRepository = Depends(get_activity_repo),
98
101
  ) -> HeatmapResponse:
99
102
  """Get activity heatmap data for a year."""
100
- target_year = year or date.today().year
103
+ target_year = year or datetime.now(UTC).date().year
101
104
  rows = repo.get_heatmap_data(target_year, activity_type)
102
105
 
103
106
  max_count = max((row["activity_count"] for row in rows), default=0)