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
+25 -3
View File
@@ -31,6 +31,21 @@ The app is served on port 80 by nginx, which proxies `/api/*` to the backend (po
There are no automated tests. Verification is done by running the app and observing behaviour.
## Debugging running containers
The production stack runs in `~/milevault_docker` with fixed container names. Use these to investigate issues — never patch the running files:
```bash
# Tail logs from a specific container
docker logs -f milevault_backend
docker logs -f milevault_worker
docker logs -f milevault_db
# Run a one-off query or command inside a container
docker exec milevault_backend python -c "from app.core.config import settings; print(settings.base_url)"
docker exec -it milevault_db psql -U milevault -d milevault
```
## Building and deploying
`docker-compose.yml` — build from source (dev/CI).
@@ -40,6 +55,10 @@ The Gitea Actions workflow (`.gitea/workflows/build.yml`) auto-builds and pushes
`./deploy.sh "<commit message>"` is the normal dev loop here: it commits everything, pushes to `main` (triggering the image build), and stops the running stack in `../milevault_docker`. After the build finishes, run `docker compose pull && docker compose up -d` there. This matches the repo rule: fix files in `~/milevault`, push to git — never patch the running containers in `~/milevault_docker`.
**CI validation**: The build workflow runs a `validate` job before building images. It will fail if `@polyline-codec` appears in `frontend/package.json` or if `npm ci` is used in `frontend/Dockerfile` (no lockfile exists — always use `npm install`). Fix these before pushing.
**`VITE_MAPBOX_TOKEN`** is baked empty by the CI build (`build-args: VITE_MAPBOX_TOKEN=`), so satellite tiles are disabled in all pre-built images. To enable them, rebuild locally with the token set in `.env`.
```bash
# Rebuild and restart from source:
docker compose build --no-cache
@@ -94,8 +113,8 @@ docker compose -f docker-compose.deploy.yml up -d
- TanStack Query (`@tanstack/react-query`) handles all server-state fetching and caching; Zustand is used only for auth state
- `utils/format.js` — shared formatting helpers: `formatDuration`, `formatPace`, `formatDistance`, `formatCadence`, `hrZoneColor`, `sportIcon`, `sportColor`, etc.
- `pages/` — one file per route: `Dashboard`, `Activities`, `ActivityDetail`, `Routes`, `Records`, `Health`, `Upload`, `Profile`, `Users`, `Login`
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts)
- `components/ui/RouteMiniMap` small Leaflet map used in route/segment cards
- `components/activity/``ActivityMap` (Leaflet), `MetricTimeline` (Recharts), `HRZoneBar`, `LapTable`, `SegmentsPanel` (per-activity segment efforts), `RouteLeaderboard` (top-10 by pace for a named route)
- `components/ui/``Layout` (nav shell), `StatCard`, `RouteMiniMap` (small Leaflet map used in route/segment cards)
The Vite dev server proxies `/api` to `http://backend:8000` (for use inside the Docker Compose network). The production build bakes `VITE_API_URL` at build time.
@@ -115,8 +134,11 @@ Required in `.env` (or passed to Docker Compose):
| `HTTP_PORT` | Host port for nginx (default: `80`) |
| `FILE_STORE_PATH` | Where uploaded FIT files are stored (default: `/data/files`) |
| `BASE_URL` | Used for PocketID OAuth callback redirect URI |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer |
| `ENVIRONMENT` | `production` (default) or `development`; controls CORS (dev allows all origins) |
| `VITE_MAPBOX_TOKEN` | Optional — enables satellite tile layer (baked at build time) |
| `GARMIN_SYNC_INTERVAL_MINUTES` | How often the beat scheduler polls Garmin Connect (default: `30`) |
| `POCKETID_ISSUER` / `POCKETID_CLIENT_ID` / `POCKETID_CLIENT_SECRET` | Optional OIDC |
| `POCKETID_ALLOWED_GROUP` | Optional — restrict passkey login to a specific PocketID group |
## milevault_export/
+1
View File
@@ -35,6 +35,7 @@ class ActivitySummary(BaseModel):
class ActivityDetail(ActivitySummary):
end_time: Optional[datetime]
moving_time_s: Optional[float]
elevation_loss_m: Optional[float]
max_heart_rate: Optional[float]
avg_power: Optional[float]
+56
View File
@@ -13,6 +13,19 @@ from app.models.user import User, GarminConnectConfig
router = APIRouter()
def _redis_client():
import redis as redis_lib
return redis_lib.Redis.from_url(settings.redis_url)
def sync_task_key(user_id: int) -> str:
return f"garmin_sync_task:{user_id}"
def sync_cancel_key(user_id: int) -> str:
return f"garmin_sync_cancel:{user_id}"
class GarminConfigIn(BaseModel):
email: str
password: Optional[str] = None # plaintext; encrypted before storage. None = keep existing.
@@ -183,4 +196,47 @@ async def trigger_sync(
from app.workers.tasks import sync_garmin_connect_user
task = sync_garmin_connect_user.delay(current_user.id)
# Track the active task id and clear any stale cancel flag so the new sync runs.
try:
r = _redis_client()
r.delete(sync_cancel_key(current_user.id))
r.set(sync_task_key(current_user.id), task.id, ex=3600)
except Exception:
pass
return {"task_id": task.id, "status": "queued"}
@router.post("/cancel")
async def cancel_sync(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Request cancellation of the user's in-progress Garmin sync. The running task
checks this flag between items and aborts cooperatively."""
from app.workers.tasks import celery_app
try:
r = _redis_client()
r.set(sync_cancel_key(current_user.id), "1", ex=3600)
task_id = r.get(sync_task_key(current_user.id))
if task_id:
tid = task_id.decode() if isinstance(task_id, (bytes, bytearray)) else task_id
# terminate=False: don't kill a running worker mid-transaction; the
# cooperative flag handles an already-running task, and this revoke
# prevents a still-queued one from starting.
celery_app.control.revoke(tid, terminate=False)
except Exception:
pass
# Reflect intent immediately so the UI updates before the worker writes "Cancelled".
result = await db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == current_user.id)
)
cfg = result.scalar_one_or_none()
if cfg:
cfg.last_sync_status = "Cancelling…"
await db.commit()
return {"status": "cancelling"}
+27 -4
View File
@@ -115,14 +115,21 @@ async def upload_garmin_export(
extract_dir = dest_dir / f"garmin_{dest.stem}"
task_ids = []
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
try:
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
except zipfile.BadZipFile:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
has_health = False
for path in extracted:
suffix = path.suffix.lower()
if suffix == ".fit":
task = process_activity_file.delay(str(path), current_user.id, "fit")
task_ids.append(task.id)
elif suffix == ".json":
has_health = True # Garmin wellness data is exported as JSON files
elif suffix == ".zip":
# Garmin exports nest activity FIT files inside sub-zips
# (e.g. DI-Connect-Uploaded-Files/UploadedFiles_*_Part*.zip)
@@ -137,6 +144,12 @@ async def upload_garmin_export(
task = process_activity_file.delay(str(np), current_user.id, "fit")
task_ids.append(task.id)
if not task_ids and not has_health:
raise HTTPException(
status_code=400,
detail="No fitness data found in this archive — make sure you uploaded your full Garmin Connect export ZIP",
)
# Queue health/wellness data extraction
health_task = process_garmin_health_zip.delay(str(dest), current_user.id)
@@ -163,8 +176,12 @@ async def upload_strava_export(
extract_dir = dest_dir / f"strava_{dest.stem}"
task_ids = []
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
try:
with zipfile.ZipFile(dest) as zf:
extracted = _safe_extract(zf, extract_dir)
except zipfile.BadZipFile:
dest.unlink(missing_ok=True)
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive")
for path in extracted:
suffix = path.suffix.lower()
@@ -172,6 +189,12 @@ async def upload_strava_export(
task = process_activity_file.delay(str(path), current_user.id, suffix[1:])
task_ids.append(task.id)
if not task_ids:
raise HTTPException(
status_code=400,
detail="No activity files (.fit or .gpx) found in this Strava archive",
)
return {
"status": "queued",
"activity_tasks": len(task_ids),
+9
View File
@@ -50,6 +50,15 @@ async def init_db():
except Exception as e:
print(f"Column migration skipped: {e}")
# activities.moving_time_s column added after initial creation (timer time)
try:
async with engine.begin() as conn:
await conn.execute(text(
"ALTER TABLE activities ADD COLUMN IF NOT EXISTS moving_time_s FLOAT"
))
except Exception as e:
print(f"activities.moving_time_s column migration skipped: {e}")
# health_metrics columns added after initial creation
try:
async with engine.begin() as conn:
+2 -1
View File
@@ -92,7 +92,8 @@ class Activity(Base):
start_time = Column(DateTime(timezone=True), nullable=False, index=True)
end_time = Column(DateTime(timezone=True), nullable=True)
distance_m = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True)
duration_s = Column(Float, nullable=True) # total elapsed time (wall clock, incl. pauses)
moving_time_s = Column(Float, nullable=True) # timer time — excludes paused periods
elevation_gain_m = Column(Float, nullable=True)
elevation_loss_m = Column(Float, nullable=True)
avg_heart_rate = Column(Float, nullable=True)
+47 -10
View File
@@ -44,6 +44,33 @@ def _sanitize_speed(val, dist_m=None, dur_s=None) -> Optional[float]:
return fv
# Conservative average-speed ceilings (m/s) above which an activity was almost
# certainly recorded in a vehicle rather than under human power. Sports not
# listed fall back to the generous default.
_VEHICLE_SPEED_CEILINGS = {
"running": 8.0, # ~28.8 km/h — well above elite sprint pace sustained
"walking": 8.0,
"hiking": 8.0,
"cycling": 22.0, # ~79 km/h — beyond sustained amateur cycling
}
_VEHICLE_SPEED_DEFAULT = 25.0 # ~90 km/h
def _vehicle_reason(sport_type, avg_speed_ms, dist_m=None, dur_s=None) -> Optional[str]:
"""Return a human-readable reason if the average speed is implausibly fast for
the sport (i.e. the 'activity' looks like car/vehicle travel), else None."""
speed = _safe_float(avg_speed_ms)
if speed is None and dist_m and dur_s and float(dur_s) > 0:
speed = float(dist_m) / float(dur_s)
if speed is None or speed <= 0:
return None
ceiling = _VEHICLE_SPEED_CEILINGS.get(sport_type, _VEHICLE_SPEED_DEFAULT)
if speed > ceiling:
return (f"Looks like vehicle travel — average speed {speed * 3.6:.0f} km/h "
f"exceeds the plausible limit for {sport_type}")
return None
def _bounding_box(coords):
if not coords:
return None
@@ -210,12 +237,22 @@ def parse_fit_file(filepath: str) -> dict:
if start_time:
name += " " + start_time.strftime("%Y-%m-%d")
total_dist = _safe_float(get(session_data, "totalDistance", "total_distance"))
elapsed_s = _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time"))
# Timer time = time the device was actively recording (excludes auto/manual pauses).
moving_s = _safe_float(get(session_data, "totalTimerTime", "total_timer_time"))
avg_speed = _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=total_dist, dur_s=elapsed_s,
)
return {
"name": name,
"sport_type": sport_type,
"start_time": start_time.isoformat() if start_time else None,
"distance_m": _safe_float(get(session_data, "totalDistance", "total_distance")),
"duration_s": _safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
"distance_m": total_dist,
"duration_s": elapsed_s,
"moving_time_s": moving_s,
"elevation_gain_m": _safe_float(get(session_data, "totalAscent", "total_ascent")),
"elevation_loss_m": _safe_float(get(session_data, "totalDescent", "total_descent")),
"avg_heart_rate": _safe_float(get(session_data, "avgHeartRate", "avg_heart_rate")),
@@ -223,11 +260,7 @@ def parse_fit_file(filepath: str) -> dict:
"avg_cadence": _safe_float(get(session_data, "avgCadence", "avg_cadence")),
"avg_power": _safe_float(get(session_data, "avgPower", "avg_power")),
"normalized_power": _safe_float(get(session_data, "normalizedPower", "normalized_power")),
"avg_speed_ms": _sanitize_speed(
get(session_data, "avgSpeed", "avg_speed", "enhancedAvgSpeed", "enhanced_avg_speed"),
dist_m=_safe_float(get(session_data, "totalDistance", "total_distance")),
dur_s=_safe_float(get(session_data, "totalElapsedTime", "total_elapsed_time")),
),
"avg_speed_ms": avg_speed,
"max_speed_ms": _safe_float(get(session_data, "maxSpeed", "max_speed",
"enhancedMaxSpeed", "enhanced_max_speed")),
"avg_temperature_c": _safe_float(get(session_data, "avgTemperature", "avg_temperature")),
@@ -239,6 +272,7 @@ def parse_fit_file(filepath: str) -> dict:
"polyline": encoded_polyline,
"bounding_box": bounding_box,
"source_type": "fit",
"rejected_reason": _vehicle_reason(sport_type, avg_speed, total_dist, moving_s or elapsed_s),
"data_points": normalized_points,
"laps": normalized_laps,
}
@@ -310,20 +344,23 @@ def parse_gpx_file(filepath: str) -> dict:
end_dt = datetime.fromisoformat(data_points[-1]["timestamp"]) if data_points else None
duration = (end_dt - start_dt).total_seconds() if (start_dt and end_dt) else None
sport = track.type.lower() if track.type else "running"
gpx_avg_speed = (total_dist / duration) if (total_dist and duration) else None
return {
"name": track.name or gpx.name or f"Activity {start_dt.date() if start_dt else ''}",
"sport_type": sport, "start_time": start_time_str,
"distance_m": total_dist, "duration_s": duration,
"distance_m": total_dist, "duration_s": duration, "moving_time_s": None,
"elevation_gain_m": uphill, "elevation_loss_m": downhill,
"avg_heart_rate": (sum(hrs) / len(hrs)) if hrs else None,
"max_heart_rate": max(hrs) if hrs else None,
"avg_cadence": None, "avg_power": None, "normalized_power": None,
"avg_speed_ms": (total_dist / duration) if (total_dist and duration) else None,
"avg_speed_ms": gpx_avg_speed,
"max_speed_ms": None, "avg_temperature_c": None, "calories": None,
"training_stress_score": None, "vo2max_estimate": None,
"polyline": encoded_polyline, "bounding_box": bounding_box,
"source_type": "gpx", "data_points": data_points, "laps": [],
"source_type": "gpx",
"rejected_reason": _vehicle_reason(sport, gpx_avg_speed, total_dist, duration),
"data_points": data_points, "laps": [],
}
+108 -20
View File
@@ -80,6 +80,11 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
if not parsed.get("start_time"):
return {"status": "skipped", "reason": "no start_time", "file": file_path}
# Reject activities whose average speed is implausible for the sport (e.g. a
# car journey accidentally recorded). Surfaced to the upload UI as the reason.
if parsed.get("rejected_reason"):
return {"status": "skipped", "reason": parsed["rejected_reason"], "file": file_path}
with SyncSessionLocal() as db:
start_time = datetime.fromisoformat(parsed["start_time"])
@@ -128,6 +133,7 @@ def process_activity_file(self, file_path: str, user_id: int, source_type: str,
start_time=start_time,
distance_m=parsed.get("distance_m"),
duration_s=parsed.get("duration_s"),
moving_time_s=parsed.get("moving_time_s"),
elevation_gain_m=parsed.get("elevation_gain_m"),
elevation_loss_m=parsed.get("elevation_loss_m"),
avg_heart_rate=parsed.get("avg_heart_rate"),
@@ -651,6 +657,10 @@ def process_garmin_health_zip(zip_path: str, user_id: int):
db.commit()
class SyncCancelled(Exception):
"""Raised inside a Garmin sync when the user has requested cancellation."""
@celery_app.task(name="sync_garmin_connect_user")
def sync_garmin_connect_user(user_id: int):
"""Sync Garmin Connect data (activities + wellness) for one user."""
@@ -661,6 +671,20 @@ def sync_garmin_connect_user(user_id: int):
from sqlalchemy import select
from datetime import datetime, timezone
# Cooperative-cancellation flag (set by POST /garmin-sync/cancel).
cancel_key = f"garmin_sync_cancel:{user_id}"
try:
import redis as redis_lib
_redis = redis_lib.Redis.from_url(settings.redis_url)
except Exception:
_redis = None
def _cancelled():
try:
return bool(_redis and _redis.exists(cancel_key))
except Exception:
return False
with SyncSessionLocal() as db:
cfg = db.execute(
select(GarminConnectConfig).where(GarminConnectConfig.user_id == user_id)
@@ -698,31 +722,51 @@ def sync_garmin_connect_user(user_id: int):
errors = []
def _set_status(text):
# Checked between items: abort the sync if cancellation was requested.
if _cancelled():
raise SyncCancelled()
cfg.last_sync_status = text
db.commit()
if sync_acts:
_set_status("Syncing activities...")
try:
activities_queued = sync_activities(
garmin, user_id, last_sync_at, db, settings.file_store_path,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"activities: {exc}")
try:
if sync_acts:
_set_status("Syncing activities...")
try:
activities_queued = sync_activities(
garmin, user_id, last_sync_at, db, settings.file_store_path,
lookback_days=lookback,
status_callback=_set_status,
)
except SyncCancelled:
raise
except Exception as exc:
errors.append(f"activities: {exc}")
if sync_well:
_set_status("Syncing wellness...")
if sync_well:
_set_status("Syncing wellness...")
try:
wellness_days = sync_wellness(
garmin, user_id, last_sync_at, db,
lookback_days=lookback,
status_callback=_set_status,
)
except SyncCancelled:
raise
except Exception as exc:
errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed
except SyncCancelled:
db.rollback()
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = "Cancelled"
db.commit()
try:
wellness_days = sync_wellness(
garmin, user_id, last_sync_at, db,
lookback_days=lookback,
status_callback=_set_status,
)
except Exception as exc:
errors.append(f"wellness: {exc}")
db.rollback() # recover session so the final status commit can succeed
if _redis:
_redis.delete(cancel_key)
except Exception:
pass
return {"status": "cancelled",
"activities_queued": activities_queued, "wellness_days": wellness_days}
cfg.last_sync_at = datetime.now(timezone.utc)
cfg.last_sync_status = (
@@ -777,3 +821,47 @@ def recalculate_hr_zones_for_user(user_id: int, new_max_hr: float):
activity.hr_zones = new_zones
db.commit()
@celery_app.task(name="backfill_moving_time")
def backfill_moving_time(user_id: int = None):
"""Populate moving_time_s for existing FIT-sourced activities by re-reading the
timer time from their stored source files. Idempotent — skips activities that
already have a value or whose source file is missing/unreadable."""
import os
from app.services.fit_parser import parse_fit_file
from app.core.database import SyncSessionLocal
from app.models.user import Activity
from sqlalchemy import select
updated, skipped = 0, 0
with SyncSessionLocal() as db:
q = select(Activity).where(
Activity.moving_time_s.is_(None),
Activity.source_type == "fit",
Activity.source_file.isnot(None),
)
if user_id is not None:
q = q.where(Activity.user_id == user_id)
activities = db.execute(q).scalars().all()
for activity in activities:
path = activity.source_file
if not path or not os.path.exists(path):
skipped += 1
continue
try:
parsed = parse_fit_file(path)
except Exception:
skipped += 1
continue
mt = parsed.get("moving_time_s")
if mt:
activity.moving_time_s = mt
updated += 1
else:
skipped += 1
db.commit()
return {"status": "ok", "updated": updated, "skipped": skipped}
@@ -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>