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 156 additions and 43 deletions
Showing only changes of commit f718785145 - Show all commits

View File

@ -7,6 +7,9 @@ import csv
import io
import uuid
import logging
import re
import calendar
from datetime import date
from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query
@ -22,6 +25,19 @@ from data_layer.activity_session_metrics import sync_column_backed_session_metri
router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__)
_MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
def _month_date_bounds(ym: str) -> tuple[date, date]:
m = _MONTH_RE.match((ym or "").strip())
if not m:
raise HTTPException(status_code=400, detail="month muss YYYY-MM sein")
y, mo = int(m.group(1)), int(m.group(2))
if mo < 1 or mo > 12:
raise HTTPException(status_code=400, detail="Ungültiger Monat")
last = calendar.monthrange(y, mo)[1]
return date(y, mo, 1), date(y, mo, last)
# Evaluation import with error handling (Phase 1.2)
try:
from evaluation_helper import evaluate_and_save_activity
@ -37,6 +53,10 @@ 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)"),
month: Optional[str] = Query(
None,
description='Kalendermonat "YYYY-MM" (ganzer Monat; schließt days und offset aus)',
),
skip_quality_filter: bool = Query(
False,
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.",
@ -57,6 +77,25 @@ def list_activity(
profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {})
if month:
if days is not None:
raise HTTPException(status_code=400, detail="month und days schließen sich aus")
if offset != 0:
raise HTTPException(status_code=400, detail="month und offset schließen sich aus")
d0, d1 = _month_date_bounds(month)
cur.execute(
f"""
SELECT * FROM activity_log
WHERE profile_id=%s
{quality_filter}
AND date >= %s AND date <= %s
ORDER BY date DESC, start_time DESC NULLS LAST, id DESC
LIMIT %s
""",
(pid, d0, d1, limit),
)
return [r2d(r) for r in cur.fetchall()]
if days is not None:
cur.execute(
f"""

View File

@ -9,8 +9,34 @@ 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
/** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */
const ACTIVITY_MONTH_FETCH_LIMIT = 25_000
function ymdMonth(d = dayjs()) {
return d.format('YYYY-MM')
}
function prevMonthYm(ym) {
return dayjs(`${ym}-01`).subtract(1, 'month').format('YYYY-MM')
}
function compareActivities(a, b) {
const da = a.date || ''
const db = b.date || ''
if (da !== db) return db.localeCompare(da)
const sa = String(a.start_time || '')
const sb = String(b.start_time || '')
if (sa !== sb) return sb.localeCompare(sa)
return String(b.id || '').localeCompare(String(a.id || ''))
}
function dedupeActivitiesById(rows) {
const m = new Map()
for (const r of rows) {
if (r?.id) m.set(r.id, r)
}
return [...m.values()].sort(compareActivities)
}
const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
@ -304,54 +330,69 @@ 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 [selectedMonth, setSelectedMonth] = useState(() => ymdMonth())
const [monthsIncluded, setMonthsIncluded] = useState(() => [ymdMonth()])
const monthsIncludedRef = useRef(monthsIncluded)
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)
useEffect(() => {
monthsIncludedRef.current = monthsIncluded
}, [monthsIncluded])
const fetchMonthsChain = useCallback(async (chain) => {
const lists = await Promise.all(
chain.map((ym) =>
api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
skipQualityFilter: true,
month: ym,
})
)
)
const merged = dedupeActivitiesById(lists.flat())
const s = await api.activityStats({ skipQualityFilter: true })
setEntries(merged)
setStats(s)
setListHasMore(e.length === n)
}, [])
const entriesRef = useRef(entries)
useEffect(() => {
entriesRef.current = entries
}, [entries])
const load = useCallback(async () => {
await fetchMonthsChain(monthsIncludedRef.current)
}, [fetchMonthsChain])
const loadMore = async () => {
if (listLoadingMore || !listHasMore) return
const onMonthPickerChange = (e) => {
const ym = e.target.value
if (!ym) return
setSelectedMonth(ym)
const chain = [ym]
monthsIncludedRef.current = chain
setMonthsIncluded(chain)
void fetchMonthsChain(chain)
}
const loadPreviousMonth = async () => {
const chain = monthsIncludedRef.current
if (chain.length === 0) return
const oldest = chain[chain.length - 1]
const prev = prevMonthYm(oldest)
if (chain.includes(prev)) return
if (prev < '2000-01') return
setListLoadingMore(true)
try {
const n = ACTIVITY_LIST_PAGE_SIZE
const offset = entriesRef.current.length
const more = await api.listActivity(n, undefined, {
const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
skipQualityFilter: true,
offset,
month: prev,
})
if (more.length === 0) {
setListHasMore(false)
return
}
const prev = entriesRef.current
const seen = new Set(prev.map((r) => r.id))
const add = more.filter((r) => r.id && !seen.has(r.id))
if (add.length === 0) {
setListHasMore(false)
return
}
setEntries((p) => [...p, ...add])
setListHasMore(more.length === n)
const newChain = [...chain, prev]
monthsIncludedRef.current = newChain
setMonthsIncluded(newChain)
setEntries((cur) => dedupeActivitiesById([...cur, ...more]))
} finally {
setListLoadingMore(false)
}
}
const load = loadFirstPage
const oldestLoadedYm = monthsIncluded.length ? monthsIncluded[monthsIncluded.length - 1] : selectedMonth
const nextOlderYm = prevMonthYm(oldestLoadedYm)
const canLoadOlder = nextOlderYm >= '2000-01' && !monthsIncluded.includes(nextOlderYm)
const loadUsage = () => {
api.getFeatureUsage().then(features => {
@ -361,10 +402,14 @@ export default function ActivityPage() {
}
useEffect(() => {
void loadFirstPage()
const ym = ymdMonth()
monthsIncludedRef.current = [ym]
setMonthsIncluded([ym])
setSelectedMonth(ym)
void fetchMonthsChain([ym])
loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [loadFirstPage])
}, [fetchMonthsChain])
useEffect(() => {
if (!editing?.id) {
@ -594,10 +639,36 @@ export default function ActivityPage() {
{tab==='list' && (
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 12,
flexWrap: 'wrap',
}}
>
<label className="form-label" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: 8 }}>
Monat
<input
type="month"
className="form-input"
value={selectedMonth}
onChange={onMonthPickerChange}
style={{ width: 'auto', minWidth: 150, margin: 0 }}
/>
</label>
{monthsIncluded.length > 1 && (
<span style={{ fontSize: 11, color: 'var(--text3)' }}>
Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
{dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
</span>
)}
</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.
abweichender Einordnung). Unter Verlauf / Auswertung bleibt der Filter aktiv. Es wird jeweils ein
kompletter Kalendermonat geladen; Vorheriger Monat hängt den nächstälteren Monat an (ohne OFFSET-Pagination).
</p>
{entries.length===0 && (
<div className="empty-state">
@ -741,16 +812,18 @@ export default function ActivityPage() {
</div>
)
})}
{listHasMore && (
{canLoadOlder && (
<div style={{ marginTop: 12, marginBottom: 8 }}>
<button
type="button"
className="btn btn-secondary"
style={{ width: '100%' }}
disabled={listLoadingMore}
onClick={() => void loadMore()}
onClick={() => void loadPreviousMonth()}
>
{listLoadingMore ? 'Lade…' : 'Weitere Einträge laden'}
{listLoadingMore
? 'Lade…'
: `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
</button>
</div>
)}

View File

@ -141,11 +141,12 @@ export const api = {
/**
* @param {number} [limit=200]
* @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert
* @param {{ offset?: number, skipQualityFilter?: boolean }} [opts]
* @param {{ offset?: number, skipQualityFilter?: boolean, month?: string }} [opts] month = YYYY-MM (schließt days/offset aus)
*/
listActivity: (limit=200, days, opts={})=> {
const q = new URLSearchParams({ limit: String(limit) })
if (days != null && days !== '') q.set('days', String(days))
if (opts.month) q.set('month', String(opts.month))
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}`)