Fitness historie #95

Merged
Lars merged 6 commits from develop into main 2026-04-20 08:26:46 +02:00
5 changed files with 60 additions and 35 deletions
Showing only changes of commit 22c5f695c9 - Show all commits

View File

@ -8,7 +8,7 @@
## Ziel
- Eine **Fitness-Übersicht** auf `/activity` (Capture-Hub: „Fitness“), die **keine parallelen Berechnungen** im Client führt.
- Eine **Fitness-Übersicht** auf **`/history`** (Tab Fitness), analog Körper/Ernährung — **keine parallelen Berechnungen** im Client für Layer 2b.
- **Single Source of Truth:** `data_layer/activity_metrics` (und Scores/Focus wie bei den Platzhaltern), identische Chart-Payloads wie die bestehenden Chart-Endpunkte A1/A2.
---
@ -35,8 +35,8 @@
|-------------|------|
| API-Client | `getFitnessDashboardViz(days)` in `frontend/src/utils/api.js` |
| Darstellung | `frontend/src/components/FitnessDashboardOverview.jsx` |
| Einbindung | `frontend/src/pages/ActivityPage.jsx` (oben, vor Tabs) |
| Navigation Capture | `frontend/src/config/captureNav.js` Label **Fitness**, Route `/activity` |
| Einbindung | `frontend/src/pages/History.jsx` → `ActivitySection` (gemeinsamer `PeriodSelector` wie die Liste darunter) |
| Erfassung | `/activity` bleibt reine Erfassung; Capture-Hub-Label **Aktivität** |
---

View File

@ -22,9 +22,20 @@ const PERIODS = [
/**
* Layer 2b: Kennzahlen und Charts nur aus GET /api/charts/fitness-dashboard-viz (activity_metrics).
*
* @param {number} [period] gesteuert von außen (z. B. Verlauf `PeriodSelector`); mit `onPeriodChange` koppeln.
* @param {(n: number) => void} [onPeriodChange]
* @param {boolean} [hidePeriodSelector] eigenes Zeitraum-Dropdown ausblenden (wenn die Seite oben schon einen Zeitraum wählt).
*/
export default function FitnessDashboardOverview() {
const [period, setPeriod] = useState(28)
export default function FitnessDashboardOverview({
period: periodProp,
onPeriodChange,
hidePeriodSelector = false,
}) {
const [internalPeriod, setInternalPeriod] = useState(28)
const controlled = periodProp !== undefined && typeof onPeriodChange === 'function'
const period = controlled ? periodProp : internalPeriod
const setPeriod = controlled ? onPeriodChange : setInternalPeriod
const [viz, setViz] = useState(null)
const [loading, setLoading] = useState(true)
const [err, setErr] = useState(null)
@ -104,25 +115,31 @@ export default function FitnessDashboardOverview() {
const wUsed = viz.training_volume_weeks_used
const dTyp = viz.training_type_dist_days_used
const showPeriodDropdown = !hidePeriodSelector && !controlled
return (
<div className="card section-gap">
<div className="card-title" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 12 }}>
<span>Fitness-Übersicht</span>
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}>
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
{showPeriodDropdown ? (
<label
style={{ fontSize: 12, fontWeight: 500, color: 'var(--text3)', display: 'flex', alignItems: 'center', gap: 8 }}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
Zeitraum
<select
className="form-input"
style={{ maxWidth: 140, padding: '6px 10px', fontSize: 13 }}
value={period}
onChange={(e) => setPeriod(Number(e.target.value))}
>
{PERIODS.map((p) => (
<option key={p.v} value={p.v}>
{p.label}
</option>
))}
</select>
</label>
) : null}
</div>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>

View File

@ -56,7 +56,7 @@ export const CAPTURE_HUB_TILES = [
},
{
icon: '🏋️',
label: 'Fitness',
label: 'Aktivität',
sub: 'Training manuell oder Apple Health importieren',
to: '/activity',
color: '#D4537E',

View File

@ -3,7 +3,6 @@ import { Upload, Pencil, Trash2, Check, X, CheckCircle } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
import { api } from '../utils/api'
import UsageBadge from '../components/UsageBadge'
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
import TrainingTypeSelect from '../components/TrainingTypeSelect'
import BulkCategorize from '../components/BulkCategorize'
import dayjs from 'dayjs'
@ -913,12 +912,7 @@ export default function ActivityPage() {
return (
<div className="capture-page">
<h1 className="page-title">Fitness</h1>
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: -8, marginBottom: 12 }}>
Auswertung (Data-Layer) und Erfassung an einem Ort.
</p>
<FitnessDashboardOverview />
<h1 className="page-title">Aktivität</h1>
<div className="tabs" style={{overflowX:'auto',flexWrap:'nowrap'}}>
<button className={'tab'+(tab==='list'?' active':'')} onClick={()=>setTab('list')}>Verlauf</button>

View File

@ -14,6 +14,7 @@ import { getStatusColor, getStatusBg } from '../utils/interpret'
import { MACRO_CHART, macroFillByName, NUTRITION_MACRO_CHART_BLOCK_PX } from '../utils/macroChartTheme'
import Markdown from '../utils/Markdown'
import TrainingTypeDistribution from '../components/TrainingTypeDistribution'
import FitnessDashboardOverview from '../components/FitnessDashboardOverview'
import NutritionCharts, { WeeklyMacroDistributionPanel } from '../components/NutritionCharts'
import RecoveryCharts from '../components/RecoveryCharts'
import KpiTilesOverview from '../components/KpiTilesOverview'
@ -1097,16 +1098,14 @@ function NutritionSection({ profile, insights, onRequest, loadingSlug, filterAct
)
}
// Activity Section
// Activity Section Layer 2b Fitness-Bundle wie Körper/Ernährung auf /history
function ActivitySection({ activities, insights, onRequest, loadingSlug, filterActiveSlugs, globalQualityLevel }) {
const [period, setPeriod] = useState(30)
if (!activities?.length) return (
<EmptySection text="Noch keine Aktivitätsdaten." to="/activity" toLabel="Fitness erfassen"/>
)
const cutoff = dayjs().subtract(period,'day').format('YYYY-MM-DD')
// Issue #31: Backend already filters by global quality level - only filter by period here
const filtA = activities.filter(d => period === 9999 || d.date >= cutoff)
const actList = activities || []
const filtA = actList.filter(d => period === 9999 || d.date >= cutoff)
const byDate={}
filtA.forEach(a=>{ byDate[a.date]=(byDate[a.date]||0)+(a.kcal_active||0) })
@ -1130,13 +1129,24 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
value:consistency+'%'
}]
const hasList = actList.length > 0
return (
<div>
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={activities[0]?.date}/>
<SectionHeader title="🏋️ Fitness" to="/activity" toLabel="Alle Einträge" lastUpdated={actList[0]?.date}/>
<PeriodSelector value={period} onChange={setPeriod}/>
<p style={{ fontSize: 11, color: 'var(--text3)', lineHeight: 1.45, marginBottom: 10 }}>
Fitness-Kennzahlen und Diagramme (Layer 2b) kommen aus dem Aktivitäts-Data-Layer dieselbe Quelle wie die
KI-Platzhalter. Zeitraum gilt auch für die Liste unten.
</p>
<FitnessDashboardOverview period={period} onPeriodChange={setPeriod} hidePeriodSelector />
{!hasList ? (
<EmptySection text="Noch keine Aktivitätsdaten im Verlauf." to="/activity" toLabel="Aktivität erfassen" />
) : null}
{/* Issue #31: Show active global quality filter */}
{globalQualityLevel && globalQualityLevel !== 'all' && (
{hasList && globalQualityLevel && globalQualityLevel !== 'all' && (
<div style={{
marginBottom:12, padding:'8px 12px', borderRadius:8,
background:'var(--surface2)', border:'1px solid var(--border)',
@ -1156,6 +1166,8 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
</div>
)}
{!hasList ? null : (
<>
<div style={{display:'flex',gap:6,marginBottom:12}}>
{[['Trainings',filtA.length,'var(--text1)'],['Kcal',totalKcal,'#EF9F27'],
['Stunden',Math.round(totalMin/60*10)/10,'#378ADD'],
@ -1186,7 +1198,7 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
<div key={type} style={{display:'flex',alignItems:'center',gap:8,padding:'4px 0',borderBottom:'1px solid var(--border)'}}>
<div style={{flex:1,fontSize:13}}>{type}</div>
<div style={{fontSize:12,color:'var(--text3)'}}>{count}×</div>
<div style={{width:Math.max(4,Math.round(count/filtA.length*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
<div style={{width:Math.max(4,Math.round(count/Math.max(1,filtA.length)*80)),height:6,background:'#EF9F2788',borderRadius:3}}/>
</div>
))}
</div>
@ -1199,6 +1211,8 @@ function ActivitySection({ activities, insights, onRequest, loadingSlug, filterA
{actRules.map((item,i)=><RuleCard key={i} item={item}/>)}
</div>
<InsightBox insights={insights} slugs={filterActiveSlugs(['aktivitaet'])} onRequest={onRequest} loading={loadingSlug}/>
</>
)}
</div>
)
}