Erste Version - Universal CSV Importer für EAV und activity_log #85

Merged
Lars merged 17 commits from develop into main 2026-04-15 11:46:31 +02:00
3 changed files with 108 additions and 22 deletions
Showing only changes of commit 1f51c32521 - Show all commits

View File

@ -35,7 +35,12 @@ except Exception as e:
@router.get("")
def list_activity(
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)"),
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),
):
"""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:
cur = get_cursor(conn)
# 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 or {})
# Issue #31: Qualitätsfilter — auf der Erfassungsseite /activity abschaltbar (skip_quality_filter)
if skip_quality_filter:
quality_filter = ""
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:
cur.execute(
@ -57,9 +65,9 @@ def list_activity(
{quality_filter}
AND date >= (CURRENT_DATE - %s::integer)
ORDER BY date DESC, start_time DESC
LIMIT %s
LIMIT %s OFFSET %s
""",
(pid, days, limit),
(pid, days, limit, offset),
)
else:
cur.execute(
@ -68,9 +76,9 @@ def list_activity(
WHERE profile_id=%s
{quality_filter}
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()]
@ -167,14 +175,23 @@ def create_activity(e: ActivityEntry, x_profile_id: Optional[str]=Header(default
@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)."""
pid = str(session["profile_id"])
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {})
if skip_quality_filter:
quality_filter = ""
else:
cur.execute("SELECT * FROM profiles WHERE id=%s", (pid,))
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {})
cur.execute(
f"""
SELECT * FROM activity_log

View File

@ -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 { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
@ -9,6 +9,9 @@ import dayjs from 'dayjs'
import '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 = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
'Innenräume Spaziergang','Laufen','Radfahren','Schwimmen',
@ -299,12 +302,48 @@ export default function ActivityPage() {
const [metricDraft, setMetricDraft] = useState({})
const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false)
const [listHasMore, setListHasMore] = useState(false)
const [listLoadingMore, setListLoadingMore] = useState(false)
const load = async () => {
const [e, s] = await Promise.all([api.listActivity(), api.activityStats()])
setEntries(e); setStats(s)
const loadFirstPage = useCallback(async () => {
const n = ACTIVITY_LIST_PAGE_SIZE
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 = () => {
api.getFeatureUsage().then(features => {
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))
}
useEffect(()=>{
load()
useEffect(() => {
void loadFirstPage()
loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
},[])
}, [loadFirstPage])
useEffect(() => {
if (!editing?.id) {
@ -536,6 +575,11 @@ export default function ActivityPage() {
{tab==='list' && (
<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 && (
<div className="empty-state">
<h3>Keine Trainings</h3>
@ -678,6 +722,19 @@ export default function ActivityPage() {
</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>

View File

@ -138,16 +138,28 @@ export const api = {
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
// Activity
/** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert */
listActivity: (limit=200, days)=> {
/**
* @param {number} [limit=200]
* @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert
* @param {{ offset?: number, skipQualityFilter?: boolean }} [opts]
*/
listActivity: (limit=200, days, opts={})=> {
const q = new URLSearchParams({ limit: String(limit) })
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}`)
},
createActivity: (d) => req('/activity',json(d)),
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
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'),
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
importActivityCsv: async(file)=>{