Compare commits

...

2 Commits

Author SHA1 Message Date
Moritz Graf 3fa3eb90ee Adding new style 2026-01-02 20:08:53 +01:00
Moritz Graf 3b836d945e Adding latest changes 2026-01-02 17:58:42 +01:00
13 changed files with 332 additions and 154 deletions

View File

@ -2,7 +2,7 @@
import json import json
import os import os
import sys import sys
from typing import Dict, Any from typing import Any, Dict
# 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,6 +10,7 @@ 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 {

View File

@ -53,6 +53,26 @@ 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:
@ -271,6 +291,22 @@ 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),
@ -278,5 +314,6 @@ 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
} }

View File

@ -107,10 +107,16 @@ 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"))

View File

@ -72,10 +72,19 @@ 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(): def test_settings_status_v2(mock_sync):
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:

View File

@ -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)

View File

@ -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: 'Workout Plans' })).toBeVisible() await expect(page.getByRole('heading', { name: 'My 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()

View File

@ -524,7 +524,11 @@ 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>

View File

@ -17,6 +17,14 @@ 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(() => {
@ -119,21 +127,23 @@ describe('PlanView.vue', () => {
await flushPromises() await flushPromises()
await wrapper.find('.primary-btn').trigger('click') 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({ await wrapper.find('button.ai-enhance-btn').trigger('click')
ok: true, await wrapper.find('.stub-send').trigger('click')
json: () => await flushPromises() // Wait for fetch
Promise.resolve({
workout: {
workoutName: 'Harder',
workoutSegments: [{ workoutSteps: [] }]
}
})
})
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')
}) })

View File

@ -35,8 +35,9 @@ 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 15 min warmup". >. For example: "Add 3 intervals of 1 minute fast", "Make it a recovery run", or "Add a
15 min warmup".
</p> </p>
</div> </div>
@ -139,7 +140,9 @@ const handleAsk = () => {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
transition: transform 0.2s, box-shadow 0.2s; transition:
transform 0.2s,
box-shadow 0.2s;
} }
.ai-submit-btn:hover:not(:disabled) { .ai-submit-btn:hover:not(:disabled) {

View File

@ -55,14 +55,22 @@ 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="activeTab === 'original' ? 'bg-gray-800 text-white shadow' : 'text-gray-400 hover:text-white'" :class="
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="activeTab === 'modified' ? 'bg-blue-900/40 text-blue-100 shadow' : 'text-gray-400 hover:text-white'" :class="
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
@ -71,13 +79,14 @@ 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>
@ -91,38 +100,35 @@ onUnmounted(() => {
:steps="original.workoutSegments[0].workoutSteps" :steps="original.workoutSegments[0].workoutSteps"
readonly readonly
/> />
<WorkoutJsonEditor <WorkoutJsonEditor v-else :modelValue="original" readonly />
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
<span>Working Copy</span> 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 class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span> >
<span>Working Copy</span>
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
</div> </div>
<div class="panel-content overflow-y-auto h-full p-4"> <div class="panel-content overflow-y-auto h-full p-4">
<WorkoutVisualEditor <WorkoutVisualEditor
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>

View File

@ -101,6 +101,16 @@ 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>
@ -118,7 +128,8 @@ const getStepColor = (typeId) => {
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 && emit('update:modelValue', { !readonly &&
emit('update:modelValue', {
...modelValue, ...modelValue,
workoutName: $event.target.value workoutName: $event.target.value
}) })
@ -133,7 +144,8 @@ const getStepColor = (typeId) => {
class="bare-select" class="bare-select"
:class="{ 'opacity-50 cursor-not-allowed': readonly }" :class="{ 'opacity-50 cursor-not-allowed': readonly }"
@change=" @change="
!readonly && emit('update:modelValue', { !readonly &&
emit('update:modelValue', {
...modelValue, ...modelValue,
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) } sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
}) })
@ -162,7 +174,9 @@ const getStepColor = (typeId) => {
<!-- 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 }"><GripVertical :size="16" /></div> <div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }">
<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>
@ -203,7 +217,9 @@ const getStepColor = (typeId) => {
<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 }"><GripVertical :size="16" color="var(--text-muted)" /></div> <div class="drag-handle" :class="{ 'cursor-default opacity-50': readonly }">
<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 -->
@ -212,7 +228,7 @@ const getStepColor = (typeId) => {
:value="element.stepTypeId" :value="element.stepTypeId"
class="type-select" class="type-select"
:disabled="readonly" :disabled="readonly"
@change="element.stepTypeId = Number($event.target.value); emitUpdate()" @change="updateStepType(element, $event)"
> >
<option :value="0">Warmup</option> <option :value="0">Warmup</option>
<option :value="1">Interval</option> <option :value="1">Interval</option>
@ -235,7 +251,7 @@ const getStepColor = (typeId) => {
<select <select
:value="element.endConditionId" :value="element.endConditionId"
:disabled="readonly" :disabled="readonly"
@change="element.endConditionId = Number($event.target.value); emitUpdate()" @change="updateEndCondition(element, $event)"
> >
<option :value="3">Distance</option> <option :value="3">Distance</option>
<option :value="2">Time</option> <option :value="2">Time</option>

View File

@ -13,7 +13,9 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
*, *::before, *::after { *,
*::before,
*::after {
box-sizing: border-box; box-sizing: border-box;
} }

View File

@ -305,54 +305,45 @@ 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">
<!-- LINK TO DASHBOARD --> <!-- BROWSER TOOLBAR -->
<div v-if="viewMode === 'browser'" class="mb-6 flex justify-between items-center"> <div v-if="viewMode === 'browser'" class="editor-toolbar mb-6 rounded-xl">
<div> <div class="toolbar-left">
<h1 <h2 class="text-lg font-bold text-white tracking-tight">My Plans</h2>
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 mb-2" <div class="toolbar-divider"></div>
> <!-- Search Input Stub -->
Workout Plans <div class="relative group">
</h1> <input
<p class="text-gray-400">Manage your training collection</p> type="text"
placeholder="Search workouts..."
class="bg-transparent border-none text-sm text-white focus:outline-none w-48 transition-all"
/>
</div>
</div> </div>
<div class="flex gap-4"> <div class="toolbar-right">
<!-- Source Toggle --> <!-- Source Toggle (Segmented) -->
<div class="flex gap-2 bg-gray-900/50 p-1 rounded-lg border border-gray-800"> <div class="segmented-control">
<button <button :class="{ active: sourceMode === 'remote' }" @click="setSourceMode('remote')">
: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" />
Gapminder <span>Gapminder</span>
</button> </button>
<button <button :class="{ active: sourceMode === 'local' }" @click="setSourceMode('local')">
class="px-4 py-1.5 rounded-md transition-all" <FileJson class="w-4 h-4" />
:class=" <span>Local</span>
sourceMode === 'local'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white'
"
@click="setSourceMode('local')"
>
Local Files
</button> </button>
</div> </div>
<button class="primary-btn" @click="createNewWorkout"> <div class="toolbar-divider"></div>
<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 --> <!-- WORKOUT BROWSER GRID -->
<div v-if="viewMode === 'browser'" class="flex-1 overflow-y-auto custom-scrollbar"> <div v-if="viewMode === 'browser'" class="flex-1 overflow-y-auto custom-scrollbar px-1">
<!-- ... 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>
@ -367,44 +358,59 @@ 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)"
> >
<div class="flex justify-between items-start mb-3"> <!-- Card Badge (Top Right Action Area) -->
<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"
>
<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 <div
class="p-2 rounded-lg bg-gray-800 text-blue-400 group-hover:bg-blue-900/30 transition-colors" 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 class="flex gap-2">
<button
class="icon-btn p-1.5 hover:bg-white/10 rounded-md transition-colors text-gray-400 hover:text-white"
title="Duplicate"
@click.stop="duplicateWorkout(workout)"
>
<Copy class="w-5 h-5" />
</button>
<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> </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' }}
</p>
<!-- Content -->
<div class="flex-1">
<h3
class="font-bold text-base text-white mb-1 group-hover:text-blue-400 transition-colors truncate"
>
{{ workout.workoutName }}
</h3>
<p class="text-xs text-gray-500 line-clamp-2 leading-relaxed">
{{ workout.description || 'No description provided' }}
</p>
</div>
<!-- Footer -->
<div <div
class="mt-auto flex justify-between items-center text-xs text-gray-500 border-t border-gray-800 pt-3" 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"
> >
<span>{{ getSportName(workout) }}</span> <span>{{ getSportName(workout) }}</span>
<span v-if="workout.isLocal" class="text-purple-400 flex items-center gap-1"> <span v-if="workout.isLocal" class="text-purple-400/80 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 flex items-center gap-1"> <span v-else class="text-blue-400/80 flex items-center gap-1">
<Cloud class="w-3 h-3" /> Garmin <Cloud class="w-3 h-3" /> Garmin
</span> </span>
</div> </div>
@ -431,17 +437,11 @@ 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 <button :class="{ active: editorTab === 'visual' }" @click="editorTab = 'visual'">
: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 <button :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
: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,20 +450,13 @@ const getSportName = (workout) => {
<div class="toolbar-divider"></div> <div class="toolbar-divider"></div>
<!-- AI Button --> <!-- AI Button -->
<button <button class="toolbar-btn ai-enhance-btn" @click="showAiModal = true">
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 <button class="toolbar-btn primary-btn sync-btn" :disabled="syncing" @click="saveOrSync">
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>
@ -513,8 +506,9 @@ const getSportName = (workout) => {
</div> </div>
<!-- EDITOR CONTENT --> <!-- EDITOR CONTENT -->
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative p-4 overflow-y-auto"> <div
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'"
@ -530,12 +524,8 @@ const getSportName = (workout) => {
v-model="workingWorkout" v-model="workingWorkout"
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps" v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
/> />
<WorkoutJsonEditor <WorkoutJsonEditor v-else v-model="workingWorkout" />
v-else
v-model="workingWorkout"
/>
</div> </div>
</div> </div>
</div> </div>
@ -589,7 +579,8 @@ const getSportName = (workout) => {
z-index: 10; z-index: 10;
} }
.toolbar-left, .toolbar-right { .toolbar-left,
.toolbar-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@ -661,7 +652,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) {
@ -702,6 +693,24 @@ 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;