feat: Enhance activity listing and statistics retrieval with pagination and quality filter options
- Added pagination support to the activity listing endpoint with `limit` and `offset` parameters. - Introduced a `skip_quality_filter` option to allow retrieval of all entries without applying the quality filter. - Updated the frontend to implement dynamic loading of activity entries and statistics without the quality filter. - Improved user experience with a "Load More" button for fetching additional entries on the ActivityPage.
This commit is contained in:
parent
766b64cd64
commit
1f51c32521
|
|
@ -35,7 +35,12 @@ except Exception as e:
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_activity(
|
def list_activity(
|
||||||
limit: int = Query(200, ge=1, le=50_000),
|
limit: int = Query(200, ge=1, le=50_000),
|
||||||
|
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"),
|
||||||
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
days: Optional[int] = Query(None, ge=1, le=4000, description="Nur Einträge mit date >= HEUTE − days (Kalendertage)"),
|
||||||
|
skip_quality_filter: bool = Query(
|
||||||
|
False,
|
||||||
|
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
|
||||||
|
),
|
||||||
session: dict = Depends(require_auth),
|
session: dict = Depends(require_auth),
|
||||||
):
|
):
|
||||||
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
"""Get activity entries for current profile. Optional *days* filter by calendar window (not the same as *limit*)."""
|
||||||
|
|
@ -44,10 +49,13 @@ def list_activity(
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
# Issue #31: Apply global quality filter (profile from DB = saved level)
|
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
if skip_quality_filter:
|
||||||
profile = r2d(cur.fetchone())
|
quality_filter = ""
|
||||||
quality_filter = get_quality_filter_sql(profile or {})
|
else:
|
||||||
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
|
profile = r2d(cur.fetchone())
|
||||||
|
quality_filter = get_quality_filter_sql(profile or {})
|
||||||
|
|
||||||
if days is not None:
|
if days is not None:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -57,9 +65,9 @@ def list_activity(
|
||||||
{quality_filter}
|
{quality_filter}
|
||||||
AND date >= (CURRENT_DATE - %s::integer)
|
AND date >= (CURRENT_DATE - %s::integer)
|
||||||
ORDER BY date DESC, start_time DESC
|
ORDER BY date DESC, start_time DESC
|
||||||
LIMIT %s
|
LIMIT %s OFFSET %s
|
||||||
""",
|
""",
|
||||||
(pid, days, limit),
|
(pid, days, limit, offset),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -68,9 +76,9 @@ def list_activity(
|
||||||
WHERE profile_id=%s
|
WHERE profile_id=%s
|
||||||
{quality_filter}
|
{quality_filter}
|
||||||
ORDER BY date DESC, start_time DESC
|
ORDER BY date DESC, start_time DESC
|
||||||
LIMIT %s
|
LIMIT %s OFFSET %s
|
||||||
""",
|
""",
|
||||||
(pid, limit),
|
(pid, limit, offset),
|
||||||
)
|
)
|
||||||
return [r2d(r) for r in cur.fetchall()]
|
return [r2d(r) for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
@ -167,14 +175,23 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
def activity_stats(session: dict = Depends(require_auth)):
|
def activity_stats(
|
||||||
|
skip_quality_filter: bool = Query(
|
||||||
|
False,
|
||||||
|
description="True = Statistik-Kacheln ohne Profil-Qualitätsfilter (passend zur /activity-Liste).",
|
||||||
|
),
|
||||||
|
session: dict = Depends(require_auth),
|
||||||
|
):
|
||||||
"""Get activity statistics (last 30 entries)."""
|
"""Get activity statistics (last 30 entries)."""
|
||||||
pid = str(session["profile_id"])
|
pid = str(session["profile_id"])
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
if skip_quality_filter:
|
||||||
profile = r2d(cur.fetchone())
|
quality_filter = ""
|
||||||
quality_filter = get_quality_filter_sql(profile or {})
|
else:
|
||||||
|
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
|
||||||
|
profile = r2d(cur.fetchone())
|
||||||
|
quality_filter = get_quality_filter_sql(profile or {})
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT * FROM activity_log
|
SELECT * FROM activity_log
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useRef, startTransition } from 'react'
|
import { useState, useEffect, useRef, useCallback, startTransition } from 'react'
|
||||||
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
|
||||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
@ -9,6 +9,9 @@ import dayjs from 'dayjs'
|
||||||
import 'dayjs/locale/de'
|
import 'dayjs/locale/de'
|
||||||
dayjs.locale('de')
|
dayjs.locale('de')
|
||||||
|
|
||||||
|
/** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */
|
||||||
|
const ACTIVITY_LIST_PAGE_SIZE = 40
|
||||||
|
|
||||||
const ACTIVITY_TYPES = [
|
const ACTIVITY_TYPES = [
|
||||||
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
|
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
|
||||||
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
|
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
|
||||||
|
|
@ -299,12 +302,48 @@ export default function ActivityPage() {
|
||||||
const [metricDraft, setMetricDraft] = useState({})
|
const [metricDraft, setMetricDraft] = useState({})
|
||||||
const [sessionLoadError, setSessionLoadError] = useState(null)
|
const [sessionLoadError, setSessionLoadError] = useState(null)
|
||||||
const [savingEdit, setSavingEdit] = useState(false)
|
const [savingEdit, setSavingEdit] = useState(false)
|
||||||
|
const [listHasMore, setListHasMore] = useState(false)
|
||||||
|
const [listLoadingMore, setListLoadingMore] = useState(false)
|
||||||
|
|
||||||
const load = async () => {
|
const loadFirstPage = useCallback(async () => {
|
||||||
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
|
const n = ACTIVITY_LIST_PAGE_SIZE
|
||||||
setEntries(e); setStats(s)
|
const [e, s] = await Promise.all([
|
||||||
|
api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }),
|
||||||
|
api.activityStats({ skipQualityFilter: true }),
|
||||||
|
])
|
||||||
|
setEntries(e)
|
||||||
|
setStats(s)
|
||||||
|
setListHasMore(e.length === n)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const entriesRef = useRef(entries)
|
||||||
|
useEffect(() => {
|
||||||
|
entriesRef.current = entries
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (listLoadingMore || !listHasMore) return
|
||||||
|
setListLoadingMore(true)
|
||||||
|
try {
|
||||||
|
const n = ACTIVITY_LIST_PAGE_SIZE
|
||||||
|
const offset = entriesRef.current.length
|
||||||
|
const more = await api.listActivity(n, undefined, {
|
||||||
|
skipQualityFilter: true,
|
||||||
|
offset,
|
||||||
|
})
|
||||||
|
if (more.length === 0) {
|
||||||
|
setListHasMore(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setEntries((prev) => [...prev, ...more])
|
||||||
|
setListHasMore(more.length === n)
|
||||||
|
} finally {
|
||||||
|
setListLoadingMore(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const load = loadFirstPage
|
||||||
|
|
||||||
const loadUsage = () => {
|
const loadUsage = () => {
|
||||||
api.getFeatureUsage().then(features => {
|
api.getFeatureUsage().then(features => {
|
||||||
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
|
const activityFeature = features.find(f => f.feature_id === 'activity_entries')
|
||||||
|
|
@ -312,11 +351,11 @@ export default function ActivityPage() {
|
||||||
}).catch(err => console.error('Failed to load usage:', err))
|
}).catch(err => console.error('Failed to load usage:', err))
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(() => {
|
||||||
load()
|
void loadFirstPage()
|
||||||
loadUsage()
|
loadUsage()
|
||||||
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
|
||||||
},[])
|
}, [loadFirstPage])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editing?.id) {
|
if (!editing?.id) {
|
||||||
|
|
@ -536,6 +575,11 @@ export default function ActivityPage() {
|
||||||
|
|
||||||
{tab==='list' && (
|
{tab==='list' && (
|
||||||
<div>
|
<div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||||
|
Hier sind <strong>alle</strong> Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei
|
||||||
|
abweichender Einordnung). Unter „Verlauf“ / Auswertung bleibt der Filter aktiv. Es werden jeweils bis zu{' '}
|
||||||
|
{ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen.
|
||||||
|
</p>
|
||||||
{entries.length===0 && (
|
{entries.length===0 && (
|
||||||
<div className="empty-state">
|
<div className="empty-state">
|
||||||
<h3>Keine Trainings</h3>
|
<h3>Keine Trainings</h3>
|
||||||
|
|
@ -678,6 +722,19 @@ export default function ActivityPage() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{listHasMore && (
|
||||||
|
<div style={{ marginTop: 12, marginBottom: 8 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={listLoadingMore}
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
>
|
||||||
|
{listLoadingMore ? 'Lade…' : 'Weitere Einträge laden'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -138,16 +138,28 @@ export const api = {
|
||||||
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
|
||||||
|
|
||||||
// Activity
|
// Activity
|
||||||
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert */
|
/**
|
||||||
listActivity: (limit=200, days)=> {
|
* @param {number} [limit=200]
|
||||||
|
* @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert
|
||||||
|
* @param {{ offset?: number, skipQualityFilter?: boolean }} [opts]
|
||||||
|
*/
|
||||||
|
listActivity: (limit=200, days, opts={})=> {
|
||||||
const q = new URLSearchParams({ limit: String(limit) })
|
const q = new URLSearchParams({ limit: String(limit) })
|
||||||
if (days != null && days !== '') q.set('days', String(days))
|
if (days != null && days !== '') q.set('days', String(days))
|
||||||
|
if (opts.offset != null && opts.offset > 0) q.set('offset', String(opts.offset))
|
||||||
|
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
|
||||||
return req(`/activity?${q}`)
|
return req(`/activity?${q}`)
|
||||||
},
|
},
|
||||||
createActivity: (d) => req('/activity',json(d)),
|
createActivity: (d) => req('/activity',json(d)),
|
||||||
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
|
||||||
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
|
||||||
activityStats: () => req('/activity/stats'),
|
/** @param {{ skipQualityFilter?: boolean }} [opts] */
|
||||||
|
activityStats: (opts={}) => {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
|
||||||
|
const qs = q.toString()
|
||||||
|
return req(`/activity/stats${qs ? `?${qs}` : ''}`)
|
||||||
|
},
|
||||||
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
listUncategorizedActivities: () => req('/activity/uncategorized'),
|
||||||
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
|
||||||
importActivityCsv: async(file)=>{
|
importActivityCsv: async(file)=>{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user