feat: Add monthly activity fetching and improve activity listing
- 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:
parent
9fdb02ff8b
commit
f718785145
|
|
@ -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"""
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -141,11 +141,12 @@ export const api = {
|
||||||
/**
|
/**
|
||||||
* @param {number} [limit=200]
|
* @param {number} [limit=200]
|
||||||
* @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert
|
* @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={})=> {
|
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}`)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user