diff --git a/backend/app/api/health.py b/backend/app/api/health.py index 0bf0f35..120480c 100644 --- a/backend/app/api/health.py +++ b/backend/app/api/health.py @@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, func from pydantic import BaseModel from typing import Optional, List -from datetime import datetime, date +from datetime import datetime, timedelta, timezone from app.core.database import get_db from app.core.security import get_current_user @@ -58,12 +58,15 @@ async def list_health_metrics( current_user: User = Depends(get_current_user), ): q = select(HealthMetric).where(HealthMetric.user_id == current_user.id) - if from_date: - q = q.where(HealthMetric.date >= from_date) - if to_date: - q = q.where(HealthMetric.date <= to_date) - q = q.order_by(desc(HealthMetric.date)).limit(limit) + if from_date: + from_date_naive = from_date.replace(tzinfo=None) if from_date.tzinfo else from_date + q = q.where(func.date(HealthMetric.date) >= from_date_naive.date()) + if to_date: + to_date_naive = to_date.replace(tzinfo=None) if to_date.tzinfo else to_date + q = q.where(func.date(HealthMetric.date) <= to_date_naive.date()) + + q = q.order_by(desc(HealthMetric.date)).limit(limit) result = await db.execute(q) return result.scalars().all() @@ -73,8 +76,6 @@ async def health_summary( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Latest values + 30-day averages for dashboard widgets.""" - # Latest record latest_result = await db.execute( select(HealthMetric) .where(HealthMetric.user_id == current_user.id) @@ -83,9 +84,7 @@ async def health_summary( ) latest = latest_result.scalar_one_or_none() - # 30-day averages - from datetime import timedelta, timezone - cutoff = datetime.now(timezone.utc) - timedelta(days=30) + cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date() avg_result = await db.execute( select( func.avg(HealthMetric.resting_hr).label("avg_resting_hr"), @@ -97,7 +96,7 @@ async def health_summary( func.avg(HealthMetric.weight_kg).label("avg_weight"), ).where( HealthMetric.user_id == current_user.id, - HealthMetric.date >= cutoff, + func.date(HealthMetric.date) >= cutoff, ) ) avgs = avg_result.one() @@ -122,17 +121,13 @@ async def add_manual_metric( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - """Manually add or update a health metric for a given date.""" - from sqlalchemy.dialects.postgresql import insert as pg_insert - + from fastapi import HTTPException date_str = body.get("date") if not date_str: - from fastapi import HTTPException raise HTTPException(status_code=400, detail="date required") metric_date = datetime.fromisoformat(date_str) - # Check for existing existing = await db.execute( select(HealthMetric).where( HealthMetric.user_id == current_user.id, @@ -153,4 +148,4 @@ async def add_manual_metric( db.add(metric) await db.commit() - return {"status": "ok"} + return {"status": "ok"} \ No newline at end of file diff --git a/frontend/src/components/activity/ActivityMap.jsx b/frontend/src/components/activity/ActivityMap.jsx index 0fe01c5..56a8d7f 100644 --- a/frontend/src/components/activity/ActivityMap.jsx +++ b/frontend/src/components/activity/ActivityMap.jsx @@ -39,55 +39,82 @@ function decodePolyline(encoded) { return coords } +function drawRoute(map, polyline, sportType, trackRef) { + if (trackRef.current) { + trackRef.current.remove() + trackRef.current = null + } + if (!polyline) return + + const coords = decodePolyline(polyline) + if (!coords.length) return + + trackRef.current = L.polyline(coords, { + color: sportColor(sportType), + weight: 3, + opacity: 0.9, + }).addTo(map) + + map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) + + const dot = (color) => L.divIcon({ + html: `
`, + iconSize: [12, 12], iconAnchor: [6, 6], className: '', + }) + L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map) + L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map) +} + export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) { const mapRef = useRef(null) const mapInstanceRef = useRef(null) const markerRef = useRef(null) const trackRef = useRef(null) const tileLayerRef = useRef(null) + const polylineRef = useRef(polyline) + const sportTypeRef = useRef(sportType) + + useEffect(() => { polylineRef.current = polyline }, [polyline]) + useEffect(() => { sportTypeRef.current = sportType }, [sportType]) useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return - mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true }) - const tile = TILE_LAYERS['dark'] - tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) - .addTo(mapInstanceRef.current) - return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null } + + mapInstanceRef.current = L.map(mapRef.current, { + zoomControl: true, + attributionControl: true, + }) + + const tile = TILE_LAYERS.dark + tileLayerRef.current = L.tileLayer(tile.url, { + attribution: tile.attribution, + maxZoom: 19, + }).addTo(mapInstanceRef.current) + + return () => { + mapInstanceRef.current?.remove() + mapInstanceRef.current = null + } }, []) - // Switch tile layer when mapType changes useEffect(() => { if (!mapInstanceRef.current) return const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark - if (tileLayerRef.current) { - tileLayerRef.current.remove() - } - tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) - .addTo(mapInstanceRef.current) + if (tileLayerRef.current) tileLayerRef.current.remove() + tileLayerRef.current = L.tileLayer(tile.url, { + attribution: tile.attribution, + maxZoom: 19, + }).addTo(mapInstanceRef.current) + drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef) }, [mapType]) - // Draw route useEffect(() => { - if (!mapInstanceRef.current || !polyline) return - if (trackRef.current) trackRef.current.remove() - const coords = decodePolyline(polyline) - if (!coords.length) return - trackRef.current = L.polyline(coords, { color: sportColor(sportType), weight: 3, opacity: 0.9 }) - .addTo(mapInstanceRef.current) - mapInstanceRef.current.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] }) - if (coords.length > 0) { - const dot = (color) => L.divIcon({ - html: `
`, - iconSize: [12, 12], iconAnchor: [6, 6], className: '', - }) - L.marker(coords[0], { icon: dot('#22c55e') }).addTo(mapInstanceRef.current) - L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(mapInstanceRef.current) - } + if (!mapInstanceRef.current) return + drawRoute(mapInstanceRef.current, polyline, sportType, trackRef) }, [polyline, sportType]) - // Position marker on timeline hover useEffect(() => { - if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return + if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return const point = dataPoints.find(p => p.distance_m >= hoveredDistance) if (!point?.latitude || !point?.longitude) return if (markerRef.current) { @@ -97,9 +124,10 @@ export default function ActivityMap({ polyline, dataPoints, hoveredDistance, spo html: '
', iconSize: [14, 14], iconAnchor: [7, 7], className: '', }) - markerRef.current = L.marker([point.latitude, point.longitude], { icon }).addTo(mapInstanceRef.current) + markerRef.current = L.marker([point.latitude, point.longitude], { icon }) + .addTo(mapInstanceRef.current) } }, [hoveredDistance, dataPoints]) return
-} +} \ No newline at end of file diff --git a/frontend/src/pages/HealthPage.jsx b/frontend/src/pages/HealthPage.jsx index 6d91413..90dd7df 100644 --- a/frontend/src/pages/HealthPage.jsx +++ b/frontend/src/pages/HealthPage.jsx @@ -79,7 +79,7 @@ function SleepChart({ data }) { export default function HealthPage() { const [rangeDays, setRangeDays] = useState(7) // default 1 week - const fromDate = useMemo(() => subDays(new Date(), rangeDays).toISOString(), [rangeDays]) + const fromDate = useMemo(() => format(subDays(new Date(), rangeDays), 'yyyy-MM-dd'), [rangeDays]) const { data: summary } = useQuery({ queryKey: ['health-summary'],