Fix map route not rendering; fix health date filter timezone mismatch
Build and push images / validate (push) Successful in 3s
Build and push images / build-backend (push) Successful in 5s
Build and push images / build-worker (push) Successful in 5s
Build and push images / build-frontend (push) Successful in 9s

This commit is contained in:
2026-06-06 18:52:29 +01:00
parent 24f8417982
commit 93b8f00f94
3 changed files with 73 additions and 50 deletions
+13 -18
View File
@@ -3,7 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, func from sqlalchemy import select, desc, func
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List 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.database import get_db
from app.core.security import get_current_user from app.core.security import get_current_user
@@ -58,12 +58,15 @@ async def list_health_metrics(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
q = select(HealthMetric).where(HealthMetric.user_id == current_user.id) 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) result = await db.execute(q)
return result.scalars().all() return result.scalars().all()
@@ -73,8 +76,6 @@ async def health_summary(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Latest values + 30-day averages for dashboard widgets."""
# Latest record
latest_result = await db.execute( latest_result = await db.execute(
select(HealthMetric) select(HealthMetric)
.where(HealthMetric.user_id == current_user.id) .where(HealthMetric.user_id == current_user.id)
@@ -83,9 +84,7 @@ async def health_summary(
) )
latest = latest_result.scalar_one_or_none() latest = latest_result.scalar_one_or_none()
# 30-day averages cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).date()
from datetime import timedelta, timezone
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
avg_result = await db.execute( avg_result = await db.execute(
select( select(
func.avg(HealthMetric.resting_hr).label("avg_resting_hr"), 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"), func.avg(HealthMetric.weight_kg).label("avg_weight"),
).where( ).where(
HealthMetric.user_id == current_user.id, HealthMetric.user_id == current_user.id,
HealthMetric.date >= cutoff, func.date(HealthMetric.date) >= cutoff,
) )
) )
avgs = avg_result.one() avgs = avg_result.one()
@@ -122,17 +121,13 @@ async def add_manual_metric(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Manually add or update a health metric for a given date.""" from fastapi import HTTPException
from sqlalchemy.dialects.postgresql import insert as pg_insert
date_str = body.get("date") date_str = body.get("date")
if not date_str: if not date_str:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="date required") raise HTTPException(status_code=400, detail="date required")
metric_date = datetime.fromisoformat(date_str) metric_date = datetime.fromisoformat(date_str)
# Check for existing
existing = await db.execute( existing = await db.execute(
select(HealthMetric).where( select(HealthMetric).where(
HealthMetric.user_id == current_user.id, HealthMetric.user_id == current_user.id,
@@ -153,4 +148,4 @@ async def add_manual_metric(
db.add(metric) db.add(metric)
await db.commit() await db.commit()
return {"status": "ok"} return {"status": "ok"}
@@ -39,55 +39,82 @@ function decodePolyline(encoded) {
return coords 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' }) { export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
const mapRef = useRef(null) const mapRef = useRef(null)
const mapInstanceRef = useRef(null) const mapInstanceRef = useRef(null)
const markerRef = useRef(null) const markerRef = useRef(null)
const trackRef = useRef(null) const trackRef = useRef(null)
const tileLayerRef = 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(() => { useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return if (!mapRef.current || mapInstanceRef.current) return
mapInstanceRef.current = L.map(mapRef.current, { zoomControl: true, attributionControl: true })
const tile = TILE_LAYERS['dark'] mapInstanceRef.current = L.map(mapRef.current, {
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) zoomControl: true,
.addTo(mapInstanceRef.current) attributionControl: true,
return () => { mapInstanceRef.current?.remove(); mapInstanceRef.current = null } })
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(() => { useEffect(() => {
if (!mapInstanceRef.current) return if (!mapInstanceRef.current) return
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
if (tileLayerRef.current) { if (tileLayerRef.current) tileLayerRef.current.remove()
tileLayerRef.current.remove() tileLayerRef.current = L.tileLayer(tile.url, {
} attribution: tile.attribution,
tileLayerRef.current = L.tileLayer(tile.url, { attribution: tile.attribution, maxZoom: 19 }) maxZoom: 19,
.addTo(mapInstanceRef.current) }).addTo(mapInstanceRef.current)
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
}, [mapType]) }, [mapType])
// Draw route
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current || !polyline) return if (!mapInstanceRef.current) return
if (trackRef.current) trackRef.current.remove() drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
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)
}
}, [polyline, sportType]) }, [polyline, sportType])
// Position marker on timeline hover
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current || !dataPoints || !hoveredDistance) return if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
const point = dataPoints.find(p => p.distance_m >= hoveredDistance) const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
if (!point?.latitude || !point?.longitude) return if (!point?.latitude || !point?.longitude) return
if (markerRef.current) { 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>', 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: '', 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]) }, [hoveredDistance, dataPoints])
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} /> return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
} }
+1 -1
View File
@@ -79,7 +79,7 @@ function SleepChart({ data }) {
export default function HealthPage() { export default function HealthPage() {
const [rangeDays, setRangeDays] = useState(7) // default 1 week 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({ const { data: summary } = useQuery({
queryKey: ['health-summary'], queryKey: ['health-summary'],