feat: enhance UI, fix backend routes, and add E2E quality gates

- UI: Polish PlanView toggles, buttons, and fix missing icons (running/activity).
- Backend: Fix route shadowing in main.py, add startup route check, and improve API test coverage.
- Tests: Add Playwright E2E smoke test, eslint-plugin-import, and integrate all into 'make check'.
This commit is contained in:
Moritz Graf
2026-01-02 09:40:37 +01:00
parent a2c86dfea7
commit 715da2a816
26 changed files with 3753 additions and 605 deletions
+9
View File
@@ -170,6 +170,15 @@ onMounted(() => {
<button @click="handleChipClick('Why is my volume increasing?')">
Analyze volume trend
</button>
<button
@click="
handleChipClick(
'Analyze my last trainings and make recommendations about my trainings the next 3 days'
)
"
>
Analyze & Recommend
</button>
</div>
</div>
+387 -188
View File
@@ -1,16 +1,20 @@
<script setup>
import { ref, onMounted } from 'vue'
import {
Calendar,
Plus,
Copy,
Edit,
Edit2, // Was Edit, but Edit2 used in template
ArrowLeft,
UploadCloud,
Cloud, // Used in template
Loader2,
Sparkles,
Code,
Layout
LayoutDashboard, // Used in template
AlertTriangle,
CheckCircle2,
Dumbbell,
Activity,
FileJson
} from 'lucide-vue-next'
import WorkoutVisualEditor from '../components/WorkoutVisualEditor.vue'
import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
@@ -18,6 +22,7 @@ import WorkoutJsonEditor from '../components/WorkoutJsonEditor.vue'
// State
const viewMode = ref('browser') // 'browser' | 'editor'
const editorTab = ref('visual') // 'visual' | 'json'
const sourceMode = ref('remote') // 'remote' | 'local'
const workouts = ref([])
const loading = ref(false)
const syncing = ref(false)
@@ -33,10 +38,30 @@ const aiError = ref('')
const fetchWorkouts = async () => {
loading.value = true
workouts.value = []
try {
const res = await fetch('http://localhost:8000/workouts')
const url =
sourceMode.value === 'local'
? 'http://localhost:8000/workouts/local'
: 'http://localhost:8000/workouts'
const res = await fetch(url)
if (res.ok) {
workouts.value = await res.json()
const data = await res.json()
// Normalize structure: Local returns filenames, Remote returns objects
if (sourceMode.value === 'local') {
// Wrap filenames in minimal workout objects for display
workouts.value = data.map((f) => ({
workoutId: f,
workoutName: f.replace('.json', ''),
description: 'Local File',
sportType: { sportTypeKey: 'local' },
isLocal: true,
filename: f
}))
} else {
workouts.value = data
}
}
} catch (error) {
console.error('Fetch workouts failed', error)
@@ -45,6 +70,11 @@ const fetchWorkouts = async () => {
}
}
const setSourceMode = (mode) => {
sourceMode.value = mode
fetchWorkouts()
}
const createNewWorkout = () => {
workingWorkout.value = {
workoutName: 'New Workout',
@@ -56,36 +86,125 @@ const createNewWorkout = () => {
sportType: { sportTypeId: 1, sportTypeKey: 'running' },
workoutSteps: []
}
]
],
// If local, we need to know it's a new local file
isLocal: sourceMode.value === 'local'
}
viewMode.value = 'editor'
editorTab.value = 'visual'
syncResult.value = null
}
const editWorkout = (workout) => {
// Deep copy to avoid mutating list directly
workingWorkout.value = JSON.parse(JSON.stringify(workout))
const duplicateWorkout = (workout) => {
// 1. Create deep copy
const copy = JSON.parse(JSON.stringify(workout))
// 2. Assign new details
copy.workoutName = `${copy.workoutName} (Copy)`
delete copy.workoutId
delete copy.uploadDate
// 3. Set as working
workingWorkout.value = copy
workingWorkout.value.isLocal = true // Default to local for safety, or prompt user? Let's say local.
viewMode.value = 'editor'
editorTab.value = 'visual'
syncResult.value = null
}
const duplicateWorkout = (workout) => {
const copy = JSON.parse(JSON.stringify(workout))
copy.workoutName = `${copy.workoutName} (Copy)`
// Clear ID to ensure it treats as new if we were persisting IDs (remote IDs ignored on upload usually)
delete copy.workoutId
workingWorkout.value = copy
viewMode.value = 'editor'
syncResult.value = null
const copyDebugInfo = async () => {
if (!syncResult.value || syncResult.value.success) return
const err = syncResult.value.error
const json = JSON.stringify(workingWorkout.value, null, 2)
const prompt = `I am encountering a Garmin Sync Error.
Error: ${err}
Workout JSON:
\`\`\`json
${json}
\`\`\`
Please debug this.`
try {
await navigator.clipboard.writeText(prompt)
alert('Debug info copied to clipboard!')
} catch (e) {
console.error('Failed to copy', e)
alert('Failed to copy to clipboard. Check console.')
}
}
const editWorkout = async (workout) => {
try {
loading.value = true
// Determine source
if (workout.isLocal) {
const res = await fetch(`http://localhost:8000/workouts/local/${workout.filename}`)
if (res.ok) {
const data = await res.json()
workingWorkout.value = { ...data, isLocal: true, filename: workout.filename }
} else {
throw new Error('Failed to load local')
}
} else {
// REMOTE: Fetch full details now!
// The list only gives metadata. We need segments/steps.
const res = await fetch(`http://localhost:8000/workouts/${workout.workoutId}`)
if (res.ok) {
const data = await res.json()
workingWorkout.value = { ...data, isLocal: false }
} else {
throw new Error('Failed to load remote')
}
}
viewMode.value = 'editor'
editorTab.value = 'visual'
syncResult.value = null
} catch (e) {
console.error('Failed to load workout', e)
alert('Failed to load workout. See console.')
} finally {
loading.value = false
}
}
// --- EDITOR ACTIONS ---
const syncToGarmin = async () => {
const saveOrSync = async () => {
syncing.value = true
syncResult.value = null
syncResult.value = null // Local Save
if (workingWorkout.value.isLocal) {
try {
const filename =
workingWorkout.value.filename || `workout_${Math.floor(Date.now() / 1000)}.json`
const res = await fetch(`http://localhost:8000/workouts/local/${filename}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: filename,
workout: workingWorkout.value
})
})
const data = await res.json()
if (data.status === 'SUCCESS') {
syncResult.value = { success: true, msg: 'Saved locally!' }
workingWorkout.value.filename = data.filename
} else {
syncResult.value = { success: false, error: 'Save failed' }
}
} catch (e) {
syncResult.value = { success: false, error: 'Save failed: ' + e }
} finally {
syncing.value = false
}
return
}
// Remote Sync (Existing Logic)
try {
const res = await fetch('http://localhost:8000/workouts/upload', {
method: 'POST',
@@ -94,25 +213,26 @@ const syncToGarmin = async () => {
})
const data = await res.json()
if (data.success) {
// Note: Backend changed 'status' to 'success' boolean
syncResult.value = { type: 'success', msg: 'Uploaded to Garmin!' }
syncResult.value = { success: true, msg: 'Uploaded to Garmin!' }
} else {
syncResult.value = { type: 'error', msg: 'Upload failed: ' + (data.error || 'Unknown error') }
let errMsg = 'Upload failed: ' + (data.error || 'Unknown error')
if (data.details) {
console.error('Validation Details:', data.details)
// Could show detailed validation errors in UI here
syncResult.value.msg += ' (Check Console)'
// We might want to embed details in the error object for debug info?
// Let's store raw details too
syncResult.value = { success: false, error: errMsg, details: data.details }
} else {
syncResult.value = { success: false, error: errMsg }
}
}
} catch (e) {
syncResult.value = { type: 'error', msg: 'Network error during sync.' }
} catch (_e) {
syncResult.value = { success: false, error: 'Network error during sync.' }
} finally {
syncing.value = false
}
}
// --- AI ACTIONS ---
const askAI = async () => {
if (!aiPrompt.value.trim()) return
@@ -134,7 +254,7 @@ const askAI = async () => {
} else if (data.error) {
aiError.value = data.error
}
} catch (err) {
} catch (_err) {
aiError.value = 'Failed to contact AI.'
} finally {
aiLoading.value = false
@@ -145,125 +265,265 @@ const askAI = async () => {
onMounted(() => {
fetchWorkouts()
})
const isStrength = (workout) => {
if (workout.sportType?.sportTypeKey === 'strength_training') return true
if (workout.sportType?.sportTypeId === 5) return true
return false
}
const getSportName = (workout) => {
if (workout.sportType?.sportTypeKey) {
return workout.sportType.sportTypeKey.replace('_', ' ').toUpperCase()
}
const id = workout.sportType?.sportTypeId || workout.sportTypeId
if (id === 1) return 'RUNNING'
if (id === 2) return 'CYCLING'
if (id === 5) return 'STRENGTH'
return 'OTHER'
}
</script>
<template>
<div class="plan-view">
<!-- BROWSER MODE -->
<div v-if="viewMode === 'browser'" class="browser-mode">
<div class="card toolbar">
<h3><Calendar :size="24" /> Existing Workouts</h3>
<button class="primary-btn" @click="createNewWorkout">
<Plus :size="18" /> New Workout
</button>
<div class="h-full flex flex-col p-6 max-w-6xl mx-auto w-full">
<!-- LINK TO DASHBOARD -->
<div v-if="viewMode === 'browser'" class="mb-6 flex justify-between items-center">
<div>
<h1
class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-400 mb-2"
>
Workout Plans
</h1>
<p class="text-gray-400">Manage your training collection</p>
</div>
<div v-if="loading" style="text-align: center; padding: 2rem">
<Loader2 class="spinner" /> Loading remote workouts...
<div class="flex gap-4">
<!-- Source Toggle -->
<div class="flex gap-2 bg-gray-900/50 p-1 rounded-lg border border-gray-800">
<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" />
Gapminder
</button>
<button
class="px-4 py-1.5 rounded-md transition-all"
:class="
sourceMode === 'local'
? 'bg-purple-600 text-white shadow-lg'
: 'text-gray-400 hover:text-white'
"
@click="setSourceMode('local')"
>
Local Files
</button>
</div>
<button class="primary-btn" @click="createNewWorkout">
<Plus class="w-5 h-5" /> New Plan
</button>
</div>
</div>
<!-- WORKOUT BROWSER -->
<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">
<Loader2 class="w-8 h-8 animate-spin mb-2" />
<span>Loading workouts...</span>
</div>
<div v-else-if="workouts.length === 0" class="text-center text-gray-500 mt-20">
<p>No workouts found.</p>
</div>
<div v-else class="workout-grid">
<div v-if="workouts.length === 0" class="empty-state">No workouts found. Create one!</div>
<div v-for="w in workouts" :key="w.workoutId" class="workout-card">
<div class="w-header">
<h4>{{ w.workoutName }}</h4>
<span class="badge">{{ w.sportType?.sportTypeKey }}</span>
<div
v-for="workout in workouts"
:key="workout.workoutId || workout.filename"
class="workout-card group"
>
<div class="flex justify-between items-start mb-3">
<div
class="p-2 rounded-lg bg-gray-800 text-blue-400 group-hover:bg-blue-900/30 transition-colors"
>
<Dumbbell v-if="isStrength(workout)" class="w-6 h-6" />
<Activity v-else class="w-6 h-6" />
</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>
<p class="desc">{{ w.description || 'No description' }}</p>
<div class="actions">
<button class="icon-btn" title="Duplicate" @click="duplicateWorkout(w)">
<Copy :size="16" />
</button>
<button class="icon-btn" title="Edit" @click="editWorkout(w)">
<Edit :size="16" />
</button>
<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>
<div
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 v-if="workout.isLocal" class="text-purple-400 flex items-center gap-1">
<FileJson class="w-3 h-3" /> Local
</span>
<span v-else class="text-blue-400 flex items-center gap-1">
<Cloud class="w-3 h-3" /> Garmin
</span>
</div>
</div>
</div>
</div>
<!-- EDITOR MODE -->
<div v-if="viewMode === 'editor'" class="editor-mode">
<!-- Editor Header -->
<div class="card editor-header">
<div class="left-controls">
<button class="icon-btn" @click="viewMode = 'browser'"><ArrowLeft :size="20" /></button>
<input
v-model="workingWorkout.workoutName"
class="title-input"
placeholder="Workout Name"
/>
</div>
<div class="right-controls">
<span v-if="syncResult" :class="['sync-res', syncResult.type]">
{{ syncResult.msg }}
</span>
<button class="primary-btn" :disabled="syncing" @click="syncToGarmin">
<UploadCloud v-if="!syncing" :size="18" />
<Loader2 v-else class="spinner" :size="18" />
{{ syncing ? 'Syncing...' : 'Sync to Garmin' }}
<div v-else class="flex flex-col h-full gap-4">
<!-- HEADER ROW -->
<div class="flex items-center gap-4 bg-gray-900/50 p-2 rounded-xl border border-gray-800">
<button
class="flex items-center gap-2 px-3 py-2 text-gray-400 hover:text-white hover:bg-gray-800 rounded-lg transition-colors"
@click="viewMode = 'browser'"
>
<ArrowLeft class="w-5 h-5" />
<span class="font-medium">Back</span>
</button>
<input
v-model="workingWorkout.workoutName"
class="bg-transparent text-xl font-bold focus:outline-none border-b border-transparent focus:border-blue-500 px-2 py-1 min-w-[200px]"
placeholder="Workout Name"
/>
<div class="flex-1"></div>
<!-- Editor Toggle -->
<div class="bg-gray-800 p-0.5 rounded-lg flex text-xs">
<button
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
:class="
editorTab === 'visual'
? 'bg-gray-700 text-white shadow'
: 'text-gray-400 hover:text-white'
"
@click="editorTab = 'visual'"
>
<LayoutDashboard class="w-3 h-3" /> Visual
</button>
<button
class="px-3 py-1.5 rounded-md transition-all flex items-center gap-2"
:class="
editorTab === 'json'
? 'bg-gray-700 text-white shadow'
: 'text-gray-400 hover:text-white'
"
@click="editorTab = 'json'"
>
<Code class="w-3 h-3" /> JSON
</button>
</div>
<button
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium shadow-lg shadow-blue-900/20 transition-all"
:disabled="syncing"
@click="saveOrSync"
>
<Cloud v-if="!syncing" class="w-4 h-4" />
<Loader2 v-else class="w-4 h-4 animate-spin" />
{{ workingWorkout.isLocal ? 'Save Local' : 'Sync to Garmin' }}
</button>
</div>
<!-- AI Assistant Bar -->
<div class="card ai-bar">
<div class="ai-input-wrapper">
<Sparkles :size="20" class="ai-icon" />
<!-- AI BAR & ERRORS -->
<div class="flex items-center gap-2">
<div
:class="[
'flex-1 flex items-center gap-3 bg-gray-900 border border-gray-700 px-4 py-2 rounded-xl focus-within:border-purple-500 focus-within:ring-1 focus-within:ring-purple-500/50 transition-all shadow-sm',
aiLoading ? 'opacity-75' : ''
]"
>
<Sparkles class="w-5 h-5 text-purple-400" />
<input
v-model="aiPrompt"
class="ai-prompt-input bg-transparent border-none focus:outline-none text-sm w-full placeholder-gray-500"
placeholder="Ask AI to modify... (e.g. 'Add a 10 min warmup' or 'Make intervals harder')"
:disabled="aiLoading"
@keyup.enter="askAI"
/>
<button class="ai-btn" :disabled="!aiPrompt || aiLoading" @click="askAI">
<Loader2 v-if="aiLoading" class="spinner" :size="16" />
<span v-else>Generate</span>
<button
class="ai-btn px-3 py-1 bg-purple-600/20 text-purple-300 hover:bg-purple-600 hover:text-white text-xs font-bold rounded uppercase tracking-wider transition-colors"
:disabled="!aiPrompt.trim() || aiLoading"
@click="askAI"
>
{{ aiLoading ? 'Thinking...' : 'Generate' }}
</button>
</div>
<div v-if="aiError" class="ai-error">{{ aiError }}</div>
</div>
<!-- Editor Tabs -->
<div class="flex gap-2 border-b border-gray-700 mb-2">
<button
:class="[
'px-4 py-2 text-sm flex items-center gap-2 border-b-2',
editorTab === 'visual'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
]"
@click="editorTab = 'visual'"
>
<Layout class="w-4 h-4" /> Visual Editor
</button>
<button
:class="[
'px-4 py-2 text-sm flex items-center gap-2 border-b-2',
editorTab === 'json'
? 'border-purple-500 text-purple-400'
: 'border-transparent text-gray-500 hover:text-gray-300'
]"
@click="editorTab = 'json'"
>
<Code class="w-4 h-4" /> JSON Source
</button>
<!-- ERROR FEEDBACK -->
<div
v-if="aiError"
class="p-3 bg-red-900/30 border border-red-800 text-red-200 rounded-lg text-sm flex items-center gap-2"
>
<AlertTriangle class="w-4 h-4" />
{{ aiError }}
</div>
<!-- Editor Content -->
<div class="flex-1 min-h-0">
<div v-if="editorTab === 'visual'" class="h-full overflow-y-auto pr-2">
<!-- Using the new Visual Editor component -->
<!-- We bind to workoutSteps of the first segment for simplicity, or we could make the editor handle full workout object. -->
<!-- Let's bind to the workout object to let it handle metadata, but the dragger needs a list. -->
<!-- The VisualEditor I designed takes `modelValue` (metadata) AND `steps` (list). -->
<WorkoutVisualEditor
v-model="workingWorkout"
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
/>
<!-- SYNC ERROR DIALOG -->
<div
v-if="syncResult && !syncResult.success"
class="p-4 bg-red-900/20 border border-red-800/50 rounded-xl"
>
<div class="flex items-start justify-between">
<div class="flex gap-3">
<div class="p-2 bg-red-900/30 rounded-lg text-red-400">
<AlertTriangle class="w-5 h-5" />
</div>
<div>
<h4 class="font-bold text-red-400">Sync Failed</h4>
<p class="text-sm text-red-200/80 mt-1">{{ syncResult.error }}</p>
</div>
</div>
<button
class="flex items-center gap-2 px-3 py-1.5 bg-red-900/40 hover:bg-red-900/60 text-red-300 rounded-lg text-xs font-medium transition-colors border border-red-800/50"
@click="copyDebugInfo"
>
<Copy class="w-3 h-3" /> Copy Debug Info
</button>
</div>
</div>
<div v-else-if="editorTab === 'json'" class="h-full">
<div
v-if="syncResult && syncResult.success"
class="p-3 bg-green-900/30 border border-green-800 text-green-200 rounded-lg text-sm flex items-center gap-2"
>
<CheckCircle2 class="w-4 h-4" />
{{ workingWorkout.isLocal ? 'Saved locally!' : 'Uploaded to Garmin!' }}
</div>
<!-- EDITOR CONTENT -->
<div class="flex-1 bg-gray-900/30 border border-gray-800 rounded-xl overflow-hidden relative">
<WorkoutVisualEditor
v-if="editorTab === 'visual'"
v-model="workingWorkout"
v-model:steps="workingWorkout.workoutSegments[0].workoutSteps"
/>
<div v-else class="h-full">
<WorkoutJsonEditor v-model="workingWorkout" />
</div>
</div>
@@ -272,89 +532,28 @@ onMounted(() => {
</template>
<style scoped>
.plan-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
/* Toolbar & Header */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
.browser-mode {
display: flex;
flex-direction: column;
gap: 1.5rem;
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
.workout-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
gap: 1.5rem;
padding-bottom: 2rem;
}
.workout-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.w-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.w-header h4 {
margin: 0;
font-size: 1rem;
}
.badge {
background: rgba(255, 255, 255, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.75rem;
text-transform: uppercase;
}
.desc {
font-size: 0.9rem;
color: var(--text-muted);
flex: 1;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* Editor Styles */
.editor-mode {
display: flex;
flex-direction: column;
gap: 1rem;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.left-controls,
.right-controls {
display: flex;
align-items: center;
gap: 1rem;
}