diff --git a/backend/src/test_ai_modifier.py b/backend/scripts/test_ai_modifier.py similarity index 99% rename from backend/src/test_ai_modifier.py rename to backend/scripts/test_ai_modifier.py index 920e2be..bad6aef 100644 --- a/backend/src/test_ai_modifier.py +++ b/backend/scripts/test_ai_modifier.py @@ -2,7 +2,7 @@ import json import os import sys -from typing import Dict, Any +from typing import Any, Dict # Ensure we can import from src sys.path.append(os.path.dirname(os.path.abspath(__file__))) @@ -10,6 +10,7 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__))) from common.env_manager import EnvManager from garmin.workout_manager import WorkoutManager + def load_sample_workout() -> Dict[str, Any]: """Create a minimal valid workout for testing.""" return { diff --git a/backend/src/garmin/sync.py b/backend/src/garmin/sync.py index 2253267..f480b59 100644 --- a/backend/src/garmin/sync.py +++ b/backend/src/garmin/sync.py @@ -53,6 +53,26 @@ class GarminSync: pass return activities + def get_last_sync_date(self) -> Any: + """Get the date of the latest stored activity.""" + activities = self.load_local_activities() + if not activities: + return None + + latest_date = None + for act in activities: + start_str = act.get("startTimeLocal") + if start_str: + try: + # Parse "YYYY-MM-DD HH:MM:SS" -> date + d = datetime.strptime(start_str.split(" ")[0], "%Y-%m-%d").date() + if latest_date is None or d > latest_date: + latest_date = d + except ValueError: + continue + + return latest_date + def sync_smart(self) -> int: """Sync only new activities since the last local one.""" try: @@ -271,6 +291,22 @@ class GarminSync: label = k.replace("_", " ").title() breakdown_list.append({"label": label, "count": v}) + # VO2 Max Logic + sorted_acts = sorted( + activities, + key=lambda x: x.get("startTimeLocal", ""), + reverse=True + ) + + vo2_max = None + for act in sorted_acts: + if "vo2MaxValue" in act: + vo2_max = act["vo2MaxValue"] + break + if "vo2MaxCyclingValue" in act: + vo2_max = act["vo2MaxCyclingValue"] + break + return { "summary": { "total_hours": round(current_period["hours"], 1), @@ -278,5 +314,6 @@ class GarminSync: "period_label": "Last 7 Days" }, "breakdown": breakdown_list, - "strength_sessions": strength_count + "strength_sessions": strength_count, + "vo2_max": vo2_max } diff --git a/backend/src/main.py b/backend/src/main.py index fd516f8..0f9d30b 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -107,10 +107,16 @@ async def get_settings_status(): env.load_service_env("withings") env.load_service_env("gemini") + + # Get last sync date + sync = GarminSync(None, storage_dir=get_storage_path("garmin")) + last_date = sync.get_last_sync_date() + return { "garmin": { "configured": bool(os.getenv("GARMIN_EMAIL") and os.getenv("GARMIN_PASSWORD")), - "email": os.getenv("GARMIN_EMAIL") + "email": os.getenv("GARMIN_EMAIL"), + "last_synced_workout": last_date.strftime("%d.%m.%Y") if last_date else None }, "withings": { "configured": bool(os.getenv("WITHINGS_CLIENT_ID") and os.getenv("WITHINGS_CLIENT_SECRET")) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index d498052..7d577af 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -72,10 +72,19 @@ def test_settings_status(): assert response.status_code == 200 assert "garmin" in response.json() -def test_settings_status_v2(): +def test_settings_status_v2(mock_sync): + mock_sync.return_value.get_last_sync_date.return_value = None response = client.get("/settings/status") assert response.status_code == 200 assert "garmin" in response.json() + assert "last_synced_workout" in response.json()["garmin"] + +def test_settings_status_with_date(mock_sync): + from datetime import date + mock_sync.return_value.get_last_sync_date.return_value = date(2023, 12, 12) + response = client.get("/settings/status") + assert response.status_code == 200 + assert response.json()["garmin"]["last_synced_workout"] == "12.12.2023" def test_update_settings_garmin(): with patch("main.env") as mock_env: diff --git a/backend/tests/test_garmin_sync_unit.py b/backend/tests/test_garmin_sync_unit.py new file mode 100644 index 0000000..0bbf171 --- /dev/null +++ b/backend/tests/test_garmin_sync_unit.py @@ -0,0 +1,75 @@ +import json +import os +from datetime import date +from unittest.mock import MagicMock, patch + +import pytest + +from garmin.sync import GarminSync + + +@pytest.fixture +def mock_client(): + return MagicMock() + +@pytest.fixture +def temp_storage(tmp_path): + return str(tmp_path / "data") + +def test_get_last_sync_date(mock_client, temp_storage): + os.makedirs(temp_storage, exist_ok=True) + sync = GarminSync(mock_client, storage_dir=temp_storage) + + # 1. No files + assert sync.get_last_sync_date() is None + + # 2. Add files + today = date(2025, 12, 12) + with open(os.path.join(temp_storage, "activity_1.json"), "w") as f: + json.dump({"activityId": 1, "startTimeLocal": "2025-12-10 10:00:00"}, f) + + with open(os.path.join(temp_storage, "activity_2.json"), "w") as f: + json.dump({"activityId": 2, "startTimeLocal": "2025-12-12 10:00:00"}, f) + + # 3. Invalid Date + with open(os.path.join(temp_storage, "activity_bad.json"), "w") as f: + json.dump({"activityId": 3, "startTimeLocal": "invalid-date"}, f) + + assert sync.get_last_sync_date() == today + +def test_sync_smart_no_local(mock_client, temp_storage): + os.makedirs(temp_storage, exist_ok=True) + sync = GarminSync(mock_client, storage_dir=temp_storage) + + with patch.object(sync, 'sync_activities', return_value=10) as mock_sync_act: + count = sync.sync_smart() + assert count == 10 + mock_sync_act.assert_called_with(days=365) + +def test_sync_smart_incremental(mock_client, temp_storage): + os.makedirs(temp_storage, exist_ok=True) + sync = GarminSync(mock_client, storage_dir=temp_storage) + + # Latest is 2 days ago + mock_today = date(2025, 1, 5) + last_sync = date(2025, 1, 3) + + with open(os.path.join(temp_storage, "activity_1.json"), "w") as f: + json.dump({"activityId": 1, "startTimeLocal": f"{last_sync} 10:00:00"}, f) + + with patch("garmin.sync.date") as mock_date_cls: + mock_date_cls.today.return_value = mock_today + # Need to allow date constructor usage inside sync.py if used + # But Sync.py imports date, datetime separately. + + # When we patch 'garmin.sync.date', we replace the class 'date' imported in that module + # So date.today() is mocked. + # But side effect: date(2025,1,5) might fail if we mocked the constructor? + # Usually today is a classmethod. + + # Simpler: sync_smart calculates days = (today - latest_date).days + # If today=5th, latest=3rd. diff = 2. + + with patch.object(sync, 'sync_activities', return_value=5) as mock_sync_act: + sync.sync_smart() + mock_sync_act.assert_called_with(days=2) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 08120c2..332d1bc 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -524,7 +524,11 @@ const saveProfile = async () => {

- Garmin session is valid. Data is synced locally. + {{ + settingsStatus.garmin.last_synced_workout + ? `Last workout synced was performed on ${settingsStatus.garmin.last_synced_workout}` + : 'No workout synced' + }}

diff --git a/frontend/src/__tests__/PlanView.spec.js b/frontend/src/__tests__/PlanView.spec.js index 2003ecb..f5f5c9f 100644 --- a/frontend/src/__tests__/PlanView.spec.js +++ b/frontend/src/__tests__/PlanView.spec.js @@ -17,6 +17,14 @@ vi.mock('../components/WorkoutJsonEditor.vue', () => ({ props: ['modelValue'] } })) +vi.mock('../components/AiChatModal.vue', () => ({ + default: { + name: 'AiChatModal', + template: + '
', + props: ['isOpen', 'workout', 'loading'] + } +})) describe('PlanView.vue', () => { beforeEach(() => { @@ -119,21 +127,23 @@ describe('PlanView.vue', () => { await flushPromises() await wrapper.find('.primary-btn').trigger('click') - await wrapper.find('.ai-prompt-input').setValue('Make it harder') + fetch.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + workout: { + workoutName: 'Harder', + workoutSegments: [{ workoutSteps: [] }] + } + }) + }) + ) - fetch.mockResolvedValueOnce({ - ok: true, - json: () => - Promise.resolve({ - workout: { - workoutName: 'Harder', - workoutSegments: [{ workoutSteps: [] }] - } - }) - }) + await wrapper.find('button.ai-enhance-btn').trigger('click') + await wrapper.find('.stub-send').trigger('click') + await flushPromises() // Wait for fetch - await wrapper.find('.ai-btn').trigger('click') - await flushPromises() expect(wrapper.find('input[placeholder="Workout Name"]').element.value).toBe('Harder') }) diff --git a/frontend/src/components/AiChatModal.vue b/frontend/src/components/AiChatModal.vue index 3f8b10e..4464bd1 100644 --- a/frontend/src/components/AiChatModal.vue +++ b/frontend/src/components/AiChatModal.vue @@ -35,8 +35,9 @@ const handleAsk = () => {