Files
streakfit-web/app/pages/session/[id].vue
T
neckfireandClaude Opus 4.8 742703a36f
Build & Deploy / build (push) Successful in 23s
StreakFit web: exercices planifiés en séance + filtre matériel + config matos profil
- session/[id]: affiche les exercices planifies de la routine (objectif series x reps) + pre-remplit la 1re serie
- exercises/index: select Materiel + bouton 'Mon matos' (filtre sur le materiel du profil)
- profile: multi-select du materiel possede + PATCH /api/me
- i18n fr/en/pt-BR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:27:45 +02:00

153 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { t } = useI18n()
const { api } = useApi()
const route = useRoute()
const toast = useToast()
const sessionId = Number(route.params.id)
interface SetLog { id: number, exercise_id: number, set_number: number, reps: number | null, weight: number | null }
interface Exercise { id: number, name: string, image_url: string | null, target?: string | null }
interface Plan { sets: number, target_reps: string | null, target_weight: number | null }
interface PlannedExercise { exercise_id: number, position: number, sets: number, target_reps: string | null, target_weight: number | null, rest_seconds: number | null, exercise: Exercise | null }
interface Session { id: number, title: string | null, status: string, routine_id: number | null, set_logs: (SetLog & { exercise?: Exercise })[], planned_exercises?: PlannedExercise[] }
type ActiveExercise = Exercise & { plan?: Plan }
// L'API peut envelopper la resource dans { data: {...} } → déballage tolérant.
const { data: session, refresh } = await useAsyncData(
`session-${sessionId}`,
() => api<Session | { data: Session }>(`/api/sessions/${sessionId}`).then((r: unknown) => (r as { data?: Session })?.data ?? r as Session)
)
// Exercices actifs = exercices planifiés par la routine (dans l'ordre) + ceux ayant des sets + ajoutés manuellement.
const added = ref<Exercise[]>([])
const exercises = computed<ActiveExercise[]>(() => {
const map = new Map<number, ActiveExercise>()
for (const p of session.value?.planned_exercises ?? []) {
if (p.exercise) map.set(p.exercise.id, { ...p.exercise, plan: { sets: p.sets, target_reps: p.target_reps, target_weight: p.target_weight } })
}
for (const sl of session.value?.set_logs ?? []) {
if (sl.exercise && !map.has(sl.exercise.id)) map.set(sl.exercise.id, sl.exercise)
}
for (const e of added.value) if (!map.has(e.id)) map.set(e.id, e)
return [...map.values()]
})
function setsFor(exId: number) {
return (session.value?.set_logs ?? []).filter(s => s.exercise_id === exId).sort((a, b) => a.set_number - b.set_number)
}
// Ajout d'exercice
const pickerOpen = ref(false)
const search = ref('')
const { data: results } = await useAsyncData(
'ex-picker',
() => search.value ? api<{ data: Exercise[] }>('/api/exercises', { query: { search: search.value, per_page: 12 } }) : Promise.resolve({ data: [] }),
{ watch: [search], lazy: true }
)
function addExercise(e: Exercise) {
if (!added.value.find(x => x.id === e.id)) added.value.push(e)
pickerOpen.value = false
search.value = ''
}
// Log d'une série
const draft = reactive<Record<number, { reps?: number, weight?: number }>>({})
// Pré-remplit la 1re série de chaque exercice planifié avec les cibles de la routine (reps/poids).
watch(() => session.value?.planned_exercises, (planned) => {
for (const p of planned ?? []) {
if (draft[p.exercise_id]) continue
const reps = p.target_reps ? Number.parseInt(String(p.target_reps), 10) : Number.NaN
draft[p.exercise_id] = { reps: Number.isFinite(reps) ? reps : undefined, weight: p.target_weight ?? undefined }
}
}, { immediate: true })
async function logSet(exId: number) {
const d = draft[exId] || {}
const setNumber = setsFor(exId).length + 1
try {
await api(`/api/sessions/${sessionId}/sets`, {
method: 'POST',
body: { exercise_id: exId, set_number: setNumber, reps: d.reps ?? null, weight: d.weight ?? null }
})
draft[exId] = { reps: d.reps, weight: d.weight } // pré-remplit la série suivante
await refresh()
} catch {
toast.add({ title: t('common.error'), color: 'error' })
}
}
// Fin de séance
const finishing = ref(false)
async function finish() {
finishing.value = true
try {
const res = await api<{ unlocked_achievements?: { label: string }[], streak?: { current: number } }>(`/api/sessions/${sessionId}/complete`, { method: 'POST' })
const unlocked = res.unlocked_achievements ?? []
if (unlocked.length) {
toast.add({ title: `🏅 ${unlocked.map(u => u.label).join(', ')}`, color: 'primary' })
}
toast.add({ title: `🔥 ${t('session.streakToast', { n: res.streak?.current ?? 0 })}`, color: 'primary' })
await navigateTo('/')
} catch {
toast.add({ title: t('common.error'), color: 'error' })
finishing.value = false
}
}
</script>
<template>
<div class="space-y-4 pb-4">
<div class="flex items-center justify-between">
<h1 class="text-xl font-black tracking-tight">{{ session?.title || t('session.inProgress') }}</h1>
<UBadge color="primary" variant="soft" class="animate-pulse"> {{ t('session.live') }}</UBadge>
</div>
<div v-for="ex in exercises" :key="ex.id" class="rounded-xl border border-zinc-200 dark:border-zinc-800 p-3 space-y-2">
<div class="flex items-center gap-3">
<img v-if="ex.image_url" :src="ex.image_url" :alt="ex.name" class="size-10 rounded-lg object-cover">
<div class="flex-1">
<p class="font-semibold capitalize">{{ ex.name }}</p>
<p v-if="ex.plan" class="text-xs text-zinc-500">
{{ t('session.target') }} : {{ ex.plan.sets }} × {{ ex.plan.target_reps }}<span v-if="ex.plan.target_weight"> · {{ ex.plan.target_weight }} {{ t('session.weight') }}</span>
</p>
</div>
</div>
<div v-for="s in setsFor(ex.id)" :key="s.id" class="flex items-center gap-3 text-sm pl-1">
<span class="size-6 rounded-full bg-flame-100 dark:bg-flame-950 text-flame-600 dark:text-flame-400 text-xs font-bold flex items-center justify-center">{{ s.set_number }}</span>
<span>{{ s.reps ?? '' }} {{ t('session.reps') }}</span>
<span v-if="s.weight" class="text-zinc-500">× {{ s.weight }} {{ t('session.weight') }}</span>
</div>
<div class="flex items-center gap-2 pt-1">
<UInput v-model.number="(draft[ex.id] ??= {}).reps" type="number" :placeholder="t('session.reps')" size="sm" class="w-20" />
<UInput v-model.number="(draft[ex.id] ??= {}).weight" type="number" :placeholder="t('session.weight')" size="sm" class="w-20" />
<UButton icon="i-lucide-plus" color="primary" size="sm" @click="logSet(ex.id)" />
</div>
</div>
<UButton block color="neutral" variant="subtle" icon="i-lucide-plus" :label="t('session.addExercise')" @click="pickerOpen = true" />
<UButton block size="xl" color="primary" class="font-bold" icon="i-lucide-check" :label="t('session.finish')" :loading="finishing" @click="finish" />
<!-- Picker d'exercice -->
<UModal v-model:open="pickerOpen" :title="t('session.addExercise')">
<template #body>
<UInput v-model="search" icon="i-lucide-search" :placeholder="t('exercises.search')" size="lg" class="w-full mb-3" autofocus />
<div class="max-h-80 overflow-y-auto space-y-1">
<button
v-for="e in results?.data ?? []"
:key="e.id"
class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-left"
@click="addExercise(e)"
>
<img v-if="e.image_url" :src="e.image_url" :alt="e.name" class="size-9 rounded object-cover">
<span class="text-sm font-medium capitalize">{{ e.name }}</span>
</button>
</div>
</template>
</UModal>
</div>
</template>