feat: Add monthly activity fetching and improve activity listing
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Introduced a new query parameter for the activity listing endpoint to fetch entries by calendar month (format: YYYY-MM), excluding days and offset.
- Implemented backend validation for the month parameter to ensure correct format and range.
- Enhanced the frontend to support month selection, allowing users to load activities for specific months and dynamically update the displayed entries.
- Improved the user interface to show the selected month and the range of loaded months, enhancing user experience.
This commit is contained in:
Lars 2026-04-14 14:34:10 +02:00
parent 9fdb02ff8b
commit f718785145
3 changed files with 156 additions and 43 deletions

View File

@ -7,6 +7,9 @@ import csv
import io import io
import uuid import uuid
import logging import logging
import re
import calendar
from datetime import date
from typing import Optional from typing import Optional
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends, Query 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"]) router = APIRouter(prefix="/api/activity", tags=["activity"])
logger = logging.getLogger(__name__) 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) # Evaluation import with error handling (Phase 1.2)
try: try:
from evaluation_helper import evaluate_and_save_activity from evaluation_helper import evaluate_and_save_activity
@ -37,6 +53,10 @@ def list_activity(
limit: int = Query(200, ge=1, le=50_000), limit: int = Query(200, ge=1, le=50_000),
offset: int = Query(0, ge=0, le=100_000, description="SQL OFFSET für Pagination"), 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)"), 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( skip_quality_filter: bool = Query(
False, False,
description="True = alle Einträge des Profils (ohne quality_label-Filter). Für /activity Erfassung.", 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()) profile = r2d(cur.fetchone())
quality_filter = get_quality_filter_sql(profile or {}) 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: if days is not None:
cur.execute( cur.execute(
f""" f"""

View File

@ -9,8 +9,34 @@ import dayjs from 'dayjs'
import 'dayjs/locale/de' import 'dayjs/locale/de'
dayjs.locale('de') dayjs.locale('de')
/** Erfassungsseite /activity: erste Ladung + „Mehr laden“ (ohne Qualitätsfilter, siehe Backend). */ /** Erfassungsseite /activity: pro Kalendermonat laden (ohne Qualitätsfilter, siehe Backend). */
const ACTIVITY_LIST_PAGE_SIZE = 40 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 = [ const ACTIVITY_TYPES = [
'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang', 'Traditionelles Krafttraining','Matrial Arts','Outdoor Spaziergang',
@ -304,54 +330,69 @@ export default function ActivityPage() {
const [metricDraft, setMetricDraft] = useState({}) const [metricDraft, setMetricDraft] = useState({})
const [sessionLoadError, setSessionLoadError] = useState(null) const [sessionLoadError, setSessionLoadError] = useState(null)
const [savingEdit, setSavingEdit] = useState(false) const [savingEdit, setSavingEdit] = useState(false)
const [listHasMore, setListHasMore] = useState(false)
const [listLoadingMore, setListLoadingMore] = 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 () => { useEffect(() => {
const n = ACTIVITY_LIST_PAGE_SIZE monthsIncludedRef.current = monthsIncluded
const [e, s] = await Promise.all([ }, [monthsIncluded])
api.listActivity(n, undefined, { skipQualityFilter: true, offset: 0 }),
api.activityStats({ skipQualityFilter: true }), const fetchMonthsChain = useCallback(async (chain) => {
]) const lists = await Promise.all(
setEntries(e) 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) setStats(s)
setListHasMore(e.length === n)
}, []) }, [])
const entriesRef = useRef(entries) const load = useCallback(async () => {
useEffect(() => { await fetchMonthsChain(monthsIncludedRef.current)
entriesRef.current = entries }, [fetchMonthsChain])
}, [entries])
const loadMore = async () => { const onMonthPickerChange = (e) => {
if (listLoadingMore || !listHasMore) return 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) setListLoadingMore(true)
try { try {
const n = ACTIVITY_LIST_PAGE_SIZE const more = await api.listActivity(ACTIVITY_MONTH_FETCH_LIMIT, undefined, {
const offset = entriesRef.current.length
const more = await api.listActivity(n, undefined, {
skipQualityFilter: true, skipQualityFilter: true,
offset, month: prev,
}) })
if (more.length === 0) { const newChain = [...chain, prev]
setListHasMore(false) monthsIncludedRef.current = newChain
return setMonthsIncluded(newChain)
} setEntries((cur) => dedupeActivitiesById([...cur, ...more]))
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)
} finally { } finally {
setListLoadingMore(false) 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 = () => { const loadUsage = () => {
api.getFeatureUsage().then(features => { api.getFeatureUsage().then(features => {
@ -361,10 +402,14 @@ export default function ActivityPage() {
} }
useEffect(() => { useEffect(() => {
void loadFirstPage() const ym = ymdMonth()
monthsIncludedRef.current = [ym]
setMonthsIncluded([ym])
setSelectedMonth(ym)
void fetchMonthsChain([ym])
loadUsage() loadUsage()
api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err)) api.getTrainingCategories().then(setCategories).catch(err => console.error('Failed to load categories:', err))
}, [loadFirstPage]) }, [fetchMonthsChain])
useEffect(() => { useEffect(() => {
if (!editing?.id) { if (!editing?.id) {
@ -594,10 +639,36 @@ export default function ActivityPage() {
{tab==='list' && ( {tab==='list' && (
<div> <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 }}> <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 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{' '} abweichender Einordnung). Unter Verlauf / Auswertung bleibt der Filter aktiv. Es wird jeweils ein
{ACTIVITY_LIST_PAGE_SIZE} Einträge nachgeladen. kompletter Kalendermonat geladen; Vorheriger Monat hängt den nächstälteren Monat an (ohne OFFSET-Pagination).
</p> </p>
{entries.length===0 && ( {entries.length===0 && (
<div className="empty-state"> <div className="empty-state">
@ -741,16 +812,18 @@ export default function ActivityPage() {
</div> </div>
) )
})} })}
{listHasMore && ( {canLoadOlder && (
<div style={{ marginTop: 12, marginBottom: 8 }}> <div style={{ marginTop: 12, marginBottom: 8 }}>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
style={{ width: '100%' }} style={{ width: '100%' }}
disabled={listLoadingMore} 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> </button>
</div> </div>
)} )}

View File

@ -141,11 +141,12 @@ export const api = {
/** /**
* @param {number} [limit=200] * @param {number} [limit=200]
* @param {number} [days] nur Einträge ab HEUTEdays (Kalendertage), backend-filtert * @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={})=> { listActivity: (limit=200, days, opts={})=> {
const q = new URLSearchParams({ limit: String(limit) }) const q = new URLSearchParams({ limit: String(limit) })
if (days != null && days !== '') q.set('days', String(days)) 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.offset != null && opts.offset > 0) q.set('offset', String(opts.offset))
if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true') if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
return req(`/activity?${q}`) return req(`/activity?${q}`)