Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion fitbit_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
fitbit_cli Module
"""

__version__ = "1.8.0"
__version__ = "1.9.0"
56 changes: 44 additions & 12 deletions fitbit_cli/fitbit_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
Fitbit API
"""

from datetime import datetime, timedelta

import requests

from .exceptions import FitbitAPIError
Expand Down Expand Up @@ -134,29 +136,59 @@ def get_azm_intraday(self, start_date, end_date=None):
response = self.make_request("GET", url)
return response.json()

def _fetch_chunked_data(self, url_template, key, start_date, end_date=None):
"""Helper to fetch and aggregate data for APIs with max date range limits."""
max_days = 30
if not end_date:
url = url_template.format(date_range=start_date)
return self.make_request("GET", url).json()

start = (
datetime.strptime(str(start_date), "%Y-%m-%d").date()
if isinstance(start_date, str)
else start_date
)
end = (
datetime.strptime(str(end_date), "%Y-%m-%d").date()
if isinstance(end_date, str)
else end_date
)

if (end - start).days < max_days:
url = url_template.format(date_range=f"{start_date}/{end_date}")
return self.make_request("GET", url).json()

combined_items = []
curr_start = start
while curr_start <= end:
curr_end = min(curr_start + timedelta(days=max_days - 1), end)
chunk_range = (
f"{curr_start.strftime('%Y-%m-%d')}/{curr_end.strftime('%Y-%m-%d')}"
)
url = url_template.format(date_range=chunk_range)
response = self.make_request("GET", url).json()
combined_items.extend(response.get(key, []))
curr_start = curr_end + timedelta(days=1)

return {key: combined_items}

def get_breathing_rate_summary(self, start_date, end_date=None):
"""Get Breathing Rate Summary by Interval and Data"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}.json"
return self._fetch_chunked_data(url_template, "br", start_date, end_date)

def get_breathing_rate_intraday(self, start_date, end_date=None):
"""Get Breathing Rate Intraday by Interval and Data"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/br/date/{date_range}/all.json"
return self._fetch_chunked_data(url_template, "br", start_date, end_date)

def get_hrv_summary(self, start_date, end_date=None):
"""Get HRV Summary by Interval and Date"""

date_range = f"{start_date}/{end_date}" if end_date else start_date
url = f"https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json"
response = self.make_request("GET", url)
return response.json()
url_template = "https://api.fitbit.com/1/user/-/hrv/date/{date_range}.json"
return self._fetch_chunked_data(url_template, "hrv", start_date, end_date)

def get_body_time_series(self, resource_path, start_date, end_date=None):
"""Get Body Time Series by Interval and Date"""
Expand Down
106 changes: 106 additions & 0 deletions tests/date_range_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# -*- coding: utf-8 -*-
"""
Date Range Chunking Tests for Breathing Rate and HRV APIs
"""

import os
import sys
import unittest
from datetime import date
from unittest.mock import MagicMock

# Add the parent directory to sys.path to make imports work
sys.path.insert(0, os.path.abspath(os.path.dirname(os.path.dirname(__file__))))

# pylint: disable=C0413
from fitbit_cli.fitbit_api import FitbitAPI


class TestDateRangeChunking(unittest.TestCase):
"""Test suite for auto-chunking date ranges > 30 days for Breathing Rate and HRV."""

def setUp(self):
self.fitbit = FitbitAPI("client", "secret", "access", "refresh")

def test_breathing_rate_single_date(self):
"""Test breathing rate summary with a single date."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [{"dateTime": "2026-01-01", "value": {"breathingRate": 14.0}}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_summary("2026-01-01")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01.json"
)
self.assertEqual(len(res["br"]), 1)

def test_breathing_rate_short_range(self):
"""Test breathing rate summary with a range <= 30 days."""
mock_resp = MagicMock()
mock_resp.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-15"}]
}
self.fitbit.make_request = MagicMock(return_value=mock_resp)

res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-01-15")

self.fitbit.make_request.assert_called_once_with(
"GET", "https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-15.json"
)
self.assertEqual(len(res["br"]), 2)

def test_breathing_rate_long_range_chunks_and_aggregates(self):
"""Test breathing rate summary with a range > 30 days automatically chunks and merges data."""
mock_resp1 = MagicMock()
mock_resp1.json.return_value = {
"br": [{"dateTime": "2026-01-01"}, {"dateTime": "2026-01-30"}]
}

mock_resp2 = MagicMock()
mock_resp2.json.return_value = {
"br": [{"dateTime": "2026-01-31"}, {"dateTime": "2026-03-01"}]
}

self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2])

# Range is 59 days: 2026-01-01 to 2026-03-01
res = self.fitbit.get_breathing_rate_summary("2026-01-01", "2026-03-01")

expected_calls = [
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-01/2026-01-30.json",
),
),
(
(
"GET",
"https://api.fitbit.com/1/user/-/br/date/2026-01-31/2026-03-01.json",
),
),
]
self.assertEqual(self.fitbit.make_request.call_args_list, expected_calls)
self.assertEqual(len(res["br"]), 4)

def test_hrv_long_range_chunks_and_aggregates(self):
"""Test HRV summary with a range > 30 days automatically chunks and merges data."""
mock_resp1 = MagicMock()
mock_resp1.json.return_value = {"hrv": [{"dateTime": "2026-01-01"}]}

mock_resp2 = MagicMock()
mock_resp2.json.return_value = {"hrv": [{"dateTime": "2026-01-31"}]}

self.fitbit.make_request = MagicMock(side_effect=[mock_resp1, mock_resp2])

res = self.fitbit.get_hrv_summary(date(2026, 1, 1), date(2026, 3, 1))

self.assertEqual(self.fitbit.make_request.call_count, 2)
self.assertEqual(len(res["hrv"]), 2)


if __name__ == "__main__":
unittest.main()