Erste Version - Universal CSV Importer für EAV und activity_log #85
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 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) })
|
||||
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)=>{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user