Fix upload auto-refresh, health data refresh, and HR zone recalculation
Build and push images / validate (push) Successful in 2s
Build and push images / build-backend (push) Successful in 6s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 10s

- UploadPage now polls task status every 2s and invalidates activity,
  health-summary, and health-metrics queries on completion so new
  activities and health data appear without a hard refresh
- Garmin and Strava export endpoints now return a task_id for polling
- Updating max HR in Profile triggers a background Celery task to
  recalculate hr_zones for all existing activities; profile page shows
  a confirmation note when this is queued
- Add CLAUDE.md with repo architecture and dev commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:13:44 +01:00
parent b5fd17a597
commit 95f704cb54
6 changed files with 205 additions and 11 deletions
+23 -5
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
import { useAuthStore } from '../hooks/useAuth'
@@ -59,6 +59,8 @@ export default function ProfilePage() {
// HR / measurements form
const [hrForm, setHrForm] = useState({ max_heart_rate: '', resting_heart_rate: '', birth_year: '', height_cm: '' })
const [hrSaved, setHrSaved] = useState(false)
const [hrZoneRecalc, setHrZoneRecalc] = useState(false)
const maxHrChangedRef = useRef(false)
useEffect(() => {
if (profile) setHrForm({
max_heart_rate: profile.max_heart_rate || '',
@@ -70,7 +72,16 @@ export default function ProfilePage() {
const updateProfile = useMutation({
mutationFn: data => api.patch('/profile/', data).then(r => r.data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['profile'] }); setHrSaved(true); setTimeout(() => setHrSaved(false), 3000) },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['profile'] })
setHrSaved(true)
setTimeout(() => setHrSaved(false), 3000)
if (maxHrChangedRef.current) {
setHrZoneRecalc(true)
setTimeout(() => setHrZoneRecalc(false), 6000)
maxHrChangedRef.current = false
}
},
})
// Weight log
@@ -149,12 +160,19 @@ export default function ProfilePage() {
</div>
<SaveButton
onClick={() => updateProfile.mutate(Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
))}
onClick={() => {
const data = Object.fromEntries(
Object.entries(hrForm).filter(([,v]) => v !== '').map(([k,v]) => [k, parseFloat(v)])
)
maxHrChangedRef.current = data.max_heart_rate !== undefined && data.max_heart_rate !== profile?.max_heart_rate
updateProfile.mutate(data)
}}
loading={updateProfile.isPending}
saved={hrSaved}
/>
{hrZoneRecalc && (
<p className="text-xs text-blue-400 mt-1">HR zones are being recalculated for your existing activities.</p>
)}
</Section>
{/* Weight log */}
+43 -4
View File
@@ -1,10 +1,38 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { useDropzone } from 'react-dropzone'
import { useMutation } from '@tanstack/react-query'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../utils/api'
function UploadZone({ title, description, accept, endpoint, icon }) {
const [tasks, setTasks] = useState([])
const queryClient = useQueryClient()
const intervalsRef = useRef({})
const pollTask = useCallback((taskId) => {
if (intervalsRef.current[taskId]) return
const intervalId = setInterval(async () => {
try {
const { data } = await api.get(`/upload/task/${taskId}`)
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId])
delete intervalsRef.current[taskId]
setTasks(ts => ts.map(t =>
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
))
if (data.status === 'SUCCESS') {
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
}
}
} catch { /* ignore transient poll errors */ }
}, 2000)
intervalsRef.current[taskId] = intervalId
}, [queryClient])
useEffect(() => {
return () => { Object.values(intervalsRef.current).forEach(clearInterval) }
}, [])
const upload = useMutation({
mutationFn: async (file) => {
@@ -16,7 +44,11 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
return { file: file.name, ...data }
},
onSuccess: (data) => {
setTasks(t => [...t, { ...data, status: 'queued' }])
const task = { ...data, status: data.task_id ? 'processing' : 'queued' }
setTasks(t => [...t, task])
if (data.task_id) {
pollTask(data.task_id)
}
},
})
@@ -30,6 +62,13 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
multiple: true,
})
function StatusBadge({ status }) {
if (status === 'processing') return <span className="ml-2 text-blue-400 animate-pulse"> Processing</span>
if (status === 'done') return <span className="ml-2 text-green-400"> Done</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span>
}
return (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<div className="flex items-center gap-3 mb-3">
@@ -73,7 +112,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<span className="ml-2 text-green-400"> Queued</span>
<StatusBadge status={task.status} />
</div>
))}
</div>