garsync 0.1.2__py3-none-any.whl
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/__init__.py +3 -0
- garsync/__main__.py +4 -0
- garsync/api/__init__.py +5 -0
- garsync/api/deps.py +38 -0
- garsync/api/main.py +83 -0
- garsync/api/routes/__init__.py +1 -0
- garsync/api/routes/activities.py +38 -0
- garsync/api/routes/biometrics.py +26 -0
- garsync/api/routes/sleep.py +26 -0
- garsync/api/routes/stats.py +124 -0
- garsync/api/routes/sync.py +38 -0
- garsync/api/schemas.py +121 -0
- garsync/cli.py +108 -0
- garsync/client.py +169 -0
- garsync/db/__init__.py +20 -0
- garsync/db/connection.py +19 -0
- garsync/db/repository.py +400 -0
- garsync/db/schema.py +88 -0
- garsync/exporter.py +20 -0
- garsync/models.py +47 -0
- garsync/pipeline.py +128 -0
- garsync-0.1.2.dist-info/METADATA +85 -0
- garsync-0.1.2.dist-info/RECORD +25 -0
- garsync-0.1.2.dist-info/WHEEL +4 -0
- garsync-0.1.2.dist-info/entry_points.txt +3 -0
garsync/client.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
|
|
6
|
+
from garminconnect import Garmin
|
|
7
|
+
from tenacity import retry, stop_after_attempt, wait_exponential
|
|
8
|
+
|
|
9
|
+
from garsync.models import DailyBiometrics, NormalizedActivity, SleepData
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GarminClient:
|
|
15
|
+
"""Wrapper for Garmin Connect API with token caching support."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, email: str, password: str, token_store: Optional[str] = None):
|
|
18
|
+
self.email = email
|
|
19
|
+
self.password = password
|
|
20
|
+
self.token_store = token_store
|
|
21
|
+
self.client = Garmin(self.email, self.password)
|
|
22
|
+
|
|
23
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
|
24
|
+
def login(self) -> None:
|
|
25
|
+
"""Authenticate with Garmin Connect using tokens or credentials."""
|
|
26
|
+
try:
|
|
27
|
+
if self.token_store and os.path.exists(self.token_store):
|
|
28
|
+
logger.info(f"Attempting to login using tokens from {self.token_store}...")
|
|
29
|
+
self.client.login(self.token_store)
|
|
30
|
+
logger.info("Token-based authentication successful.")
|
|
31
|
+
else:
|
|
32
|
+
logger.info("Authenticating with Garmin Connect credentials...")
|
|
33
|
+
self.client.login()
|
|
34
|
+
logger.info("Authentication successful.")
|
|
35
|
+
if self.token_store:
|
|
36
|
+
# Save tokens for next time
|
|
37
|
+
os.makedirs(os.path.dirname(self.token_store), exist_ok=True)
|
|
38
|
+
self.client.garth.save(self.token_store)
|
|
39
|
+
logger.info(f"Session tokens saved to {self.token_store}")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
logger.error(f"Failed to authenticate: {e}")
|
|
42
|
+
# If token login fails, try one more time with credentials (if they exist)
|
|
43
|
+
if self.token_store and os.path.exists(self.token_store):
|
|
44
|
+
logger.warning("Token login failed, falling back to credentials...")
|
|
45
|
+
try:
|
|
46
|
+
self.client.login()
|
|
47
|
+
if self.token_store:
|
|
48
|
+
self.client.garth.save(self.token_store)
|
|
49
|
+
return
|
|
50
|
+
except Exception as cred_err:
|
|
51
|
+
logger.error(f"Fallback credential login also failed: {cred_err}")
|
|
52
|
+
raise
|
|
53
|
+
|
|
54
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
|
55
|
+
def fetch_activities(self, start: int = 0, limit: int = 100) -> List[NormalizedActivity]:
|
|
56
|
+
logger.info(f"Fetching activities (start={start}, limit={limit})...")
|
|
57
|
+
raw_activities = self.client.get_activities(start, limit)
|
|
58
|
+
activities = []
|
|
59
|
+
for raw in raw_activities:
|
|
60
|
+
try:
|
|
61
|
+
# Convert timestamp from Garmin format
|
|
62
|
+
start_time_str = raw.get("startTimeLocal", raw.get("startTimeGMT"))
|
|
63
|
+
start_time = (
|
|
64
|
+
datetime.datetime.fromisoformat(start_time_str) if start_time_str else None
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
activities.append(
|
|
68
|
+
NormalizedActivity(
|
|
69
|
+
activity_id=raw.get("activityId"),
|
|
70
|
+
activity_name=raw.get("activityName"),
|
|
71
|
+
activity_type=raw.get("activityType", {}).get("typeKey"),
|
|
72
|
+
start_time=start_time,
|
|
73
|
+
duration_seconds=raw.get("duration", 0.0),
|
|
74
|
+
distance_meters=raw.get("distance"),
|
|
75
|
+
average_heart_rate=raw.get("averageHR"),
|
|
76
|
+
max_heart_rate=raw.get("maxHR"),
|
|
77
|
+
calories=raw.get("calories"),
|
|
78
|
+
raw_data=raw,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.warning(f"Failed to parse activity {raw.get('activityId')}: {e}")
|
|
83
|
+
return activities
|
|
84
|
+
|
|
85
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
|
86
|
+
def fetch_biometrics(self, date: datetime.date) -> DailyBiometrics:
|
|
87
|
+
logger.info(f"Fetching biometrics for {date}...")
|
|
88
|
+
try:
|
|
89
|
+
date_str = date.isoformat()
|
|
90
|
+
hr_data = self.client.get_heart_rates(date_str)
|
|
91
|
+
bb_data = self.client.get_body_battery(date_str)
|
|
92
|
+
stress_data = self.client.get_all_day_stress(date_str)
|
|
93
|
+
hrv_data = self.client.get_hrv_data(date_str)
|
|
94
|
+
|
|
95
|
+
# Helpers to safely extract data whether it's a list or dict
|
|
96
|
+
def get_dict(data):
|
|
97
|
+
if isinstance(data, list):
|
|
98
|
+
return data[0] if data else {}
|
|
99
|
+
return data or {}
|
|
100
|
+
|
|
101
|
+
hr_dict = get_dict(hr_data)
|
|
102
|
+
bb_dict = get_dict(bb_data)
|
|
103
|
+
stress_dict = get_dict(stress_data)
|
|
104
|
+
hrv_dict = get_dict(hrv_data)
|
|
105
|
+
|
|
106
|
+
# Simple aggregations
|
|
107
|
+
rhr = hr_dict.get("restingHeartRate")
|
|
108
|
+
|
|
109
|
+
bb_values = [v for t, v in bb_dict.get("bodyBatteryValues", []) if v] if bb_dict else []
|
|
110
|
+
bb_highest = max(bb_values) if bb_values else None
|
|
111
|
+
bb_lowest = min(bb_values) if bb_values else None
|
|
112
|
+
|
|
113
|
+
stress_avg = stress_dict.get("averageStressLevel")
|
|
114
|
+
hrv_bal = hrv_dict.get("hrvSummary", {}).get("baselineStatus")
|
|
115
|
+
|
|
116
|
+
return DailyBiometrics(
|
|
117
|
+
date=date,
|
|
118
|
+
resting_heart_rate=rhr,
|
|
119
|
+
hrv_balance=hrv_bal,
|
|
120
|
+
body_battery_highest=bb_highest,
|
|
121
|
+
body_battery_lowest=bb_lowest,
|
|
122
|
+
stress_average=stress_avg,
|
|
123
|
+
raw_data={
|
|
124
|
+
"hr": hr_data,
|
|
125
|
+
"bb": bb_data,
|
|
126
|
+
"stress": stress_data,
|
|
127
|
+
"hrv": hrv_data,
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.error(f"Failed to fetch biometrics for {date}: {e}")
|
|
132
|
+
raise
|
|
133
|
+
|
|
134
|
+
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
|
|
135
|
+
def fetch_sleep(self, date: datetime.date) -> SleepData:
|
|
136
|
+
logger.info(f"Fetching sleep data for {date}...")
|
|
137
|
+
try:
|
|
138
|
+
sleep_data = self.client.get_sleep_data(date.isoformat())
|
|
139
|
+
|
|
140
|
+
# Check if sleep data is available
|
|
141
|
+
daily_sleep = sleep_data.get("dailySleepDTO", {})
|
|
142
|
+
if not daily_sleep:
|
|
143
|
+
return SleepData(date=date, raw_data=sleep_data)
|
|
144
|
+
|
|
145
|
+
sleep_start = None
|
|
146
|
+
if start_timestamp := daily_sleep.get("sleepStartTimestampGMT"):
|
|
147
|
+
sleep_start = datetime.datetime.fromtimestamp(
|
|
148
|
+
start_timestamp / 1000.0, datetime.UTC
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
sleep_end = None
|
|
152
|
+
if end_timestamp := daily_sleep.get("sleepEndTimestampGMT"):
|
|
153
|
+
sleep_end = datetime.datetime.fromtimestamp(end_timestamp / 1000.0, datetime.UTC)
|
|
154
|
+
|
|
155
|
+
return SleepData(
|
|
156
|
+
date=date,
|
|
157
|
+
sleep_start=sleep_start,
|
|
158
|
+
sleep_end=sleep_end,
|
|
159
|
+
total_sleep_seconds=daily_sleep.get("sleepTimeSeconds"),
|
|
160
|
+
deep_sleep_seconds=daily_sleep.get("deepSleepSeconds"),
|
|
161
|
+
light_sleep_seconds=daily_sleep.get("lightSleepSeconds"),
|
|
162
|
+
rem_sleep_seconds=daily_sleep.get("remSleepSeconds"),
|
|
163
|
+
awake_sleep_seconds=daily_sleep.get("awakeSleepSeconds"),
|
|
164
|
+
sleep_score=sleep_data.get("sleepScore", {}).get("value"),
|
|
165
|
+
raw_data=sleep_data,
|
|
166
|
+
)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.error(f"Failed to fetch sleep for {date}: {e}")
|
|
169
|
+
raise
|
garsync/db/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Database package for garsync — SQLite persistence layer."""
|
|
2
|
+
|
|
3
|
+
from garsync.db.connection import get_connection
|
|
4
|
+
from garsync.db.repository import (
|
|
5
|
+
ActivityRepository,
|
|
6
|
+
BiometricsRepository,
|
|
7
|
+
SleepRepository,
|
|
8
|
+
SyncLogRepository,
|
|
9
|
+
)
|
|
10
|
+
from garsync.db.schema import CURRENT_VERSION, init_db
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"get_connection",
|
|
14
|
+
"init_db",
|
|
15
|
+
"CURRENT_VERSION",
|
|
16
|
+
"ActivityRepository",
|
|
17
|
+
"BiometricsRepository",
|
|
18
|
+
"SleepRepository",
|
|
19
|
+
"SyncLogRepository",
|
|
20
|
+
]
|
garsync/db/connection.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SQLite connection management with WAL mode and Row factory."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def get_connection(db_path: str) -> sqlite3.Connection:
|
|
7
|
+
"""Create a SQLite connection with WAL mode and Row factory.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
db_path: Path to the database file, or ":memory:" for in-memory.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Configured sqlite3.Connection.
|
|
14
|
+
"""
|
|
15
|
+
conn = sqlite3.connect(db_path, check_same_thread=False)
|
|
16
|
+
conn.row_factory = sqlite3.Row
|
|
17
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
18
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
19
|
+
return conn
|
garsync/db/repository.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Repository classes for garsync SQLite tables.
|
|
2
|
+
|
|
3
|
+
Each repository wraps a single table with upsert, query, and count operations.
|
|
4
|
+
Connection is injected — the caller manages lifecycle and transactions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sqlite3
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ActivityRepository:
|
|
12
|
+
"""CRUD operations for the activities table."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
15
|
+
self._conn = conn
|
|
16
|
+
|
|
17
|
+
def upsert(self, row: dict[str, Any]) -> None:
|
|
18
|
+
"""Insert or update an activity by activity_id."""
|
|
19
|
+
self._conn.execute(
|
|
20
|
+
"""
|
|
21
|
+
INSERT INTO activities (
|
|
22
|
+
activity_id, activity_name, activity_type, start_time,
|
|
23
|
+
duration_seconds, distance_meters, average_heart_rate,
|
|
24
|
+
max_heart_rate, calories, raw_data
|
|
25
|
+
) VALUES (
|
|
26
|
+
:activity_id, :activity_name, :activity_type, :start_time,
|
|
27
|
+
:duration_seconds, :distance_meters, :average_heart_rate,
|
|
28
|
+
:max_heart_rate, :calories, :raw_data
|
|
29
|
+
)
|
|
30
|
+
ON CONFLICT(activity_id) DO UPDATE SET
|
|
31
|
+
activity_name = excluded.activity_name,
|
|
32
|
+
activity_type = excluded.activity_type,
|
|
33
|
+
start_time = excluded.start_time,
|
|
34
|
+
duration_seconds = excluded.duration_seconds,
|
|
35
|
+
distance_meters = excluded.distance_meters,
|
|
36
|
+
average_heart_rate = excluded.average_heart_rate,
|
|
37
|
+
max_heart_rate = excluded.max_heart_rate,
|
|
38
|
+
calories = excluded.calories,
|
|
39
|
+
raw_data = excluded.raw_data,
|
|
40
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
41
|
+
""",
|
|
42
|
+
row,
|
|
43
|
+
)
|
|
44
|
+
self._conn.commit()
|
|
45
|
+
|
|
46
|
+
def upsert_batch(self, rows: list[dict[str, Any]]) -> None:
|
|
47
|
+
"""Upsert multiple activities in a single transaction."""
|
|
48
|
+
for row in rows:
|
|
49
|
+
self._conn.execute(
|
|
50
|
+
"""
|
|
51
|
+
INSERT INTO activities (
|
|
52
|
+
activity_id, activity_name, activity_type, start_time,
|
|
53
|
+
duration_seconds, distance_meters, average_heart_rate,
|
|
54
|
+
max_heart_rate, calories, raw_data
|
|
55
|
+
) VALUES (
|
|
56
|
+
:activity_id, :activity_name, :activity_type, :start_time,
|
|
57
|
+
:duration_seconds, :distance_meters, :average_heart_rate,
|
|
58
|
+
:max_heart_rate, :calories, :raw_data
|
|
59
|
+
)
|
|
60
|
+
ON CONFLICT(activity_id) DO UPDATE SET
|
|
61
|
+
activity_name = excluded.activity_name,
|
|
62
|
+
activity_type = excluded.activity_type,
|
|
63
|
+
start_time = excluded.start_time,
|
|
64
|
+
duration_seconds = excluded.duration_seconds,
|
|
65
|
+
distance_meters = excluded.distance_meters,
|
|
66
|
+
average_heart_rate = excluded.average_heart_rate,
|
|
67
|
+
max_heart_rate = excluded.max_heart_rate,
|
|
68
|
+
calories = excluded.calories,
|
|
69
|
+
raw_data = excluded.raw_data,
|
|
70
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
71
|
+
""",
|
|
72
|
+
row,
|
|
73
|
+
)
|
|
74
|
+
self._conn.commit()
|
|
75
|
+
|
|
76
|
+
def get_by_id(self, activity_id: int) -> Optional[sqlite3.Row]:
|
|
77
|
+
"""Get a single activity by ID, or None."""
|
|
78
|
+
cursor = self._conn.execute(
|
|
79
|
+
"SELECT * FROM activities WHERE activity_id = ?", (activity_id,)
|
|
80
|
+
)
|
|
81
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
82
|
+
return row
|
|
83
|
+
|
|
84
|
+
def get_all(self, limit: int = 1000) -> list[sqlite3.Row]:
|
|
85
|
+
"""Get all activities, ordered by start_time DESC."""
|
|
86
|
+
cursor = self._conn.execute(
|
|
87
|
+
"SELECT * FROM activities ORDER BY start_time DESC LIMIT ?", (limit,)
|
|
88
|
+
)
|
|
89
|
+
result: list[sqlite3.Row] = cursor.fetchall()
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
def count(self) -> int:
|
|
93
|
+
"""Return total number of stored activities."""
|
|
94
|
+
cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM activities")
|
|
95
|
+
cnt: int = cursor.fetchone()["cnt"]
|
|
96
|
+
return cnt
|
|
97
|
+
|
|
98
|
+
def get_paginated(
|
|
99
|
+
self,
|
|
100
|
+
page: int = 1,
|
|
101
|
+
limit: int = 20,
|
|
102
|
+
start_date: Optional[str] = None,
|
|
103
|
+
end_date: Optional[str] = None,
|
|
104
|
+
activity_type: Optional[str] = None,
|
|
105
|
+
) -> tuple[list[sqlite3.Row], int]:
|
|
106
|
+
"""Get activities with pagination and optional filters.
|
|
107
|
+
|
|
108
|
+
Returns (rows, total_count).
|
|
109
|
+
"""
|
|
110
|
+
where_clauses: list[str] = []
|
|
111
|
+
params: list[Any] = []
|
|
112
|
+
|
|
113
|
+
if start_date:
|
|
114
|
+
where_clauses.append("date(start_time) >= ?")
|
|
115
|
+
params.append(start_date)
|
|
116
|
+
if end_date:
|
|
117
|
+
where_clauses.append("date(start_time) <= ?")
|
|
118
|
+
params.append(end_date)
|
|
119
|
+
if activity_type:
|
|
120
|
+
where_clauses.append("activity_type = ?")
|
|
121
|
+
params.append(activity_type)
|
|
122
|
+
|
|
123
|
+
where_sql = ""
|
|
124
|
+
if where_clauses:
|
|
125
|
+
where_sql = "WHERE " + " AND ".join(where_clauses)
|
|
126
|
+
|
|
127
|
+
count_cursor = self._conn.execute(
|
|
128
|
+
f"SELECT COUNT(*) as cnt FROM activities {where_sql}", # noqa: S608
|
|
129
|
+
params,
|
|
130
|
+
)
|
|
131
|
+
total: int = count_cursor.fetchone()["cnt"]
|
|
132
|
+
|
|
133
|
+
offset = (page - 1) * limit
|
|
134
|
+
cursor = self._conn.execute(
|
|
135
|
+
f"SELECT * FROM activities {where_sql} " # noqa: S608
|
|
136
|
+
"ORDER BY start_time DESC LIMIT ? OFFSET ?",
|
|
137
|
+
[*params, limit, offset],
|
|
138
|
+
)
|
|
139
|
+
rows: list[sqlite3.Row] = cursor.fetchall()
|
|
140
|
+
return rows, total
|
|
141
|
+
|
|
142
|
+
def get_heatmap_data(
|
|
143
|
+
self,
|
|
144
|
+
year: int,
|
|
145
|
+
activity_type: Optional[str] = None,
|
|
146
|
+
) -> list[sqlite3.Row]:
|
|
147
|
+
"""Get per-day activity aggregates for a calendar year."""
|
|
148
|
+
params: list[Any] = [str(year)]
|
|
149
|
+
type_filter = ""
|
|
150
|
+
if activity_type:
|
|
151
|
+
type_filter = "AND activity_type = ?"
|
|
152
|
+
params.append(activity_type)
|
|
153
|
+
|
|
154
|
+
cursor = self._conn.execute(
|
|
155
|
+
f"""
|
|
156
|
+
SELECT
|
|
157
|
+
date(start_time) as date,
|
|
158
|
+
COUNT(*) as activity_count,
|
|
159
|
+
SUM(duration_seconds) as total_duration,
|
|
160
|
+
SUM(calories) as total_calories
|
|
161
|
+
FROM activities
|
|
162
|
+
WHERE strftime('%Y', start_time) = ?
|
|
163
|
+
{type_filter}
|
|
164
|
+
GROUP BY date(start_time)
|
|
165
|
+
ORDER BY date(start_time)
|
|
166
|
+
""", # noqa: S608
|
|
167
|
+
params,
|
|
168
|
+
)
|
|
169
|
+
result: list[sqlite3.Row] = cursor.fetchall()
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def get_summary_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
|
|
173
|
+
"""Get aggregate stats for a date range."""
|
|
174
|
+
cursor = self._conn.execute(
|
|
175
|
+
"""
|
|
176
|
+
SELECT
|
|
177
|
+
COUNT(*) as total_activities,
|
|
178
|
+
COALESCE(SUM(duration_seconds), 0) as total_duration_seconds,
|
|
179
|
+
COALESCE(SUM(distance_meters), 0) as total_distance_meters,
|
|
180
|
+
COALESCE(SUM(calories), 0) as total_calories,
|
|
181
|
+
AVG(duration_seconds) as avg_duration_seconds,
|
|
182
|
+
AVG(distance_meters) as avg_distance_meters,
|
|
183
|
+
AVG(average_heart_rate) as avg_heart_rate
|
|
184
|
+
FROM activities
|
|
185
|
+
WHERE date(start_time) >= ? AND date(start_time) <= ?
|
|
186
|
+
""",
|
|
187
|
+
(start_date, end_date),
|
|
188
|
+
)
|
|
189
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
190
|
+
return row
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class BiometricsRepository:
|
|
194
|
+
"""CRUD operations for the biometrics table."""
|
|
195
|
+
|
|
196
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
197
|
+
self._conn = conn
|
|
198
|
+
|
|
199
|
+
def upsert(self, row: dict[str, Any]) -> None:
|
|
200
|
+
"""Insert or update biometrics by date."""
|
|
201
|
+
self._conn.execute(
|
|
202
|
+
"""
|
|
203
|
+
INSERT INTO biometrics (
|
|
204
|
+
date, resting_heart_rate, hrv_balance,
|
|
205
|
+
body_battery_highest, body_battery_lowest,
|
|
206
|
+
stress_average, raw_data
|
|
207
|
+
) VALUES (
|
|
208
|
+
:date, :resting_heart_rate, :hrv_balance,
|
|
209
|
+
:body_battery_highest, :body_battery_lowest,
|
|
210
|
+
:stress_average, :raw_data
|
|
211
|
+
)
|
|
212
|
+
ON CONFLICT(date) DO UPDATE SET
|
|
213
|
+
resting_heart_rate = excluded.resting_heart_rate,
|
|
214
|
+
hrv_balance = excluded.hrv_balance,
|
|
215
|
+
body_battery_highest = excluded.body_battery_highest,
|
|
216
|
+
body_battery_lowest = excluded.body_battery_lowest,
|
|
217
|
+
stress_average = excluded.stress_average,
|
|
218
|
+
raw_data = excluded.raw_data,
|
|
219
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
220
|
+
""",
|
|
221
|
+
row,
|
|
222
|
+
)
|
|
223
|
+
self._conn.commit()
|
|
224
|
+
|
|
225
|
+
def get_by_date(self, date: str) -> Optional[sqlite3.Row]:
|
|
226
|
+
"""Get biometrics for a specific date (ISO format string)."""
|
|
227
|
+
cursor = self._conn.execute("SELECT * FROM biometrics WHERE date = ?", (date,))
|
|
228
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
229
|
+
return row
|
|
230
|
+
|
|
231
|
+
def get_latest_date(self) -> Optional[str]:
|
|
232
|
+
"""Return the most recent date with biometrics data, or None."""
|
|
233
|
+
cursor = self._conn.execute("SELECT date FROM biometrics ORDER BY date DESC LIMIT 1")
|
|
234
|
+
row = cursor.fetchone()
|
|
235
|
+
result: Optional[str] = row["date"] if row else None
|
|
236
|
+
return result
|
|
237
|
+
|
|
238
|
+
def count(self) -> int:
|
|
239
|
+
"""Return total number of stored biometrics rows."""
|
|
240
|
+
cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM biometrics")
|
|
241
|
+
cnt: int = cursor.fetchone()["cnt"]
|
|
242
|
+
return cnt
|
|
243
|
+
|
|
244
|
+
def get_by_date_range(self, start_date: str, end_date: str) -> list[sqlite3.Row]:
|
|
245
|
+
"""Get biometrics rows within a date range (inclusive)."""
|
|
246
|
+
cursor = self._conn.execute(
|
|
247
|
+
"SELECT * FROM biometrics WHERE date >= ? AND date <= ? ORDER BY date",
|
|
248
|
+
(start_date, end_date),
|
|
249
|
+
)
|
|
250
|
+
result: list[sqlite3.Row] = cursor.fetchall()
|
|
251
|
+
return result
|
|
252
|
+
|
|
253
|
+
def get_avg_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
|
|
254
|
+
"""Get average biometrics for a date range."""
|
|
255
|
+
cursor = self._conn.execute(
|
|
256
|
+
"""
|
|
257
|
+
SELECT
|
|
258
|
+
COUNT(*) as record_count,
|
|
259
|
+
AVG(resting_heart_rate) as avg_resting_heart_rate,
|
|
260
|
+
AVG(stress_average) as avg_stress,
|
|
261
|
+
AVG(body_battery_highest) as avg_body_battery_high,
|
|
262
|
+
AVG(body_battery_lowest) as avg_body_battery_low
|
|
263
|
+
FROM biometrics
|
|
264
|
+
WHERE date >= ? AND date <= ?
|
|
265
|
+
""",
|
|
266
|
+
(start_date, end_date),
|
|
267
|
+
)
|
|
268
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
269
|
+
return row
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class SleepRepository:
|
|
273
|
+
"""CRUD operations for the sleep table."""
|
|
274
|
+
|
|
275
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
276
|
+
self._conn = conn
|
|
277
|
+
|
|
278
|
+
def upsert(self, row: dict[str, Any]) -> None:
|
|
279
|
+
"""Insert or update sleep data by date."""
|
|
280
|
+
self._conn.execute(
|
|
281
|
+
"""
|
|
282
|
+
INSERT INTO sleep (
|
|
283
|
+
date, sleep_start, sleep_end, total_sleep_seconds,
|
|
284
|
+
deep_sleep_seconds, light_sleep_seconds, rem_sleep_seconds,
|
|
285
|
+
awake_sleep_seconds, sleep_score, raw_data
|
|
286
|
+
) VALUES (
|
|
287
|
+
:date, :sleep_start, :sleep_end, :total_sleep_seconds,
|
|
288
|
+
:deep_sleep_seconds, :light_sleep_seconds, :rem_sleep_seconds,
|
|
289
|
+
:awake_sleep_seconds, :sleep_score, :raw_data
|
|
290
|
+
)
|
|
291
|
+
ON CONFLICT(date) DO UPDATE SET
|
|
292
|
+
sleep_start = excluded.sleep_start,
|
|
293
|
+
sleep_end = excluded.sleep_end,
|
|
294
|
+
total_sleep_seconds = excluded.total_sleep_seconds,
|
|
295
|
+
deep_sleep_seconds = excluded.deep_sleep_seconds,
|
|
296
|
+
light_sleep_seconds = excluded.light_sleep_seconds,
|
|
297
|
+
rem_sleep_seconds = excluded.rem_sleep_seconds,
|
|
298
|
+
awake_sleep_seconds = excluded.awake_sleep_seconds,
|
|
299
|
+
sleep_score = excluded.sleep_score,
|
|
300
|
+
raw_data = excluded.raw_data,
|
|
301
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
302
|
+
""",
|
|
303
|
+
row,
|
|
304
|
+
)
|
|
305
|
+
self._conn.commit()
|
|
306
|
+
|
|
307
|
+
def get_by_date(self, date: str) -> Optional[sqlite3.Row]:
|
|
308
|
+
"""Get sleep data for a specific date (ISO format string)."""
|
|
309
|
+
cursor = self._conn.execute("SELECT * FROM sleep WHERE date = ?", (date,))
|
|
310
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
311
|
+
return row
|
|
312
|
+
|
|
313
|
+
def get_latest_date(self) -> Optional[str]:
|
|
314
|
+
"""Return the most recent date with sleep data, or None."""
|
|
315
|
+
cursor = self._conn.execute("SELECT date FROM sleep ORDER BY date DESC LIMIT 1")
|
|
316
|
+
row = cursor.fetchone()
|
|
317
|
+
result: Optional[str] = row["date"] if row else None
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
def count(self) -> int:
|
|
321
|
+
"""Return total number of stored sleep rows."""
|
|
322
|
+
cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM sleep")
|
|
323
|
+
cnt: int = cursor.fetchone()["cnt"]
|
|
324
|
+
return cnt
|
|
325
|
+
|
|
326
|
+
def get_by_date_range(self, start_date: str, end_date: str) -> list[sqlite3.Row]:
|
|
327
|
+
"""Get sleep rows within a date range (inclusive)."""
|
|
328
|
+
cursor = self._conn.execute(
|
|
329
|
+
"SELECT * FROM sleep WHERE date >= ? AND date <= ? ORDER BY date",
|
|
330
|
+
(start_date, end_date),
|
|
331
|
+
)
|
|
332
|
+
result: list[sqlite3.Row] = cursor.fetchall()
|
|
333
|
+
return result
|
|
334
|
+
|
|
335
|
+
def get_avg_stats(self, start_date: str, end_date: str) -> Optional[sqlite3.Row]:
|
|
336
|
+
"""Get average sleep stats for a date range."""
|
|
337
|
+
cursor = self._conn.execute(
|
|
338
|
+
"""
|
|
339
|
+
SELECT
|
|
340
|
+
COUNT(*) as record_count,
|
|
341
|
+
AVG(total_sleep_seconds) as avg_sleep_seconds,
|
|
342
|
+
AVG(deep_sleep_seconds) as avg_deep_sleep_seconds,
|
|
343
|
+
AVG(light_sleep_seconds) as avg_light_sleep_seconds,
|
|
344
|
+
AVG(rem_sleep_seconds) as avg_rem_sleep_seconds,
|
|
345
|
+
AVG(sleep_score) as avg_sleep_score
|
|
346
|
+
FROM sleep
|
|
347
|
+
WHERE date >= ? AND date <= ?
|
|
348
|
+
""",
|
|
349
|
+
(start_date, end_date),
|
|
350
|
+
)
|
|
351
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
352
|
+
return row
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
class SyncLogRepository:
|
|
356
|
+
"""Append-only audit trail for sync operations."""
|
|
357
|
+
|
|
358
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
359
|
+
self._conn = conn
|
|
360
|
+
|
|
361
|
+
def log(
|
|
362
|
+
self,
|
|
363
|
+
sync_type: str,
|
|
364
|
+
records_synced: int,
|
|
365
|
+
status: str = "success",
|
|
366
|
+
error_message: Optional[str] = None,
|
|
367
|
+
) -> None:
|
|
368
|
+
"""Append a sync log entry."""
|
|
369
|
+
self._conn.execute(
|
|
370
|
+
"""
|
|
371
|
+
INSERT INTO sync_log (sync_type, records_synced, status, error_message)
|
|
372
|
+
VALUES (?, ?, ?, ?)
|
|
373
|
+
""",
|
|
374
|
+
(sync_type, records_synced, status, error_message),
|
|
375
|
+
)
|
|
376
|
+
self._conn.commit()
|
|
377
|
+
|
|
378
|
+
def get_latest(self, sync_type: Optional[str] = None) -> Optional[sqlite3.Row]:
|
|
379
|
+
"""Get the most recent sync log entry, optionally filtered by type."""
|
|
380
|
+
if sync_type:
|
|
381
|
+
cursor = self._conn.execute(
|
|
382
|
+
"SELECT * FROM sync_log WHERE sync_type = ? ORDER BY id DESC LIMIT 1",
|
|
383
|
+
(sync_type,),
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
cursor = self._conn.execute("SELECT * FROM sync_log ORDER BY id DESC LIMIT 1")
|
|
387
|
+
row: Optional[sqlite3.Row] = cursor.fetchone()
|
|
388
|
+
return row
|
|
389
|
+
|
|
390
|
+
def get_all(self, limit: int = 100) -> list[sqlite3.Row]:
|
|
391
|
+
"""Get recent sync log entries."""
|
|
392
|
+
cursor = self._conn.execute("SELECT * FROM sync_log ORDER BY id DESC LIMIT ?", (limit,))
|
|
393
|
+
result: list[sqlite3.Row] = cursor.fetchall()
|
|
394
|
+
return result
|
|
395
|
+
|
|
396
|
+
def count(self) -> int:
|
|
397
|
+
"""Return total number of sync log entries."""
|
|
398
|
+
cursor = self._conn.execute("SELECT COUNT(*) as cnt FROM sync_log")
|
|
399
|
+
cnt: int = cursor.fetchone()["cnt"]
|
|
400
|
+
return cnt
|