feat: Enhance activity API feat: Enhance sleep data import functionality with support for multiple CSV formats and improved data parsing
All checks were successful
Deploy Development / deploy (push) Successful in 55s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Added functions to handle Apple Health sleep data in both segment and summary formats.
- Implemented robust error handling for date parsing and data conversion.
- Updated documentation to reflect new CSV format support and data aggregation logic.
- Bumped version in version.py to reflect the changes in the activity module.
This commit is contained in:
Lars 2026-04-07 12:28:59 +02:00
parent b617212145
commit 97f9aa696e
7 changed files with 50 additions and 20 deletions

View File

@ -9,7 +9,7 @@ import uuid
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage
@ -32,24 +32,45 @@ logger = logging.getLogger(__name__)
@router.get("")
def list_activity(limit: int=200, x_profile_id: Optional[str]=Header(default=None), session: dict=Depends(require_auth)):
"""Get activity entries for current profile."""
def list_activity(
limit: int = Query(200, ge=1, le=50_000),
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE days (Kalendertage)"),
x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
pid = get_pid(x_profile_id)
with get_db() as conn:
cur = get_cursor(conn)
# Issue #31: Apply global quality filter
# Issue #31: Apply global quality filter (profile from DB = saved level)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile)
cur.execute(f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC, start_time DESC
LIMIT %s
""", (pid, limit))
if days is not None:
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= (CURRENT_DATE - %s::integer)
ORDER BY date DESC, start_time DESC
LIMIT %s
""",
(pid, days, limit),
)
else:
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
ORDER BY date DESC, start_time DESC
LIMIT %s
""",
(pid, limit),
)
return [r2d(r) for r in cur.fetchall()]

View File

@ -19,7 +19,7 @@ MODULE_VERSIONS = {
"weight": "1.0.3",
"circumference": "1.0.1",
"caliper": "1.0.1",
"activity": "1.1.0",
"activity": "1.2.0", # GET /activity: optional days= window + limit
"nutrition": "1.0.2",
"photos": "1.0.0",
"insights": "1.3.0",

View File

@ -15,8 +15,10 @@ export default function TrainingTypeDistribution({ days = 28 }) {
const [loading, setLoading] = useState(true)
useEffect(() => {
const safeDays = Math.max(1, Math.min(4000, Number(days) || 28))
const limit = Math.min(50_000, Math.max(250, safeDays * 25))
Promise.all([
api.listActivity(days),
api.listActivity(limit, safeDays),
api.getTrainingCategories()
]).then(([activities, cats]) => {
setCategories(cats)
@ -43,7 +45,7 @@ export default function TrainingTypeDistribution({ days = 28 }) {
console.error('Failed to load training type distribution:', err)
setLoading(false)
})
}, [days, activeProfile?.quality_filter_level]) // Issue #31: Reload when quality filter changes
}, [days, activeProfile?.quality_filter_level, activeProfile?.id])
if (loading) {
return (

View File

@ -21,8 +21,8 @@ export default function PilotActivitySection({ refreshTick = 0, chartDays = BODY
let cancelled = false
;(async () => {
try {
const fetchDays = Math.max(120, periodDays + 60)
const a = await api.listActivity(fetchDays)
const limit = Math.min(50_000, Math.max(200, periodDays * 25))
const a = await api.listActivity(limit, periodDays)
if (!cancelled) setActivities(Array.isArray(a) ? a : [])
} catch {
if (!cancelled) setActivities([])

View File

@ -276,7 +276,7 @@ export default function Dashboard() {
api.listCaliper(3),
api.listCirc(2),
api.listNutrition(30),
api.listActivity(30),
api.listActivity(800, 30),
api.latestInsights(),
]).then(([s,w,ca,ci,n,a,ins])=>{
setStats(s); setWeights(w); setCalipers(ca); setCircs(ci)

View File

@ -972,7 +972,7 @@ export default function History() {
const loadAll = () => Promise.all([
api.listWeight(365), api.listCaliper(), api.listCirc(),
api.listNutrition(90), api.listActivity(200),
api.listNutrition(90), api.listActivity(25_000),
api.nutritionCorrelations(), api.latestInsights(), api.getProfile(),
api.listPrompts(),
]).then(([w,ca,ci,n,a,corr,ins,p,pr])=>{
@ -983,7 +983,9 @@ export default function History() {
setLoading(false)
})
useEffect(()=>{ loadAll() },[])
useEffect(() => {
loadAll()
}, [activeProfile?.quality_filter_level])
useEffect(() => {
const t = location.state?.tab

View File

@ -118,7 +118,12 @@ export const api = {
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
// Activity
listActivity: (l=200)=> req(`/activity?limit=${l}`),
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert */
listActivity: (limit=200, days)=> {
const q = new URLSearchParams({ limit: String(limit) })
if (days != null && days !== '') q.set('days', String(days))
return req(`/activity?${q}`)
},
createActivity: (d) => req('/activity',json(d)),
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),