diff --git a/backend/routers/activity.py b/backend/routers/activity.py index 8237f1f..3296012 100644 --- a/backend/routers/activity.py +++ b/backend/routers/activity.py @@ -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""" diff --git a/frontend/src/pages/ActivityPage.jsx b/frontend/src/pages/ActivityPage.jsx index ceb9f28..58bbc99 100644 --- a/frontend/src/pages/ActivityPage.jsx +++ b/frontend/src/pages/ActivityPage.jsx @@ -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' && (
Hier sind alle 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).
{entries.length===0 && (