Compare commits
No commits in common. "3fa3eb90eebf197c60221d6cea270b943bd0829e" and "408cfa87d1b889e753a9862b577dc478f82ed108" have entirely different histories.
3fa3eb90ee
...
408cfa87d1
|
|
@ -53,26 +53,6 @@ class GarminSync:
|
||||||
pass
|
pass
|
||||||
return activities
|
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:
|
def sync_smart(self) -> int:
|
||||||
"""Sync only new activities since the last local one."""
|
"""Sync only new activities since the last local one."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -291,22 +271,6 @@ class GarminSync:
|
||||||
label = k.replace("_", " ").title()
|
label = k.replace("_", " ").title()
|
||||||
breakdown_list.append({"label": label, "count": v})
|
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 {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"total_hours": round(current_period["hours"], 1),
|
"total_hours": round(current_period["hours"], 1),
|
||||||
|
|
@ -314,6 +278,5 @@ class GarminSync:
|
||||||
"period_label": "Last 7 Days"
|
"period_label": "Last 7 Days"
|
||||||
},
|
},
|
||||||
"breakdown": breakdown_list,
|
"breakdown": breakdown_list,
|
||||||
"strength_sessions": strength_count,
|
"strength_sessions": strength_count
|
||||||
"vo2_max": vo2_max
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,16 +107,10 @@ async def get_settings_status():
|
||||||
env.load_service_env("withings")
|
env.load_service_env("withings")
|
||||||
env.load_service_env("gemini")
|
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 {
|
return {
|
||||||
"garmin": {
|
"garmin": {
|
||||||
"configured": bool(os.getenv("GARMIN_EMAIL") and os.getenv("GARMIN_PASSWORD")),
|
"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": {
|
"withings": {
|
||||||
"configured": bool(os.getenv("WITHINGS_CLIENT_ID") and os.getenv("WITHINGS_CLIENT_SECRET"))
|
"configured": bool(os.getenv("WITHINGS_CLIENT_ID") and os.getenv("WITHINGS_CLIENT_SECRET"))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
from typing import Any, Dict
|
from typing import Dict, Any
|
||||||
|
|
||||||
# Ensure we can import from src
|
# Ensure we can import from src
|
||||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
@ -10,7 +10,6 @@ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||||
from common.env_manager import EnvManager
|
from common.env_manager import EnvManager
|
||||||
from garmin.workout_manager import WorkoutManager
|
from garmin.workout_manager import WorkoutManager
|
||||||
|
|
||||||
|
|
||||||
def load_sample_workout() -> Dict[str, Any]:
|
def load_sample_workout() -> Dict[str, Any]:
|
||||||
"""Create a minimal valid workout for testing."""
|
"""Create a minimal valid workout for testing."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -72,19 +72,10 @@ def test_settings_status():
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "garmin" in response.json()
|
assert "garmin" in response.json()
|
||||||
|
|
||||||
def test_settings_status_v2(mock_sync):
|
def test_settings_status_v2():
|
||||||
mock_sync.return_value.get_last_sync_date.return_value = None
|
|
||||||
response = client.get("/settings/status")
|
response = client.get("/settings/status")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "garmin" in response.json()
|
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():
|
def test_update_settings_garmin():
|
||||||
with patch("main.env") as mock_env:
|
with patch("main.env") as mock_env:
|
||||||
|
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -25,7 +25,7 @@ test('smoke test - app loads and editor opens', async ({ page }) => {
|
||||||
await navButton.click()
|
await navButton.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: 'My Plans' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: 'Workout Plans' })).toBeVisible()
|
||||||
|
|
||||||
// 4. Navigate to Editor
|
// 4. Navigate to Editor
|
||||||
await page.getByRole('button', { name: 'New Plan' }).click()
|
await page.getByRole('button', { name: 'New Plan' }).click()
|
||||||
|
|
|
||||||
|
|
@ -524,11 +524,7 @@ const saveProfile = async () => {
|
||||||
|
|
||||||
<div class="text-center" style="margin-top: 1rem">
|
<div class="text-center" style="margin-top: 1rem">
|
||||||
<p style="font-size: 0.8rem; color: var(--text-muted)">
|
<p style="font-size: 0.8rem; color: var(--text-muted)">
|
||||||
{{
|
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'
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,6 @@ vi.mock('../components/WorkoutJsonEditor.vue', () => ({
|
||||||
props: ['modelValue']
|
props: ['modelValue']
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
vi.mock('../components/AiChatModal.vue', () => ({
|
|
||||||
default: {
|
|
||||||
name: 'AiChatModal',
|
|
||||||
template:
|
|
||||||
'<div class="ai-chat-stub"><button class="stub-send" @click="$emit(\'ask\', \'Make it harder\')">Send</button></div>',
|
|
||||||
props: ['isOpen', 'workout', 'loading']
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
describe('PlanView.vue', () => {
|
describe('PlanView.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|
@ -127,8 +119,9 @@ describe('PlanView.vue', () => {
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
await wrapper.find('.primary-btn').trigger('click')
|
await wrapper.find('.primary-btn').trigger('click')
|
||||||
|
|
||||||
fetch.mockImplementationOnce(() =>
|
await wrapper.find('.ai-prompt-input').setValue('Make it harder')
|
||||||
Promise.resolve({
|
|
||||||
|
fetch.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
|
|
@ -138,12 +131,9 @@ describe('PlanView.vue', () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
)
|
|
||||||
|
|
||||||
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')
|
expect(wrapper.find('input[placeholder="Workout Name"]').element.value).toBe('Harder')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@ const handleAsk = () => {
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="ai-info-box">
|
<div class="ai-info-box">
|
||||||
<p class="text-sm text-gray-400 mb-4">
|
<p class="text-sm text-gray-400 mb-4">
|
||||||
Tell the AI how you want to modify <strong>{{ workout?.workoutName }}</strong
|
Tell the AI how you want to modify <strong>{{ workout?.workoutName }}</strong>.
|
||||||
>. For example: "Add 3 intervals of 1 minute fast", "Make it a recovery run", or "Add a
|
For example: "Add 3 intervals of 1 minute fast", "Make it a recovery run", or "Add a 15 min warmup".
|
||||||
15 min warmup".
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -140,9 +139,7 @@ const handleAsk = () => {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
transition:
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
transform 0.2s,
|
|
||||||
box-shadow 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-submit-btn:hover:not(:disabled) {
|
.ai-submit-btn:hover:not(:disabled) {
|
||||||
|
|
|
||||||
|
|
@ -55,22 +55,14 @@ onUnmounted(() => {
|
||||||
<div class="mobile-tabs md:hidden flex bg-gray-900 border-b border-gray-800 p-1">
|
<div class="mobile-tabs md:hidden flex bg-gray-900 border-b border-gray-800 p-1">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
||||||
:class="
|
:class="activeTab === 'original' ? 'bg-gray-800 text-white shadow' : 'text-gray-400 hover:text-white'"
|
||||||
activeTab === 'original'
|
|
||||||
? 'bg-gray-800 text-white shadow'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
"
|
|
||||||
@click="setTab('original')"
|
@click="setTab('original')"
|
||||||
>
|
>
|
||||||
<Eye class="w-4 h-4" /> Original
|
<Eye class="w-4 h-4" /> Original
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
class="flex-1 py-2 text-sm font-medium rounded-md transition-colors flex items-center justify-center gap-2"
|
||||||
:class="
|
:class="activeTab === 'modified' ? 'bg-blue-900/40 text-blue-100 shadow' : 'text-gray-400 hover:text-white'"
|
||||||
activeTab === 'modified'
|
|
||||||
? 'bg-blue-900/40 text-blue-100 shadow'
|
|
||||||
: 'text-gray-400 hover:text-white'
|
|
||||||
"
|
|
||||||
@click="setTab('modified')"
|
@click="setTab('modified')"
|
||||||
>
|
>
|
||||||
<Edit2 class="w-4 h-4" /> Modified
|
<Edit2 class="w-4 h-4" /> Modified
|
||||||
|
|
@ -79,14 +71,13 @@ onUnmounted(() => {
|
||||||
|
|
||||||
<!-- MAIN CONTENT AREA -->
|
<!-- MAIN CONTENT AREA -->
|
||||||
<div class="compare-container flex-1 overflow-hidden relative">
|
<div class="compare-container flex-1 overflow-hidden relative">
|
||||||
|
|
||||||
<!-- LEFT PANEL: ORIGINAL (READONLY) -->
|
<!-- LEFT PANEL: ORIGINAL (READONLY) -->
|
||||||
<div
|
<div
|
||||||
class="panel original-panel border-r border-gray-800 bg-gray-900/50 flex-1 min-w-0"
|
class="panel original-panel border-r border-gray-800 bg-gray-900/50 flex-1 min-w-0"
|
||||||
:class="{ hidden: activeTab === 'modified' }"
|
:class="{ 'hidden': activeTab === 'modified' }"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="panel-header text-xs font-bold text-gray-500 uppercase tracking-wider p-3 border-b border-gray-800 flex justify-between"
|
|
||||||
>
|
>
|
||||||
|
<div class="panel-header text-xs font-bold text-gray-500 uppercase tracking-wider p-3 border-b border-gray-800 flex justify-between">
|
||||||
<span>Original Version</span>
|
<span>Original Version</span>
|
||||||
<span class="bg-gray-800 px-2 py-0.5 rounded text-gray-400">Read-only</span>
|
<span class="bg-gray-800 px-2 py-0.5 rounded text-gray-400">Read-only</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,18 +91,20 @@ onUnmounted(() => {
|
||||||
:steps="original.workoutSegments[0].workoutSteps"
|
:steps="original.workoutSegments[0].workoutSteps"
|
||||||
readonly
|
readonly
|
||||||
/>
|
/>
|
||||||
<WorkoutJsonEditor v-else :modelValue="original" readonly />
|
<WorkoutJsonEditor
|
||||||
|
v-else
|
||||||
|
:modelValue="original"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RIGHT PANEL: MODIFIED (EDITABLE) -->
|
<!-- RIGHT PANEL: MODIFIED (EDITABLE) -->
|
||||||
<div
|
<div
|
||||||
class="panel modified-panel flex-1 min-w-0"
|
class="panel modified-panel flex-1 min-w-0"
|
||||||
:class="{ hidden: activeTab === 'original' }"
|
:class="{ 'hidden': activeTab === 'original' }"
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="panel-header text-xs font-bold text-blue-400 uppercase tracking-wider p-3 border-b border-gray-800 bg-blue-900/10 flex justify-between"
|
|
||||||
>
|
>
|
||||||
|
<div class="panel-header text-xs font-bold text-blue-400 uppercase tracking-wider p-3 border-b border-gray-800 bg-blue-900/10 flex justify-between">
|
||||||
<span>Working Copy</span>
|
<span>Working Copy</span>
|
||||||
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
|
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,15 +113,16 @@ onUnmounted(() => {
|
||||||
v-if="editorTab === 'visual'"
|
v-if="editorTab === 'visual'"
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
:steps="modelValue.workoutSegments[0].workoutSteps"
|
:steps="modelValue.workoutSegments[0].workoutSteps"
|
||||||
@update:modelValue="(val) => emit('update:modelValue', val)"
|
@update:modelValue="val => emit('update:modelValue', val)"
|
||||||
/>
|
/>
|
||||||
<WorkoutJsonEditor
|
<WorkoutJsonEditor
|
||||||
v-else
|
v-else
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="(val) => emit('update:modelValue', val)"
|
@update:modelValue="val => emit('update:modelValue', val)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -101,16 +101,6 @@ const getStepColor = (typeId) => {
|
||||||
return 'var(--text-muted)'
|
return 'var(--text-muted)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateStepType = (element, event) => {
|
|
||||||
element.stepTypeId = Number(event.target.value)
|
|
||||||
emitUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateEndCondition = (element, event) => {
|
|
||||||
element.endConditionId = Number(event.target.value)
|
|
||||||
emitUpdate()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -128,8 +118,7 @@ const updateEndCondition = (element, event) => {
|
||||||
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
||||||
:readonly="readonly"
|
:readonly="readonly"
|
||||||
@input="
|
@input="
|
||||||
!readonly &&
|
!readonly && emit('update:modelValue', {
|
||||||
emit('update:modelValue', {
|
|
||||||
...modelValue,
|
...modelValue,
|
||||||
workoutName: $event.target.value
|
workoutName: $event.target.value
|
||||||
})
|
})
|
||||||
|
|
@ -144,8 +133,7 @@ const updateEndCondition = (element, event) => {
|
||||||
class="bare-select"
|
class="bare-select"
|
||||||
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||||
@change="
|
@change="
|
||||||
!readonly &&
|
!readonly && emit('update:modelValue', {
|
||||||
emit('update:modelValue', {
|
|
||||||
...modelValue,
|
...modelValue,
|
||||||
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
||||||
})
|
})
|
||||||
|
|
@ -174,9 +162,7 @@ const updateEndCondition = (element, event) => {
|
||||||
<!-- REPEAT BLOCK -->
|
<!-- REPEAT BLOCK -->
|
||||||
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
||||||
<div class="repeat-header">
|
<div class="repeat-header">
|
||||||
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }">
|
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" /></div>
|
||||||
<GripVertical :size="16" />
|
|
||||||
</div>
|
|
||||||
<div style="flex: 1; display: flex; align-items: center; gap: 0.5rem">
|
<div style="flex: 1; display: flex; align-items: center; gap: 0.5rem">
|
||||||
<Repeat :size="16" />
|
<Repeat :size="16" />
|
||||||
<span style="font-weight: 600">Repeat</span>
|
<span style="font-weight: 600">Repeat</span>
|
||||||
|
|
@ -217,9 +203,7 @@ const updateEndCondition = (element, event) => {
|
||||||
|
|
||||||
<div class="step-content">
|
<div class="step-content">
|
||||||
<div class="step-row-top">
|
<div class="step-row-top">
|
||||||
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }">
|
<div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }"><GripVertical :size="16" color="var(--text-muted)" /></div>
|
||||||
<GripVertical :size="16" color="var(--text-muted)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step Type Select -->
|
<!-- Step Type Select -->
|
||||||
<!-- Bind direct to stepTypeId if available, else nested -->
|
<!-- Bind direct to stepTypeId if available, else nested -->
|
||||||
|
|
@ -228,7 +212,7 @@ const updateEndCondition = (element, event) => {
|
||||||
:value="element.stepTypeId"
|
:value="element.stepTypeId"
|
||||||
class="type-select"
|
class="type-select"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@change="updateStepType(element, $event)"
|
@change="element.stepTypeId = Number($event.target.value); emitUpdate()"
|
||||||
>
|
>
|
||||||
<option :value="0">Warmup</option>
|
<option :value="0">Warmup</option>
|
||||||
<option :value="1">Interval</option>
|
<option :value="1">Interval</option>
|
||||||
|
|
@ -251,7 +235,7 @@ const updateEndCondition = (element, event) => {
|
||||||
<select
|
<select
|
||||||
:value="element.endConditionId"
|
:value="element.endConditionId"
|
||||||
:disabled="readonly"
|
:disabled="readonly"
|
||||||
@change="updateEndCondition(element, $event)"
|
@change="element.endConditionId = Number($event.target.value); emitUpdate()"
|
||||||
>
|
>
|
||||||
<option :value="3">Distance</option>
|
<option :value="3">Distance</option>
|
||||||
<option :value="2">Time</option>
|
<option :value="2">Time</option>
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,7 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
*,
|
*, *::before, *::after {
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -305,45 +305,54 @@ const getSportName = (workout) => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col p-6 max-w-6xl mx-auto w-full">
|
<div class="h-full flex flex-col p-6 max-w-6xl mx-auto w-full">
|
||||||
<!-- BROWSER TOOLBAR -->
|
<!-- LINK TO DASHBOARD -->
|
||||||
<div v-if="viewMode === 'browser'" class="editor-toolbar mb-6 rounded-xl">
|
<div v-if="viewMode === 'browser'" class="mb-6 flex justify-between items-center">
|
||||||
<div class="toolbar-left">
|
<div>
|
||||||
<h2 class="text-lg font-bold text-white tracking-tight">My Plans</h2>
|
<h1
|
||||||
<div class="toolbar-divider"></div>
|
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 mb-2"
|
||||||
<!-- Search Input Stub -->
|
>
|
||||||
<div class="relative group">
|
Workout Plans
|
||||||
<input
|
</h1>
|
||||||
type="text"
|
<p class="text-gray-400">Manage your training collection</p>
|
||||||
placeholder="Search workouts..."
|
|
||||||
class="bg-transparent border-none text-sm text-white focus:outline-none w-48 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-right">
|
<div class="flex gap-4">
|
||||||
<!-- Source Toggle (Segmented) -->
|
<!-- Source Toggle -->
|
||||||
<div class="segmented-control">
|
<div class="flex gap-2 bg-gray-900/50 p-1 rounded-lg border border-gray-800">
|
||||||
<button :class="{ active: sourceMode === 'remote' }" @click="setSourceMode('remote')">
|
<button
|
||||||
|
:class="[
|
||||||
|
'px-4 py-2 rounded-md text-sm font-medium transition-all flex items-center gap-2',
|
||||||
|
sourceMode === 'remote'
|
||||||
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||||
|
: 'text-gray-400 hover:text-white hover:bg-white/5'
|
||||||
|
]"
|
||||||
|
@click="setSourceMode('remote')"
|
||||||
|
>
|
||||||
<Cloud class="w-4 h-4" />
|
<Cloud class="w-4 h-4" />
|
||||||
<span>Gapminder</span>
|
Gapminder
|
||||||
</button>
|
</button>
|
||||||
<button :class="{ active: sourceMode === 'local' }" @click="setSourceMode('local')">
|
<button
|
||||||
<FileJson class="w-4 h-4" />
|
class="px-4 py-1.5 rounded-md transition-all"
|
||||||
<span>Local</span>
|
:class="
|
||||||
|
sourceMode === 'local'
|
||||||
|
? 'bg-purple-600 text-white shadow-lg'
|
||||||
|
: 'text-gray-400 hover:text-white'
|
||||||
|
"
|
||||||
|
@click="setSourceMode('local')"
|
||||||
|
>
|
||||||
|
Local Files
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-divider"></div>
|
<button class="primary-btn" @click="createNewWorkout">
|
||||||
|
<Plus class="w-5 h-5" /> New Plan
|
||||||
<button class="toolbar-btn primary-btn" @click="createNewWorkout">
|
|
||||||
<Plus class="w-4 h-4" />
|
|
||||||
<span>New Plan</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- WORKOUT BROWSER GRID -->
|
<!-- WORKOUT BROWSER -->
|
||||||
<div v-if="viewMode === 'browser'" class="flex-1 overflow-y-auto custom-scrollbar px-1">
|
<div v-if="viewMode === 'browser'" class="flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
<!-- ... existing browser content ... -->
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center h-64 text-gray-500">
|
<div v-if="loading" class="flex flex-col items-center justify-center h-64 text-gray-500">
|
||||||
<Loader2 class="w-8 h-8 animate-spin mb-2" />
|
<Loader2 class="w-8 h-8 animate-spin mb-2" />
|
||||||
<span>Loading workouts...</span>
|
<span>Loading workouts...</span>
|
||||||
|
|
@ -358,59 +367,44 @@ const getSportName = (workout) => {
|
||||||
v-for="workout in workouts"
|
v-for="workout in workouts"
|
||||||
:key="workout.workoutId || workout.filename"
|
:key="workout.workoutId || workout.filename"
|
||||||
class="workout-card group"
|
class="workout-card group"
|
||||||
@click="editWorkout(workout)"
|
|
||||||
>
|
>
|
||||||
<!-- Card Badge (Top Right Action Area) -->
|
<div class="flex justify-between items-start mb-3">
|
||||||
<div
|
<div
|
||||||
class="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 z-10"
|
class="p-2 rounded-lg bg-gray-800 text-blue-400 group-hover:bg-blue-900/30 transition-colors"
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="p-2 bg-gray-800 hover:bg-gray-700 text-white rounded-lg shadow-lg border border-gray-700 transition-colors"
|
|
||||||
title="Duplicate"
|
|
||||||
@click.stop="duplicateWorkout(workout)"
|
|
||||||
>
|
|
||||||
<Copy class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="p-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg shadow-lg shadow-blue-900/20 border border-blue-500/50 transition-colors"
|
|
||||||
title="Edit"
|
|
||||||
@click.stop="editWorkout(workout)"
|
|
||||||
>
|
|
||||||
<Edit2 class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Card Icon -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 rounded-xl flex items-center justify-center bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 text-blue-400 group-hover:border-blue-500/30 group-hover:text-blue-300 transition-all shadow-inner"
|
|
||||||
>
|
>
|
||||||
<Dumbbell v-if="isStrength(workout)" class="w-6 h-6" />
|
<Dumbbell v-if="isStrength(workout)" class="w-6 h-6" />
|
||||||
<Activity v-else class="w-6 h-6" />
|
<Activity v-else class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
<!-- Content -->
|
class="icon-btn p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white"
|
||||||
<div class="flex-1">
|
title="Duplicate"
|
||||||
<h3
|
@click.stop="duplicateWorkout(workout)"
|
||||||
class="font-bold text-base text-white mb-1 group-hover:text-blue-400 transition-colors truncate"
|
|
||||||
>
|
>
|
||||||
{{ workout.workoutName }}
|
<Copy class="w-5 h-5" />
|
||||||
</h3>
|
</button>
|
||||||
<p class="text-xs text-gray-500 line-clamp-2 leading-relaxed">
|
<button
|
||||||
|
class="icon-btn p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white"
|
||||||
|
title="Edit"
|
||||||
|
@click.stop="editWorkout(workout)"
|
||||||
|
>
|
||||||
|
<Edit2 class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 class="font-bold text-lg mb-1 truncate">{{ workout.workoutName }}</h3>
|
||||||
|
<p class="text-xs text-gray-400 mb-4 line-clamp-2">
|
||||||
{{ workout.description || 'No description provided' }}
|
{{ workout.description || 'No description provided' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div
|
<div
|
||||||
class="mt-4 pt-3 border-t border-gray-800 flex justify-between items-center text-[10px] uppercase font-bold tracking-wider text-gray-600"
|
class="mt-auto flex justify-between items-center text-xs text-gray-500 border-t border-gray-800 pt-3"
|
||||||
>
|
>
|
||||||
<span>{{ getSportName(workout) }}</span>
|
<span>{{ getSportName(workout) }}</span>
|
||||||
<span v-if="workout.isLocal" class="text-purple-400/80 flex items-center gap-1">
|
<span v-if="workout.isLocal" class="text-purple-400 flex items-center gap-1">
|
||||||
<FileJson class="w-3 h-3" /> Local
|
<FileJson class="w-3 h-3" /> Local
|
||||||
</span>
|
</span>
|
||||||
<span v-else class="text-blue-400/80 flex items-center gap-1">
|
<span v-else class="text-blue-400 flex items-center gap-1">
|
||||||
<Cloud class="w-3 h-3" /> Garmin
|
<Cloud class="w-3 h-3" /> Garmin
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,11 +431,17 @@ const getSportName = (workout) => {
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<!-- View Mode Toggle (Segmented) -->
|
<!-- View Mode Toggle (Segmented) -->
|
||||||
<div class="segmented-control">
|
<div class="segmented-control">
|
||||||
<button :class="{ active: editorTab === 'visual' }" @click="editorTab = 'visual'">
|
<button
|
||||||
|
:class="{ active: editorTab === 'visual' }"
|
||||||
|
@click="editorTab = 'visual'"
|
||||||
|
>
|
||||||
<LayoutDashboard class="w-4 h-4" />
|
<LayoutDashboard class="w-4 h-4" />
|
||||||
<span>Visual</span>
|
<span>Visual</span>
|
||||||
</button>
|
</button>
|
||||||
<button :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
|
<button
|
||||||
|
:class="{ active: editorTab === 'json' }"
|
||||||
|
@click="editorTab = 'json'"
|
||||||
|
>
|
||||||
<Code class="w-4 h-4" />
|
<Code class="w-4 h-4" />
|
||||||
<span>JSON</span>
|
<span>JSON</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -450,13 +450,20 @@ const getSportName = (workout) => {
|
||||||
<div class="toolbar-divider"></div>
|
<div class="toolbar-divider"></div>
|
||||||
|
|
||||||
<!-- AI Button -->
|
<!-- AI Button -->
|
||||||
<button class="toolbar-btn ai-enhance-btn" @click="showAiModal = true">
|
<button
|
||||||
|
class="toolbar-btn ai-enhance-btn"
|
||||||
|
@click="showAiModal = true"
|
||||||
|
>
|
||||||
<Sparkles class="w-4 h-4" />
|
<Sparkles class="w-4 h-4" />
|
||||||
<span>Enhance</span>
|
<span>Enhance</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Sync/Save Button -->
|
<!-- Sync/Save Button -->
|
||||||
<button class="toolbar-btn primary-btn sync-btn" :disabled="syncing" @click="saveOrSync">
|
<button
|
||||||
|
class="toolbar-btn primary-btn sync-btn"
|
||||||
|
:disabled="syncing"
|
||||||
|
@click="saveOrSync"
|
||||||
|
>
|
||||||
<Cloud v-if="!syncing" class="w-4 h-4" />
|
<Cloud v-if="!syncing" class="w-4 h-4" />
|
||||||
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
||||||
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
|
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
|
||||||
|
|
@ -506,9 +513,8 @@ const getSportName = (workout) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- EDITOR CONTENT -->
|
<!-- EDITOR CONTENT -->
|
||||||
<div
|
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative p-4 overflow-y-auto">
|
||||||
class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative p-4 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<!-- COMPARE VIEW (For Edits) -->
|
<!-- COMPARE VIEW (For Edits) -->
|
||||||
<WorkoutCompareView
|
<WorkoutCompareView
|
||||||
v-if="editorMode === 'edit'"
|
v-if="editorMode === 'edit'"
|
||||||
|
|
@ -524,8 +530,12 @@ const getSportName = (workout) => {
|
||||||
v-model="workingWorkout"
|
v-model="workingWorkout"
|
||||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||||
/>
|
/>
|
||||||
<WorkoutJsonEditor v-else v-model="workingWorkout" />
|
<WorkoutJsonEditor
|
||||||
|
v-else
|
||||||
|
v-model="workingWorkout"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -579,8 +589,7 @@ const getSportName = (workout) => {
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-left,
|
.toolbar-left, .toolbar-right {
|
||||||
.toolbar-right {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -652,7 +661,7 @@ const getSportName = (workout) => {
|
||||||
.segmented-control button.active {
|
.segmented-control button.active {
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.segmented-control button:hover:not(.active) {
|
.segmented-control button:hover:not(.active) {
|
||||||
|
|
@ -693,24 +702,6 @@ const getSportName = (workout) => {
|
||||||
box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
|
box-shadow: 0 4px 12px rgba(31, 111, 235, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.workout-card {
|
|
||||||
background: #0d1117; /* Darker Github-like bg */
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.2s cubic-bezier(0.25, 0.8, 0.25, 1);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.workout-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: #3b82f6; /* Blue 500 */
|
|
||||||
box-shadow: 0 10px 30px -10px rgba(59, 130, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-res {
|
.sync-res {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue