Erste Version - Universal CSV Importer für EAV und activity_log #85
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -141,11 +141,12 @@ export const api = {
|
|||
/**
|
||||
* @param {number} [limit=200]
|
||||
* @param {number} [days] nur Einträge ab HEUTE−days (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}`)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user