Fix map route not rendering; fix health date filter timezone mismatch
This commit is contained in:
+13
-18
@@ -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"}
|
||||
@@ -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: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||
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: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
||||
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: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
||||
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 <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user