@@ -335,6 +751,37 @@ export default function ActivityPage() {
{tab==='list' && (
+
+
+ Monat
+
+
+ {monthsIncluded.length > 1 && (
+
+ Zeitraum: {dayjs(`${selectedMonth}-01`).format('MMMM YYYY')} bis{' '}
+ {dayjs(`${oldestLoadedYm}-01`).format('MMMM YYYY')}
+
+ )}
+
+
+ Hier sind alle Trainings sichtbar (Profil-Qualitätsfilter aus — auch ohne Bewertung oder bei
+ 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 && (
Keine Trainings
@@ -347,8 +794,28 @@ export default function ActivityPage() {
return (
{isEd ? (
-
setEditing(null)} saveLabel="Speichern"/>
+ { setEditing(null); setSessionDetail(null); setSessionLoadError(null) }}
+ saveLabel="Speichern"
+ saving={savingEdit}
+ error={error}
+ formExtras={
+ <>
+ {sessionLoadError && (
+ {sessionLoadError}
+ )}
+
+ >
+ }
+ />
) : (
@@ -435,7 +902,12 @@ export default function ActivityPage() {
{dayjs(e.date).format('dd, DD. MMMM YYYY')}
- {e.start_time && e.start_time.length>10 && ` · ${e.start_time.slice(11,16)}`}
+ {(formatTimeForList(e.start_time) || formatTimeForList(e.end_time)) && (
+
+ {formatTimeForList(e.start_time) && ` · Start ${formatTimeForList(e.start_time)}`}
+ {formatTimeForList(e.end_time) && ` · Ende ${formatTimeForList(e.end_time)}`}
+
+ )}
{e.duration_min && ⏱ {Math.round(e.duration_min)} Min }
@@ -458,6 +930,21 @@ export default function ActivityPage() {
)
})}
+ {canLoadOlder && (
+
+ void loadPreviousMonth()}
+ >
+ {listLoadingMore
+ ? 'Lade…'
+ : `Vorherigen Monat laden (${dayjs(`${nextOlderYm}-01`).format('MMMM YYYY')})`}
+
+
+ )}
)}
diff --git a/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
new file mode 100644
index 0000000..f0e21cd
--- /dev/null
+++ b/frontend/src/pages/AdminActivityAttributeProfilesPage.jsx
@@ -0,0 +1,912 @@
+import { useState, useEffect, useCallback } from 'react'
+import { Link } from 'react-router-dom'
+import { Plus, Trash2, Save, RefreshCw, Pencil } from 'lucide-react'
+import { api } from '../utils/api'
+
+const PARAM_GROUP = ['physical', 'physiological', 'subjective', 'environmental', 'performance']
+const DATA_TYPES = ['integer', 'float', 'string', 'boolean']
+
+const emptyParamForm = () => ({
+ key: '',
+ name_de: '',
+ name_en: '',
+ category: 'physical',
+ data_type: 'float',
+ unit: '',
+ source_field: '',
+ is_active: true,
+})
+
+export default function AdminActivityAttributeProfilesPage() {
+ const [tab, setTab] = useState('catalog')
+ const [params, setParams] = useState([])
+ const [includeInactive, setIncludeInactive] = useState(false)
+ const [catMeta, setCatMeta] = useState({})
+ const [flatTypes, setFlatTypes] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [toast, setToast] = useState(null)
+
+ const [showParamForm, setShowParamForm] = useState(false)
+ const [paramForm, setParamForm] = useState(emptyParamForm())
+ const [editParam, setEditParam] = useState(null)
+
+ const [selCategory, setSelCategory] = useState('cardio')
+ const [catLinks, setCatLinks] = useState([])
+ const [catAdd, setCatAdd] = useState({
+ training_parameter_id: '',
+ sort_order: 0,
+ required: false,
+ ui_group: '',
+ })
+ const [editingCatId, setEditingCatId] = useState(null)
+ const [catDraft, setCatDraft] = useState({ sort_order: 0, required: false, ui_group: '' })
+
+ const [selTypeId, setSelTypeId] = useState('')
+ const [typeLinks, setTypeLinks] = useState([])
+ const [typeAdd, setTypeAdd] = useState({
+ training_parameter_id: '',
+ sort_order: '',
+ required: '',
+ ui_group: '',
+ })
+ const [editingTypeId, setEditingTypeId] = useState(null)
+ const [typeDraft, setTypeDraft] = useState({ sort_order: '', required: '', ui_group: '' })
+
+ const showToast = (msg) => {
+ setToast(msg)
+ setTimeout(() => setToast(null), 2800)
+ }
+
+ const refreshCatalog = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [p, cats, flat] = await Promise.all([
+ api.adminListTrainingParameters(includeInactive),
+ api.getTrainingCategories(),
+ api.listTrainingTypesFlat(),
+ ])
+ setParams(Array.isArray(p) ? p : [])
+ setCatMeta(cats && typeof cats === 'object' ? cats : {})
+ setFlatTypes(Array.isArray(flat) ? flat : [])
+ setSelTypeId((prev) => (prev ? prev : flat?.length ? String(flat[0].id) : ''))
+ } catch (e) {
+ setError(e.message || 'Laden fehlgeschlagen')
+ } finally {
+ setLoading(false)
+ }
+ }, [includeInactive])
+
+ useEffect(() => {
+ refreshCatalog()
+ }, [refreshCatalog])
+
+ const loadCatLinks = useCallback(async () => {
+ try {
+ const data = await api.adminListTrainingCategoryParameters(selCategory)
+ setCatLinks(Array.isArray(data) ? data : [])
+ } catch (e) {
+ setError(e.message || 'Kategorie-Zuordnungen')
+ }
+ }, [selCategory])
+
+ useEffect(() => {
+ loadCatLinks()
+ }, [loadCatLinks])
+
+ const loadTypeLinks = useCallback(async () => {
+ if (!selTypeId) {
+ setTypeLinks([])
+ return
+ }
+ try {
+ const data = await api.adminListTrainingTypeParameters(Number(selTypeId))
+ setTypeLinks(Array.isArray(data) ? data : [])
+ } catch (e) {
+ setError(e.message || 'Typ-Zuordnungen')
+ }
+ }, [selTypeId])
+
+ useEffect(() => {
+ loadTypeLinks()
+ }, [loadTypeLinks])
+
+ const activeParams = params.filter((p) => p.is_active !== false)
+
+ const categoryKeys =
+ Object.keys(catMeta).length > 0
+ ? Object.keys(catMeta).sort()
+ : ['cardio', 'strength', 'hiit', 'martial_arts', 'mobility', 'recovery', 'other']
+
+ const saveNewParameter = async () => {
+ setError(null)
+ if (!paramForm.key.trim() || !paramForm.name_de.trim() || !paramForm.name_en.trim()) {
+ setError('key, name_de und name_en sind Pflicht.')
+ return
+ }
+ try {
+ await api.adminCreateTrainingParameter({
+ key: paramForm.key.trim().toLowerCase(),
+ name_de: paramForm.name_de.trim(),
+ name_en: paramForm.name_en.trim(),
+ category: paramForm.category,
+ data_type: paramForm.data_type,
+ unit: paramForm.unit.trim() || null,
+ source_field: paramForm.source_field.trim() || null,
+ is_active: paramForm.is_active,
+ validation_rules: {},
+ })
+ showToast('Parameter angelegt')
+ setShowParamForm(false)
+ setParamForm(emptyParamForm())
+ await refreshCatalog()
+ } catch (e) {
+ setError(e.message || 'Speichern fehlgeschlagen')
+ }
+ }
+
+ const saveEditedParameter = async () => {
+ if (!editParam) return
+ setError(null)
+ try {
+ await api.adminUpdateTrainingParameter(editParam.id, {
+ name_de: editParam.name_de.trim(),
+ name_en: editParam.name_en.trim(),
+ category: editParam.category,
+ data_type: editParam.data_type,
+ unit: editParam.unit?.trim() || null,
+ source_field: editParam.source_field?.trim() || null,
+ is_active: !!editParam.is_active,
+ validation_rules: editParam.validation_rules || {},
+ })
+ showToast('Parameter gespeichert')
+ setEditParam(null)
+ await refreshCatalog()
+ } catch (e) {
+ setError(e.message || 'Update fehlgeschlagen')
+ }
+ }
+
+ const deactivateParameter = async (id) => {
+ if (!confirm('Parameter deaktivieren? (Bestehende EAV-Zeilen bleiben erhalten.)')) return
+ try {
+ await api.adminDeleteTrainingParameter(id)
+ showToast('Deaktiviert')
+ await refreshCatalog()
+ } catch (e) {
+ setError(e.message || 'Löschen fehlgeschlagen')
+ }
+ }
+
+ const addCatLink = async () => {
+ const pid = parseInt(catAdd.training_parameter_id, 10)
+ if (!pid) {
+ setError('Parameter-ID wählen')
+ return
+ }
+ try {
+ await api.adminAddTrainingCategoryParameter({
+ training_category: selCategory,
+ training_parameter_id: pid,
+ sort_order: Number(catAdd.sort_order) || 0,
+ required: !!catAdd.required,
+ ui_group: catAdd.ui_group.trim() || null,
+ })
+ showToast('Zuordnung gespeichert')
+ setCatAdd({ training_parameter_id: '', sort_order: 0, required: false, ui_group: '' })
+ await loadCatLinks()
+ } catch (e) {
+ setError(e.message || 'Konflikt oder ungültige Daten')
+ }
+ }
+
+ const saveCatLink = async (linkId) => {
+ try {
+ await api.adminUpdateTrainingCategoryParameter(linkId, {
+ sort_order: Number(catDraft.sort_order) || 0,
+ required: !!catDraft.required,
+ ui_group: catDraft.ui_group.trim() || null,
+ })
+ showToast('Kategorie-Zuordnung aktualisiert')
+ setEditingCatId(null)
+ await loadCatLinks()
+ } catch (e) {
+ setError(e.message || 'Update fehlgeschlagen')
+ }
+ }
+
+ const addTypeLink = async () => {
+ const tid = Number(selTypeId)
+ const pid = parseInt(typeAdd.training_parameter_id, 10)
+ if (!tid || !pid) {
+ setError('Trainingstyp und Parameter wählen')
+ return
+ }
+ const body = {
+ training_type_id: tid,
+ training_parameter_id: pid,
+ sort_order: typeAdd.sort_order === '' ? null : Number(typeAdd.sort_order),
+ required:
+ typeAdd.required === '' ? null : typeAdd.required === 'true' || typeAdd.required === true,
+ ui_group: typeAdd.ui_group.trim() || null,
+ }
+ try {
+ await api.adminAddTrainingTypeParameter(body)
+ showToast('Typ-Zuordnung gespeichert')
+ setTypeAdd({ training_parameter_id: '', sort_order: '', required: '', ui_group: '' })
+ await loadTypeLinks()
+ } catch (e) {
+ setError(e.message || 'Konflikt oder ungültige Daten')
+ }
+ }
+
+ const saveTypeLink = async (linkId) => {
+ try {
+ const payload = {}
+ if (typeDraft.sort_order !== '' && typeDraft.sort_order != null) {
+ payload.sort_order = Number(typeDraft.sort_order)
+ } else {
+ payload.sort_order = null
+ }
+ if (typeDraft.required === '' || typeDraft.required == null) {
+ payload.required = null
+ } else {
+ payload.required = typeDraft.required === true || typeDraft.required === 'true'
+ }
+ payload.ui_group = typeDraft.ui_group.trim() || null
+ await api.adminUpdateTrainingTypeParameter(linkId, payload)
+ showToast('Typ-Zuordnung aktualisiert')
+ setEditingTypeId(null)
+ await loadTypeLinks()
+ } catch (e) {
+ setError(e.message || 'Update fehlgeschlagen')
+ }
+ }
+
+ if (loading && !params.length) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+ ← Training (Hub)
+
+
+
Session-Metriken (EAV)
+
+
+
Hinweise
+
+
+ Daten: Offizielle Migrationen löschen keine Zeilen in activity_log. Leere
+ Tabellen nach Deploy deuten auf neues DB-Volume, manuelles SQL oder falsche Umgebung hin – vor Prod immer{' '}
+ pg_dump.
+
+
+ Doppelzuordnung: Dieselbe Metrik darf eine Zeile pro Kategorie und eine {' '}
+ pro Trainingstyp haben (Unique-Constraint). Wenn dieselbe Metrik in Kategorie und{' '}
+ Trainingstyp vorkommt, überschreibt die Typ-Zeile sort/required/ui_group
+ (Merge in Layer 1) – kein Datenfehler.
+
+
+ Nach Migration 055 werden Standard-Parameter allen Kategorien zugeordnet und vorhandene{' '}
+ activity_log-Spalten idempotent nach EAV gespiegelt (sofern noch keine EAV-Zeile existiert).
+
+
+
+
+ {toast && (
+
+ {toast}
+
+ )}
+ {error && (
+
+ {error}
+ setError(null)}>
+ OK
+
+
+ )}
+
+
+ {[
+ ['catalog', 'Katalog'],
+ ['category', 'Kategorie'],
+ ['type', 'Trainingstyp'],
+ ].map(([id, label]) => (
+ setTab(id)}
+ >
+ {label}
+
+ ))}
+
+
+ {tab === 'catalog' && (
+
+
+
Parameter-Katalog
+
+ setIncludeInactive(e.target.checked)}
+ />
+ Inaktive
+
+
refreshCatalog()}>
+ Neu laden
+
+
setShowParamForm((v) => !v)}>
+ Neu
+
+
+
+ {showParamForm && (
+
+ )}
+
+ {editParam && (
+
+ )}
+
+
+
+
+
+ ID
+ key
+ DE
+ Typ
+ aktiv
+
+
+
+
+ {params.map((r) => (
+
+ {r.id}
+
+ {r.key}
+
+ {r.name_de}
+
+ {r.data_type} · {r.category}
+
+ {r.is_active === false ? 'nein' : 'ja'}
+
+
+
setEditParam({ ...r })}
+ >
+
+
+ {r.is_active !== false && (
+
deactivateParameter(r.id)}
+ >
+
+
+ )}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {tab === 'category' && (
+
+
Zuordnung: Trainings-Kategorie
+
+ Kategorie
+ setSelCategory(e.target.value)}
+ style={{ maxWidth: 280 }}
+ >
+ {categoryKeys.map((k) => (
+
+ {catMeta[k]?.name_de || k}
+
+ ))}
+
+
+
+
+ Parameter
+ setCatAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
+ >
+ — wählen —
+ {activeParams.map((p) => (
+
+ {p.id} · {p.key} ({p.name_de})
+
+ ))}
+
+
+
+ sort
+ setCatAdd((a) => ({ ...a, sort_order: e.target.value }))}
+ />
+
+
+ setCatAdd((a) => ({ ...a, required: e.target.checked }))}
+ />
+ Pflicht
+
+
+ ui_group
+ setCatAdd((a) => ({ ...a, ui_group: e.target.value }))}
+ />
+
+
+ Hinzufügen
+
+
+
+ {catLinks.map((l) => (
+
+ {editingCatId === l.id ? (
+
+ ) : (
+
+
+ {l.parameter_key} · {l.parameter_name_de} · sort {l.sort_order}
+ {l.required ? ' · Pflicht' : ''}
+ {l.ui_group ? ` · ${l.ui_group}` : ''}
+
+
+
{
+ setEditingCatId(l.id)
+ setCatDraft({
+ sort_order: l.sort_order,
+ required: !!l.required,
+ ui_group: l.ui_group || '',
+ })
+ }}
+ >
+
+
+
{
+ if (!confirm('Zuordnung entfernen?')) return
+ await api.adminDeleteTrainingCategoryParameter(l.id)
+ await loadCatLinks()
+ showToast('Entfernt')
+ }}
+ >
+
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {tab === 'type' && (
+
+
Zuordnung: Trainingstyp (Zusatz / Override)
+
+ Trainingstyp
+ setSelTypeId(e.target.value)}
+ style={{ maxWidth: 420 }}
+ >
+ {flatTypes.map((t) => (
+
+ {t.id} · {t.name_de} ({t.category})
+
+ ))}
+
+
+
+
+ Parameter
+ setTypeAdd((a) => ({ ...a, training_parameter_id: e.target.value }))}
+ >
+ — wählen —
+ {activeParams.map((p) => (
+
+ {p.id} · {p.key}
+
+ ))}
+
+
+
+ sort (leer=Erben)
+ setTypeAdd((a) => ({ ...a, sort_order: e.target.value }))}
+ />
+
+
+ Pflicht (leer=Erben)
+ setTypeAdd((a) => ({ ...a, required: e.target.value }))}
+ >
+ —
+ ja
+ nein
+
+
+
+ ui_group
+ setTypeAdd((a) => ({ ...a, ui_group: e.target.value }))}
+ />
+
+
+ Hinzufügen
+
+
+
+ {typeLinks.map((l) => (
+
+ {editingTypeId === l.id ? (
+
+
+ {l.parameter_key}
+
+
+ sort
+ setTypeDraft((d) => ({ ...d, sort_order: e.target.value }))}
+ />
+
+
setTypeDraft((d) => ({ ...d, required: e.target.value }))}
+ >
+ Erben
+ ja
+ nein
+
+
setTypeDraft((d) => ({ ...d, ui_group: e.target.value }))}
+ />
+
saveTypeLink(l.id)}>
+ Speichern
+
+
setEditingTypeId(null)}>
+ Abbrechen
+
+
+ ) : (
+
+
+ {l.parameter_key} · sort {l.sort_order ?? '—'} · Pflicht{' '}
+ {l.required === null || l.required === undefined
+ ? '—'
+ : l.required
+ ? 'ja'
+ : 'nein'}
+
+
+
{
+ setEditingTypeId(l.id)
+ setTypeDraft({
+ sort_order: l.sort_order ?? '',
+ required:
+ l.required === null || l.required === undefined
+ ? ''
+ : l.required
+ ? 'true'
+ : 'false',
+ ui_group: l.ui_group || '',
+ })
+ }}
+ >
+
+
+
{
+ if (!confirm('Zuordnung entfernen?')) return
+ await api.adminDeleteTrainingTypeParameter(l.id)
+ await loadTypeLinks()
+ showToast('Entfernt')
+ }}
+ >
+
+
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx
index 90e6fc3..5440e87 100644
--- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx
+++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx
@@ -297,10 +297,19 @@ export default function AdminCsvTemplateEditorPage() {
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
const targetOptions = useMemo(() => {
if (!modMeta?.fields || aggregateSleepImport) return []
- return Object.entries(modMeta.fields).map(([key, meta]) => ({
- value: key,
- label: `${key}${meta.required ? ' *' : ''}`,
- }))
+ const entries = Object.entries(modMeta.fields).map(([key, meta]) => {
+ const title = meta.label_de || meta.name_de || key
+ return {
+ value: key,
+ label: `${title}${meta.required ? ' *' : ''}`,
+ group: meta.from_training_parameter ? 'eav' : 'log',
+ }
+ })
+ entries.sort((a, b) => {
+ if (a.group !== b.group) return a.group === 'log' ? -1 : 1
+ return a.label.localeCompare(b.label, 'de')
+ })
+ return entries
}, [modMeta, aggregateSleepImport])
const requiredTargets = useMemo(() => {
@@ -1025,11 +1034,23 @@ export default function AdminCsvTemplateEditorPage() {
}}
>
— ignorieren
- {targetOptions.map((o) => (
-
- {o.label}
-
- ))}
+ {['log', 'eav'].map((g) => {
+ const opts = targetOptions.filter((o) => o.group === g)
+ if (!opts.length) return null
+ const ogLabel =
+ g === 'log'
+ ? 'Activity — Kernfelder (activity_log)'
+ : 'Trainingsparameter (EAV)'
+ return (
+
+ {opts.map((o) => (
+
+ {o.label}
+
+ ))}
+
+ )
+ })}
))}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index 205acc2..68d0f89 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -138,16 +138,30 @@ export const api = {
deleteCaliper: (id) => req(`/caliper/${id}`,{method:'DELETE'}),
// Activity
- /** @param {number} [limit=200] @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert */
- listActivity: (limit=200, days)=> {
+ /**
+ * @param {number} [limit=200]
+ * @param {number} [days] nur Einträge ab HEUTE−days (Kalendertage), backend-filtert
+ * @param {{ offset?: number, skipQualityFilter?: boolean, month?: string, collapseDuplicateSessions?: boolean }} [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.collapseDuplicateSessions) q.set('collapse_duplicate_sessions', 'true')
+ if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
return req(`/activity?${q}`)
},
createActivity: (d) => req('/activity',json(d)),
updateActivity: (id,d) => req(`/activity/${id}`,jput(d)),
deleteActivity: (id) => req(`/activity/${id}`,{method:'DELETE'}),
- activityStats: () => req('/activity/stats'),
+ /** @param {{ skipQualityFilter?: boolean }} [opts] */
+ activityStats: (opts={}) => {
+ const q = new URLSearchParams()
+ if (opts.skipQualityFilter) q.set('skip_quality_filter', 'true')
+ const qs = q.toString()
+ return req(`/activity/stats${qs ? `?${qs}` : ''}`)
+ },
listUncategorizedActivities: () => req('/activity/uncategorized'),
bulkCategorizeActivities: (d) => req('/activity/bulk-categorize', json(d)),
importActivityCsv: async(file)=>{
@@ -314,6 +328,34 @@ export const api = {
adminDeleteActivityMapping: (id) => req(`/admin/activity-mappings/${id}`, {method:'DELETE'}),
adminGetMappingCoverage: () => req('/admin/activity-mappings/stats/coverage'),
+ // Admin: Training session metrics (EAV) & attribute profiles (Migration 054)
+ adminListTrainingParameters: (includeInactive = false) =>
+ req(`/admin/training-parameters${includeInactive ? '?include_inactive=true' : ''}`),
+ adminCreateTrainingParameter: (d) => req('/admin/training-parameters', json(d)),
+ adminUpdateTrainingParameter: (id, d) => req(`/admin/training-parameters/${id}`, jput(d)),
+ adminDeleteTrainingParameter: (id) =>
+ req(`/admin/training-parameters/${id}`, { method: 'DELETE' }),
+ adminListTrainingCategoryParameters: (category = '') =>
+ req(
+ `/admin/training-category-parameters${category ? `?category=${encodeURIComponent(category)}` : ''}`,
+ ),
+ adminAddTrainingCategoryParameter: (d) => req('/admin/training-category-parameters', json(d)),
+ adminUpdateTrainingCategoryParameter: (id, d) =>
+ req(`/admin/training-category-parameters/${id}`, jput(d)),
+ adminDeleteTrainingCategoryParameter: (id) =>
+ req(`/admin/training-category-parameters/${id}`, { method: 'DELETE' }),
+ adminListTrainingTypeParameters: (trainingTypeId) =>
+ req(`/admin/training-type-parameters?training_type_id=${encodeURIComponent(trainingTypeId)}`),
+ adminAddTrainingTypeParameter: (d) => req('/admin/training-type-parameters', json(d)),
+ adminUpdateTrainingTypeParameter: (id, d) =>
+ req(`/admin/training-type-parameters/${id}`, jput(d)),
+ adminDeleteTrainingTypeParameter: (id) =>
+ req(`/admin/training-type-parameters/${id}`, { method: 'DELETE' }),
+
+ getActivitySession: (id) => req(`/activity/${encodeURIComponent(id)}`),
+ putActivityMetrics: (id, body) =>
+ req(`/activity/${encodeURIComponent(id)}/metrics`, jput(body)),
+
// Sleep Module (v9d Phase 2b)
listSleep: (l=90) => req(`/sleep?limit=${l}`),
getSleepByDate: (date) => req(`/sleep/by-date/${date}`),
diff --git a/scripts/backup/mitai_pg_dump.sh b/scripts/backup/mitai_pg_dump.sh
new file mode 100644
index 0000000..b157b78
--- /dev/null
+++ b/scripts/backup/mitai_pg_dump.sh
@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# Volles PostgreSQL-Backup im Custom-Format (pg_restore-kompatibel).
+# Auf dem Host ausführen, wo der Postgres-Container läuft (z. B. Raspberry Pi mit docker compose).
+#
+# BACKUP_DIR=/path/to/safe/storage ./scripts/backup/mitai_pg_dump.sh
+#
+# Variablen (optional): POSTGRES_CONTAINER, POSTGRES_DB, POSTGRES_USER, BACKUP_DIR
+
+set -euo pipefail
+
+CONTAINER="${POSTGRES_CONTAINER:-mitai-db-prod}"
+DB="${POSTGRES_DB:-mitai_prod}"
+USER="${POSTGRES_USER:-mitai_prod}"
+OUT_DIR="${BACKUP_DIR:-.}"
+STAMP="$(date +%Y%m%d_%H%M%S)"
+mkdir -p "${OUT_DIR}"
+OUT="${OUT_DIR}/${DB}_${STAMP}.dump"
+
+if ! docker inspect "$CONTAINER" &>/dev/null; then
+ echo "Container nicht gefunden: $CONTAINER" >&2
+ exit 1
+fi
+
+docker exec "$CONTAINER" pg_dump -U "$USER" -Fc --no-owner --no-acl "$DB" > "$OUT"
+ls -la "$OUT"
+echo "OK: $OUT (zum Zurückspielen: siehe Kommentar unten in diesem Skript)"
+
+# ── Restore (nur bei Notfall; Backend vorher stoppen, sonst offene Verbindungen) ──
+# docker compose stop backend
+# docker cp "$OUT" "$CONTAINER:/tmp/restore.dump"
+# docker exec "$CONTAINER" pg_restore -U "$USER" -d "$DB" --clean --if-exists --no-owner --no-acl /tmp/restore.dump
+# docker compose start backend
+#
+# Hinweis: --clean entfernt Objekte vor dem Wiederherstellen; kurze Unterbrechung der DB.
+# Für „nur lesen“ Backup reicht die .dump-Datei auf externem Medium zu kopieren.