Add trend-range gating, vehicle filter, sync cancel, moving time, and UI fixes
Build and push images / validate (push) Successful in 9s
Build and push images / build-backend (push) Successful in 1m57s
Build and push images / build-worker (push) Successful in 50s
Build and push images / build-frontend (push) Successful in 24s

- Grey out trend ranges beyond available health history
- Reject implausibly fast (vehicle) activities on upload with feedback
- Add cancel button + cooperative cancellation for Garmin sync
- Show daily steps prominently on the dashboard
- Clear errors for malformed/empty upload ZIPs
- Snap-target dot when drawing a segment on the map
- Time-axis fallback for stationary/HIIT HR timelines; hide map when no GPS
- Parse and display moving time (timer) vs elapsed; backfill task
- Restyle SegmentsPanel like RouteLeaderboard; Laps/Routes/Segments on one row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 19:41:56 +01:00
parent 057eb9391a
commit ec87f68729
17 changed files with 569 additions and 132 deletions
@@ -71,6 +71,25 @@ const dot = (color) => L.divIcon({
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
})
// Pulsing target dot shown under the cursor while drawing a segment, so the user
// can see exactly which track point a click will snap to.
const SEG_TARGET_ICON = L.divIcon({
html: '<div style="width:14px;height:14px;background:#22c55e;border:2px solid #fff;border-radius:50%;box-shadow:0 0 8px rgba(34,197,94,0.9)"></div>',
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
})
// Nearest recorded GPS point to a lat/lng (squared planar distance is fine at
// these scales). Mirrors nearestDistance() in ActivityDetailPage.
function nearestPoint(points, lat, lng) {
let best = null, bestD = Infinity
for (const p of points) {
if (p.latitude == null || p.longitude == null) continue
const d = (p.latitude - lat) ** 2 + (p.longitude - lng) ** 2
if (d < bestD) { bestD = d; best = p }
}
return best
}
function drawRoute(map, { polyline, dataPoints, sportType, colorMode }, trackRef) {
if (trackRef.current) {
trackRef.current.remove()
@@ -140,6 +159,7 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
const mapRef = useRef(null)
const mapInstanceRef = useRef(null)
const markerRef = useRef(null)
const segTargetRef = useRef(null)
const trackRef = useRef(null)
const tileLayerRef = useRef(null)
const drawArgsRef = useRef({ polyline, dataPoints, sportType, colorMode })
@@ -165,12 +185,38 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo
if (clickRef.current) clickRef.current({ lat: e.latlng.lat, lng: e.latlng.lng })
})
// While in segment-create mode, show a target dot snapped to the nearest
// track point so the user can see what a click will select.
mapInstanceRef.current.on('mousemove', (e) => {
const pts = drawArgsRef.current.dataPoints
if (!clickRef.current || !pts?.length) return
const np = nearestPoint(pts, e.latlng.lat, e.latlng.lng)
if (!np) return
if (segTargetRef.current) {
segTargetRef.current.setLatLng([np.latitude, np.longitude])
} else {
segTargetRef.current = L.marker([np.latitude, np.longitude],
{ icon: SEG_TARGET_ICON, interactive: false }).addTo(mapInstanceRef.current)
}
})
mapInstanceRef.current.on('mouseout', () => {
if (segTargetRef.current) { segTargetRef.current.remove(); segTargetRef.current = null }
})
return () => {
mapInstanceRef.current?.remove()
mapInstanceRef.current = null
}
}, [])
// Clear the target dot when leaving segment-create mode.
useEffect(() => {
if (!onMapClick && segTargetRef.current) {
segTargetRef.current.remove()
segTargetRef.current = null
}
}, [onMapClick])
useEffect(() => {
if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.street
@@ -27,11 +27,23 @@ function downsample(points, maxPoints = 500) {
return points.filter((_, i) => i % step === 0)
}
function buildChartData(dataPoints, activeMetrics) {
// mm:ss label for the time-based X-axis (stationary/indoor activities).
function fmtSeconds(s) {
const m = Math.floor(s / 60)
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`
}
function buildChartData(dataPoints, activeMetrics, useTimeAxis) {
const base = useTimeAxis
? new Date(dataPoints.find(p => p.timestamp)?.timestamp || 0).getTime()
: 0
return dataPoints
.filter(p => p.timestamp)
.map(p => {
const row = { distance_m: p.distance_m ?? 0 }
const x = useTimeAxis
? (new Date(p.timestamp).getTime() - base) / 1000
: (p.distance_m ?? 0)
const row = { x }
for (const key of activeMetrics) {
row[key] = (p[key] != null && p[key] !== 0) ? p[key] : null
}
@@ -39,12 +51,12 @@ function buildChartData(dataPoints, activeMetrics) {
})
}
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover }) => {
const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover, useTimeAxis }) => {
if (!active || !payload?.length) return null
if (onHover) onHover(label)
return (
<div className="bg-gray-900 border border-gray-700 rounded-lg p-3 text-xs shadow-xl">
<p className="text-gray-400 mb-1">{(label / 1000).toFixed(2)} km</p>
<p className="text-gray-400 mb-1">{useTimeAxis ? fmtSeconds(label) : `${(label / 1000).toFixed(2)} km`}</p>
{payload.map(entry => {
const metric = metrics.find(m => m.key === entry.dataKey)
if (!metric || entry.value == null) return null
@@ -68,9 +80,17 @@ const CustomTooltip = ({ active, payload, label, metrics, sportType, onHover })
}
export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onHoverDistance, sportType }) {
// Stationary/indoor activities (HIIT, strength, trainer) record no distance, so
// plotting against distance collapses every sample onto x=0. Fall back to an
// elapsed-time X-axis when there's no distance spread.
const useTimeAxis = useMemo(
() => !dataPoints.some(p => p.distance_m != null && p.distance_m > 0),
[dataPoints]
)
const chartData = useMemo(() =>
downsample(buildChartData(dataPoints, activeMetrics)),
[dataPoints, activeMetrics]
downsample(buildChartData(dataPoints, activeMetrics, useTimeAxis)),
[dataPoints, activeMetrics, useTimeAxis]
)
const activeMetricConfigs = metrics.filter(m => activeMetrics.includes(m.key))
@@ -119,10 +139,10 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
<ComposedChart data={chartData} margin={{ top: 2, right: 8, bottom: 2, left: 8 }} syncId="activity-metrics">
<CartesianGrid strokeDasharray="3 3" stroke="#1f2937" vertical={false} />
<XAxis
dataKey="distance_m"
dataKey="x"
type="number"
domain={['dataMin', 'dataMax']}
tickFormatter={v => `${(v / 1000).toFixed(1)}`}
tickFormatter={v => useTimeAxis ? fmtSeconds(v) : `${(v / 1000).toFixed(1)}`}
tick={{ fontSize: 10, fill: '#6b7280' }}
axisLine={false}
tickLine={false}
@@ -146,7 +166,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
}}
/>
<Tooltip
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} />}
content={<CustomTooltip metrics={metrics} sportType={sportType} onHover={onHoverDistance} useTimeAxis={useTimeAxis} />}
isAnimationActive={false}
/>
{metric.key === 'cadence' && sportType === 'running' ? (
@@ -171,7 +191,7 @@ export default function MetricTimeline({ dataPoints, activeMetrics, metrics, onH
</div>
)
})}
<p className="text-xs text-gray-600 text-center">Distance (km)</p>
<p className="text-xs text-gray-600 text-center">{useTimeAxis ? 'Elapsed time (mm:ss)' : 'Distance (km)'}</p>
</div>
)
}
@@ -1,31 +1,75 @@
import { useState } from 'react'
import { useState, Fragment } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import api from '../../utils/api'
import { formatDuration, formatDistance } from '../../utils/format'
const MEDALS = { 1: '🥇', 2: '🥈', 3: '🥉' }
function Leaderboard({ segmentId }) {
// Compact +M:SS gap label (fastest effort shows nothing) — mirrors RouteLeaderboard.
function gapLabel(gapS) {
if (gapS == null || gapS <= 0.5) return null
return `+${formatDuration(gapS)}`
}
// Top-10 leaderboard for a single segment, styled to match RouteLeaderboard.
function Leaderboard({ segmentId, activityId }) {
const { data } = useQuery({
queryKey: ['segment', segmentId],
queryFn: () => api.get(`/segments/${segmentId}`).then(r => r.data),
})
if (!data) return <p className="text-xs text-gray-600 py-2">Loading</p>
if (!data.leaderboard?.length) return <p className="text-xs text-gray-600 py-2">No efforts yet still matching.</p>
const top = data.leaderboard.slice(0, 10)
const fastest = top[0].duration_s
return (
<div className="space-y-0.5 py-1">
{data.leaderboard.map((e, i) => (
<div key={e.activity_id} className="flex items-center gap-2 text-xs">
<span className="w-5 text-right">{MEDALS[e.rank] || i + 1}</span>
<span className="font-mono text-gray-200 w-14 text-right">{formatDuration(e.duration_s)}</span>
<a href={`/activities/${e.activity_id}`} className="text-gray-400 hover:text-blue-400 truncate flex-1">{e.activity_name}</a>
</div>
))}
</div>
<table className="w-full text-sm mt-1 mb-2">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">#</th>
<th className="text-left pb-2 font-medium">Activity</th>
<th className="text-right pb-2 font-medium">Time</th>
<th className="text-right pb-2 font-medium">Δ</th>
</tr>
</thead>
<tbody>
{top.map((e) => {
const isCurrent = e.activity_id === activityId
const gap = gapLabel(e.duration_s - fastest)
return (
<tr
key={e.activity_id}
className={`border-b border-gray-800/50 transition-colors ${
isCurrent ? 'bg-emerald-500/15 hover:bg-emerald-500/20' : 'hover:bg-gray-800/30'
}`}
>
<td className={`py-2 ${e.rank === 1 ? 'text-yellow-400' : 'text-gray-400'}`}>
{e.rank === 1 ? '🏆' : e.rank}
</td>
<td className="py-2">
<Link
to={`/activities/${e.activity_id}`}
className={`hover:underline ${isCurrent ? 'text-emerald-300 font-medium' : 'text-gray-300'}`}
>
{e.activity_name}
</Link>
</td>
<td className={`py-2 text-right font-mono ${isCurrent ? 'text-emerald-300 font-semibold' : 'text-gray-200'}`}>
{formatDuration(e.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{gap == null ? '--' : gap}
</td>
</tr>
)
})}
</tbody>
</table>
)
}
export default function SegmentsPanel({ segments }) {
export default function SegmentsPanel({ segments, activityId }) {
const qc = useQueryClient()
const [open, setOpen] = useState(null)
@@ -36,43 +80,66 @@ export default function SegmentsPanel({ segments }) {
}
return (
<div className="space-y-1">
<div className="flex items-center gap-3 pb-1.5 border-b border-gray-800 text-xs text-gray-600 uppercase tracking-wide">
<span className="flex-1">Segment</span>
<span className="w-14 text-right">This run</span>
<span className="w-14 text-right">Best</span>
<span className="w-10 text-right">Place</span>
</div>
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
return (
<div key={seg.segment_id} className="border-b border-gray-800/40">
<div className="flex items-center gap-3 py-1.5 text-sm">
<button onClick={() => setOpen(open === seg.segment_id ? null : seg.segment_id)}
className="flex-1 text-left text-gray-300 text-xs truncate hover:text-white">
{seg.name}
<span className="text-gray-600 ml-2">{formatDistance(seg.distance_m)}</span>
</button>
<span className={`font-mono text-xs w-14 text-right ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</span>
<span className="font-mono text-xs w-14 text-right text-gray-500">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</span>
<span className="w-10 text-right text-xs">
{isPodium
? <span title="New podium time on this activity">{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-red-400 font-mono">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</span>
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</div>
{open === seg.segment_id && <Leaderboard segmentId={seg.segment_id} />}
</div>
)
})}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 border-b border-gray-800">
<th className="text-left pb-2 font-medium">Segment</th>
<th className="text-right pb-2 font-medium">This run</th>
<th className="text-right pb-2 font-medium">Best</th>
<th className="text-right pb-2 font-medium">Place</th>
<th className="pb-2" />
</tr>
</thead>
<tbody>
{segments.map(seg => {
const isPodium = seg.rank && seg.rank <= 3
const delta = seg.best_s != null ? seg.duration_s - seg.best_s : null
const isOpen = open === seg.segment_id
return (
<Fragment key={seg.segment_id}>
<tr
className="border-b border-gray-800/50 transition-colors hover:bg-gray-800/30"
>
<td className="py-2">
<button
onClick={() => setOpen(isOpen ? null : seg.segment_id)}
className="text-left text-gray-300 hover:text-white"
>
<span className="text-gray-500 mr-1">{isOpen ? '▾' : '▸'}</span>
{seg.name}
<span className="text-gray-600 ml-2 text-xs">{formatDistance(seg.distance_m)}</span>
</button>
</td>
<td className={`py-2 text-right font-mono ${isPodium ? 'text-yellow-400 font-semibold' : 'text-gray-200'}`}>
{formatDuration(seg.duration_s)}
</td>
<td className="py-2 text-right font-mono text-gray-500">
{seg.best_s != null ? formatDuration(seg.best_s) : '--'}
</td>
<td className="py-2 text-right font-mono">
{isPodium
? <span title="Podium time on this activity" className="text-yellow-400">{MEDALS[seg.rank]}</span>
: delta != null
? <span className="text-gray-500">+{formatDuration(delta)}</span>
: <span className="text-gray-700">--</span>}
</td>
<td className="py-2 text-right">
<button onClick={() => remove(seg.segment_id)} className="text-gray-700 hover:text-red-400 text-xs" title="Delete segment"></button>
</td>
</tr>
{isOpen && (
<tr>
<td colSpan={5} className="bg-gray-950/40">
<Leaderboard segmentId={seg.segment_id} activityId={activityId} />
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
)
}
+10 -2
View File
@@ -1,10 +1,10 @@
import { create } from 'zustand'
import api from '../utils/api'
// A status string is "terminal" when the sync has finished (success, partial, or error).
// A status string is "terminal" when the sync has finished (success, partial, error, or cancelled).
const isTerminal = (s) =>
s.startsWith('OK') || s.startsWith('Partial') || s.startsWith('Auth error') ||
s.startsWith('Credentials') || s.startsWith('Connected')
s.startsWith('Credentials') || s.startsWith('Connected') || s.startsWith('Cancelled')
// Map a Garmin sync status string to an approximate completion percentage.
export function syncProgressPct(status) {
@@ -84,4 +84,12 @@ export const useSyncStore = create((set, get) => ({
get().stopPolling()
get().startPolling()
},
cancel: async () => {
set({ status: 'Cancelling…' })
try {
await api.post('/garmin-sync/cancel')
} catch { /* ignore — poll will reflect the true state */ }
get().poll()
},
}))
+19 -8
View File
@@ -143,7 +143,11 @@ export default function ActivityDetailPage() {
{/* Stats — all on one row */}
<div className="grid grid-cols-5 lg:grid-cols-10 gap-3">
<StatCard label="Distance" value={formatDistance(activity.distance_m)} />
<StatCard label="Time" value={formatDuration(activity.duration_s)} />
<StatCard label="Time" value={formatDuration(activity.moving_time_s ?? activity.duration_s)}
sub={activity.moving_time_s ? 'moving' : undefined} />
{activity.moving_time_s != null && Math.abs(activity.moving_time_s - (activity.duration_s ?? 0)) >= 1 && (
<StatCard label="Elapsed" value={formatDuration(activity.duration_s)} />
)}
<StatCard label="Pace" value={formatPace(activity.avg_speed_ms, activity.sport_type)} />
<StatCard label="Elevation ↑" value={formatElevation(activity.elevation_gain_m)} />
<StatCard label="Avg HR" value={formatHeartRate(activity.avg_heart_rate)} accent="red" />
@@ -162,7 +166,8 @@ export default function ActivityDetailPage() {
</div>
)}
{/* Map with controls */}
{/* Map with controls — only when the activity has a GPS track */}
{activity.polyline && activity.distance_m > 0 ? (
<div className="bg-gray-900 rounded-xl overflow-hidden border border-gray-800">
{/* Map toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800">
@@ -265,6 +270,11 @@ export default function ActivityDetailPage() {
</div>
)}
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 flex items-center justify-center text-gray-600 text-sm">
No GPS track for this activity
</div>
)}
{/* Metric timeline */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
@@ -300,25 +310,26 @@ export default function ActivityDetailPage() {
)}
</div>
{/* Laps + Route leaderboard + Segments side by side */}
{/* Laps · Routes · Segments — on one row, each shrinking to fit and
expanding to fill the width when fewer are present. */}
{((laps && laps.length > 0) || (actSegments && actSegments.length > 0) || (routeBoard && routeBoard.top?.length > 0)) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="flex flex-wrap gap-4 items-start">
{laps && laps.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Laps</h3>
<LapTable laps={laps} sportType={activity.sport_type} lapBests={lapBests} />
</div>
)}
{routeBoard && routeBoard.top?.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Route Top 10 Times</h3>
<RouteLeaderboard data={routeBoard} />
</div>
)}
{actSegments && actSegments.length > 0 && (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-4">
<div className="flex-1 min-w-[300px] bg-gray-900 rounded-xl border border-gray-800 p-4">
<h3 className="text-sm font-medium text-gray-300 mb-3">Segments</h3>
<SegmentsPanel segments={actSegments} />
<SegmentsPanel segments={actSegments} activityId={Number(id)} />
</div>
)}
</div>
+2 -1
View File
@@ -212,7 +212,8 @@ export default function DashboardPage() {
<Link to="/upload" className="text-sm text-blue-400 hover:text-blue-300 transition-colors">+ Import data</Link>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
<StatCard label="Steps today" value={health.steps != null ? health.steps.toLocaleString() : '--'} accent="green" sub="goal 10,000" />
<StatCard label="Running this year" value={ytdStats ? `${ytdStats.running_km.toFixed(0)} km` : '--'} accent="blue" />
<StatCard label="Cycling this year" value={ytdStats ? `${ytdStats.cycling_km.toFixed(0)} km` : '--'} accent="orange" />
<StatCard label="Resting HR" value={formatHeartRate(health.resting_hr)} accent="red" />
+31 -11
View File
@@ -4,7 +4,7 @@ import {
AreaChart, Area, BarChart, Bar, ReferenceLine, ReferenceArea,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell,
} from 'recharts'
import { format, subDays } from 'date-fns'
import { format, subDays, differenceInCalendarDays, parseISO } from 'date-fns'
import api from '../utils/api'
import { formatSleep, sportIcon } from '../utils/format'
import { BB_INFERRED_COLOR, BB_INFERRED_LABEL, bbLevelColor, inferBBType } from '../utils/bodyBattery'
@@ -885,6 +885,18 @@ export default function HealthPage() {
[allDays],
)
// Disable trend ranges that reach further back than the data goes. Keep every
// range up to and including the first one that already covers the full history
// enabled; ranges beyond that would only show the same (full) data. While the
// history is still loading we leave all ranges enabled.
const maxEnabledRangeIdx = useMemo(() => {
if (!allDaysSorted.length) return RANGES.length - 1
const oldest = allDaysSorted[allDaysSorted.length - 1].date
const span = differenceInCalendarDays(new Date(), parseISO(oldest))
const idx = RANGES.findIndex(r => r.days >= span)
return idx === -1 ? RANGES.length - 1 : idx
}, [allDaysSorted])
const selectedDay = useMemo(() => {
if (!selectedDateStr) return allDaysSorted[0] || null
return allDaysSorted.find(d => d.date === selectedDateStr) || null
@@ -970,16 +982,24 @@ export default function HealthPage() {
<p className="text-xs text-gray-600">Click any point to load that day above</p>
</div>
<div className="flex gap-1.5">
{RANGES.map(({ label, days }) => (
<button key={label} onClick={() => setRangeDays(days)}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
))}
{RANGES.map(({ label, days }, i) => {
const disabled = i > maxEnabledRangeIdx
return (
<button key={label}
onClick={() => !disabled && setRangeDays(days)}
disabled={disabled}
title={disabled ? 'Not enough history for this range' : undefined}
className={`text-xs px-3 py-1.5 rounded-full border transition-colors ${
disabled
? 'border-gray-800 text-gray-700 opacity-40 cursor-not-allowed'
: rangeDays === days
? 'bg-blue-600 border-blue-600 text-white'
: 'border-gray-700 text-gray-400 hover:text-white'
}`}>
{label}
</button>
)
})}
</div>
</div>
+14 -6
View File
@@ -140,7 +140,7 @@ export default function ProfilePage() {
const [gcForm, setGcForm] = useState({ email: '', password: '', sync_enabled: true, sync_activities: true, sync_wellness: true, sync_lookback_days: '30' })
const [gcSaved, setGcSaved] = useState(false)
const [gcError, setGcError] = useState('')
const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync } = useSyncStore()
const { inProgress: gcSyncing, status: syncStatus, trigger: triggerSync, cancel: cancelSync } = useSyncStore()
const gcFormLoaded = useRef(false)
useEffect(() => {
if (garminConfig?.connected && !gcFormLoaded.current) {
@@ -423,11 +423,19 @@ export default function ProfilePage() {
</span>
))}
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
<div className="flex items-center gap-2">
<div className="h-2 flex-1 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-700"
style={{ width: `${pct}%` }}
/>
</div>
<button
onClick={cancelSync}
disabled={status.startsWith('Cancel')}
className="text-red-400 hover:text-red-300 disabled:opacity-50 text-xs font-medium px-2 py-1 rounded-lg border border-red-500/40 hover:border-red-400 transition-colors whitespace-nowrap">
Cancel
</button>
</div>
<p className="text-xs text-blue-400">
{status || 'Starting sync…'}
+26 -7
View File
@@ -16,10 +16,17 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
if (data.status === 'SUCCESS' || data.status === 'FAILURE') {
clearInterval(intervalsRef.current[taskId])
delete intervalsRef.current[taskId]
// A successful task may still have skipped the file (e.g. a duplicate or
// an activity that looks like vehicle travel) — surface the reason.
const skipped = data.status === 'SUCCESS' && data.result?.status === 'skipped'
setTasks(ts => ts.map(t =>
t.task_id === taskId ? { ...t, status: data.status === 'SUCCESS' ? 'done' : 'failed' } : t
t.task_id === taskId
? { ...t,
status: data.status === 'FAILURE' ? 'failed' : skipped ? 'skipped' : 'done',
reason: skipped ? data.result?.reason : t.reason }
: t
))
if (data.status === 'SUCCESS') {
if (data.status === 'SUCCESS' && !skipped) {
queryClient.invalidateQueries({ queryKey: ['activities'] })
queryClient.invalidateQueries({ queryKey: ['health-summary'] })
queryClient.invalidateQueries({ queryKey: ['health-metrics'] })
@@ -50,6 +57,10 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
pollTask(data.task_id)
}
},
onError: (err, file) => {
const reason = err.response?.data?.detail || 'Upload failed'
setTasks(t => [...t, { file: file?.name || String(file), status: 'failed', reason }])
},
})
const onDrop = useCallback((accepted) => {
@@ -65,6 +76,7 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
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 === 'skipped') return <span className="ml-2 text-amber-400"> Skipped</span>
if (status === 'failed') return <span className="ml-2 text-red-400"> Failed</span>
return <span className="ml-2 text-green-400"> Queued</span>
}
@@ -107,12 +119,19 @@ function UploadZone({ title, description, accept, endpoint, icon }) {
{tasks.length > 0 && (
<div className="mt-4 space-y-2">
{tasks.map((task, i) => (
<div key={i} className="flex items-center justify-between text-xs bg-gray-800 rounded-lg px-3 py-2">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
<div key={i} className="bg-gray-800 rounded-lg px-3 py-2">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-300 truncate flex-1">{task.file}</span>
{task.activity_tasks !== undefined && (
<span className="text-gray-500 ml-2">{task.activity_tasks} activities queued</span>
)}
<StatusBadge status={task.status} />
</div>
{task.reason && (task.status === 'skipped' || task.status === 'failed') && (
<p className={`text-xs mt-1 ${task.status === 'skipped' ? 'text-amber-400/80' : 'text-red-400/80'}`}>
{task.reason}
</p>
)}
<StatusBadge status={task.status} />
</div>
))}
</div>