Adding latest changes
This commit is contained in:
@@ -524,7 +524,11 @@ const saveProfile = async () => {
|
||||
|
||||
<div class="text-center" style="margin-top: 1rem">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,14 @@ vi.mock('../components/WorkoutJsonEditor.vue', () => ({
|
||||
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', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
|
||||
@@ -35,8 +35,9 @@ const handleAsk = () => {
|
||||
<div class="modal-body">
|
||||
<div class="ai-info-box">
|
||||
<p class="text-sm text-gray-400 mb-4">
|
||||
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".
|
||||
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".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -48,10 +49,10 @@ const handleAsk = () => {
|
||||
:disabled="loading"
|
||||
@keyup.enter.exact.prevent="handleAsk"
|
||||
></textarea>
|
||||
|
||||
|
||||
<div class="flex justify-end mt-3">
|
||||
<button
|
||||
class="primary-btn ai-submit-btn"
|
||||
<button
|
||||
class="primary-btn ai-submit-btn"
|
||||
:disabled="!prompt.trim() || loading"
|
||||
@click="handleAsk"
|
||||
>
|
||||
@@ -139,7 +140,9 @@ const handleAsk = () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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) {
|
||||
|
||||
@@ -25,7 +25,7 @@ const emit = defineEmits(['update:modelValue'])
|
||||
const activeTab = ref('modified') // 'original' | 'modified' | 'split'
|
||||
|
||||
// Helper to determine if we can show split view (simple width check or just controlled by user preference)
|
||||
const canSplit = ref(true)
|
||||
const canSplit = ref(true)
|
||||
|
||||
const setTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
@@ -53,16 +53,24 @@ onUnmounted(() => {
|
||||
<div class="compare-view h-full flex flex-col">
|
||||
<!-- View Controls (only visible on small screens or if needed) -->
|
||||
<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="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')"
|
||||
>
|
||||
<Eye class="w-4 h-4" /> Original
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" /> Modified
|
||||
@@ -71,58 +79,56 @@ onUnmounted(() => {
|
||||
|
||||
<!-- MAIN CONTENT AREA -->
|
||||
<div class="compare-container flex-1 overflow-hidden relative">
|
||||
|
||||
<!-- 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="{ '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 class="bg-gray-800 px-2 py-0.5 rounded text-gray-400">Read-only</span>
|
||||
</div>
|
||||
<div class="panel-content overflow-y-auto h-full p-4 relative">
|
||||
<!-- Blocking Overlay for Interaction -->
|
||||
<div class="absolute inset-0 z-10 cursor-not-allowed"></div>
|
||||
|
||||
<WorkoutVisualEditor
|
||||
|
||||
<WorkoutVisualEditor
|
||||
v-if="editorTab === 'visual'"
|
||||
:modelValue="original"
|
||||
:steps="original.workoutSegments[0].workoutSteps"
|
||||
readonly
|
||||
/>
|
||||
<WorkoutJsonEditor
|
||||
v-else
|
||||
:modelValue="original"
|
||||
readonly
|
||||
/>
|
||||
<WorkoutJsonEditor v-else :modelValue="original" readonly />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT PANEL: MODIFIED (EDITABLE) -->
|
||||
<div
|
||||
<div
|
||||
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">
|
||||
<span>Working Copy</span>
|
||||
<span class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
|
||||
<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 class="bg-blue-900/30 px-2 py-0.5 rounded text-blue-200">Editable</span>
|
||||
</div>
|
||||
<div class="panel-content overflow-y-auto h-full p-4">
|
||||
<WorkoutVisualEditor
|
||||
<WorkoutVisualEditor
|
||||
v-if="editorTab === 'visual'"
|
||||
:modelValue="modelValue"
|
||||
:steps="modelValue.workoutSegments[0].workoutSteps"
|
||||
@update:modelValue="val => emit('update:modelValue', val)"
|
||||
@update:modelValue="(val) => emit('update:modelValue', val)"
|
||||
/>
|
||||
<WorkoutJsonEditor
|
||||
v-else
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="val => emit('update:modelValue', val)"
|
||||
<WorkoutJsonEditor
|
||||
v-else
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="(val) => emit('update:modelValue', val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -101,6 +101,16 @@ const getStepColor = (typeId) => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -118,7 +128,8 @@ const getStepColor = (typeId) => {
|
||||
style="width: 100%; text-align: left; font-size: 1.1rem; font-weight: 500"
|
||||
:readonly="readonly"
|
||||
@input="
|
||||
!readonly && emit('update:modelValue', {
|
||||
!readonly &&
|
||||
emit('update:modelValue', {
|
||||
...modelValue,
|
||||
workoutName: $event.target.value
|
||||
})
|
||||
@@ -133,7 +144,8 @@ const getStepColor = (typeId) => {
|
||||
class="bare-select"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': readonly }"
|
||||
@change="
|
||||
!readonly && emit('update:modelValue', {
|
||||
!readonly &&
|
||||
emit('update:modelValue', {
|
||||
...modelValue,
|
||||
sportType: { ...modelValue.sportType, sportTypeId: Number($event.target.value) }
|
||||
})
|
||||
@@ -162,7 +174,9 @@ const getStepColor = (typeId) => {
|
||||
<!-- REPEAT BLOCK -->
|
||||
<div v-if="element.type === 'RepeatGroupDTO'" class="repeat-block">
|
||||
<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">
|
||||
<Repeat :size="16" />
|
||||
<span style="font-weight: 600">Repeat</span>
|
||||
@@ -203,7 +217,9 @@ const getStepColor = (typeId) => {
|
||||
|
||||
<div class="step-content">
|
||||
<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 -->
|
||||
<!-- Bind direct to stepTypeId if available, else nested -->
|
||||
@@ -212,7 +228,7 @@ const getStepColor = (typeId) => {
|
||||
:value="element.stepTypeId"
|
||||
class="type-select"
|
||||
:disabled="readonly"
|
||||
@change="element.stepTypeId = Number($event.target.value); emitUpdate()"
|
||||
@change="updateStepType(element, $event)"
|
||||
>
|
||||
<option :value="0">Warmup</option>
|
||||
<option :value="1">Interval</option>
|
||||
@@ -232,10 +248,10 @@ const getStepColor = (typeId) => {
|
||||
<!-- Duration/Target -->
|
||||
<div class="detail-group">
|
||||
<label>Duration Type</label>
|
||||
<select
|
||||
:value="element.endConditionId"
|
||||
<select
|
||||
:value="element.endConditionId"
|
||||
:disabled="readonly"
|
||||
@change="element.endConditionId = Number($event.target.value); emitUpdate()"
|
||||
@change="updateEndCondition(element, $event)"
|
||||
>
|
||||
<option :value="3">Distance</option>
|
||||
<option :value="2">Time</option>
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
|
||||
@@ -113,11 +113,11 @@ const duplicateWorkout = (workout) => {
|
||||
workingWorkout.value = copy
|
||||
workingWorkout.value.isLocal = true // Default to local for safety, or prompt user? Let's say local.
|
||||
|
||||
// For duplicate, we treat it as a NEW workout derived from old, so no diff?
|
||||
// For duplicate, we treat it as a NEW workout derived from old, so no diff?
|
||||
// User requested "Diff for all edit/clone". So we should behave like 'edit'.
|
||||
// But wait, if it's a clone, the "Original" is the source?
|
||||
// But wait, if it's a clone, the "Original" is the source?
|
||||
// Let's copy it to original too so user sees what they started with.
|
||||
originalWorkout.value = JSON.parse(JSON.stringify(copy))
|
||||
originalWorkout.value = JSON.parse(JSON.stringify(copy))
|
||||
|
||||
viewMode.value = 'editor'
|
||||
editorMode.value = 'edit'
|
||||
@@ -431,17 +431,11 @@ const getSportName = (workout) => {
|
||||
<div class="toolbar-right">
|
||||
<!-- View Mode Toggle (Segmented) -->
|
||||
<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" />
|
||||
<span>Visual</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: editorTab === 'json' }"
|
||||
@click="editorTab = 'json'"
|
||||
>
|
||||
<button :class="{ active: editorTab === 'json' }" @click="editorTab = 'json'">
|
||||
<Code class="w-4 h-4" />
|
||||
<span>JSON</span>
|
||||
</button>
|
||||
@@ -450,20 +444,13 @@ const getSportName = (workout) => {
|
||||
<div class="toolbar-divider"></div>
|
||||
|
||||
<!-- 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" />
|
||||
<span>Enhance</span>
|
||||
</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" />
|
||||
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
||||
<span>{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}</span>
|
||||
@@ -513,8 +500,9 @@ const getSportName = (workout) => {
|
||||
</div>
|
||||
|
||||
<!-- 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) -->
|
||||
<WorkoutCompareView
|
||||
v-if="editorMode === 'edit'"
|
||||
@@ -530,17 +518,13 @@ const getSportName = (workout) => {
|
||||
v-model="workingWorkout"
|
||||
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
|
||||
/>
|
||||
<WorkoutJsonEditor
|
||||
v-else
|
||||
v-model="workingWorkout"
|
||||
/>
|
||||
<WorkoutJsonEditor v-else v-model="workingWorkout" />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI MODAL -->
|
||||
<AiChatModal
|
||||
<AiChatModal
|
||||
:is-open="showAiModal"
|
||||
:workout="workingWorkout"
|
||||
:loading="aiLoading"
|
||||
@@ -589,7 +573,8 @@ const getSportName = (workout) => {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-left, .toolbar-right {
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@@ -661,7 +646,7 @@ const getSportName = (workout) => {
|
||||
.segmented-control button.active {
|
||||
background: var(--border-color);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user