Goalsystem V1 #50

Merged
Lars merged 51 commits from develop into main 2026-03-27 17:40:51 +01:00
4 changed files with 420 additions and 13 deletions
Showing only changes of commit bcb867da69 - Show all commits

View File

@ -37,6 +37,7 @@ import SleepPage from './pages/SleepPage'
import RestDaysPage from './pages/RestDaysPage'
import VitalsPage from './pages/VitalsPage'
import GoalsPage from './pages/GoalsPage'
import CustomGoalsPage from './pages/CustomGoalsPage'
import './app.css'
function Nav() {
@ -175,6 +176,7 @@ function AppShell() {
<Route path="/rest-days" element={<RestDaysPage/>}/>
<Route path="/vitals" element={<VitalsPage/>}/>
<Route path="/goals" element={<GoalsPage/>}/>
<Route path="/custom-goals" element={<CustomGoalsPage/>}/>
<Route path="/nutrition" element={<NutritionPage/>}/>
<Route path="/activity" element={<ActivityPage/>}/>
<Route path="/analysis" element={<Analysis/>}/>

View File

@ -66,6 +66,13 @@ const ENTRIES = [
to: '/vitals',
color: '#E74C3C',
},
{
icon: '🎯',
label: 'Eigene Ziele',
sub: 'Fortschritte für individuelle Ziele erfassen',
to: '/custom-goals',
color: '#1D9E75',
},
{
icon: '📖',
label: 'Messanleitung',

View File

@ -0,0 +1,370 @@
import { useState, useEffect } from 'react'
import { api } from '../utils/api'
import { Target, TrendingUp, Calendar, CheckCircle2, AlertCircle } from 'lucide-react'
import dayjs from 'dayjs'
export default function CustomGoalsPage() {
const [customGoals, setCustomGoals] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedGoal, setSelectedGoal] = useState(null)
const [formData, setFormData] = useState({
date: new Date().toISOString().split('T')[0],
value: '',
note: ''
})
const [recentProgress, setRecentProgress] = useState([])
useEffect(() => {
loadCustomGoals()
}, [])
const loadCustomGoals = async () => {
try {
setLoading(true)
const grouped = await api.listGoalsGrouped()
// Extract all goals and filter for custom only (no source_table)
const allGoals = Object.values(grouped).flat()
const custom = allGoals.filter(g => !g.source_table)
setCustomGoals(custom)
setError(null)
} catch (err) {
console.error('Failed to load custom goals:', err)
setError(err.message || 'Fehler beim Laden')
} finally {
setLoading(false)
}
}
const loadRecentProgress = async (goalId) => {
try {
const entries = await api.listGoalProgress(goalId)
setRecentProgress(entries.slice(0, 5)) // Last 5 entries
} catch (err) {
console.error('Failed to load progress:', err)
setRecentProgress([])
}
}
const handleSelectGoal = async (goal) => {
setSelectedGoal(goal)
setFormData({
date: new Date().toISOString().split('T')[0],
value: goal.current_value || '',
note: ''
})
await loadRecentProgress(goal.id)
}
const handleSaveProgress = async () => {
if (!formData.value || !formData.date) {
setError('Bitte Datum und Wert eingeben')
return
}
try {
const data = {
date: formData.date,
value: parseFloat(formData.value),
note: formData.note || null
}
await api.createGoalProgress(selectedGoal.id, data)
// Reset form and reload
setFormData({
date: new Date().toISOString().split('T')[0],
value: '',
note: ''
})
await loadCustomGoals()
await loadRecentProgress(selectedGoal.id)
// Update selected goal with new current_value
const updated = customGoals.find(g => g.id === selectedGoal.id)
if (updated) setSelectedGoal(updated)
setError(null)
} catch (err) {
console.error('Failed to save progress:', err)
setError(err.message || 'Fehler beim Speichern')
}
}
const getProgressPercentage = (goal) => {
if (!goal.current_value || !goal.target_value) return 0
const current = parseFloat(goal.current_value)
const target = parseFloat(goal.target_value)
const start = parseFloat(goal.start_value) || 0
if (goal.direction === 'decrease') {
return Math.min(100, Math.max(0, ((start - current) / (start - target)) * 100))
} else {
return Math.min(100, Math.max(0, ((current - start) / (target - start)) * 100))
}
}
if (loading) {
return (
<div style={{ padding: 20, textAlign: 'center' }}>
<div className="spinner" />
</div>
)
}
return (
<div style={{ paddingBottom: 80 }}>
{/* Header */}
<div style={{
background: 'linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%)',
color: 'white',
padding: '24px 16px',
marginBottom: 16
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Target size={28} />
<h1 style={{ fontSize: 24, fontWeight: 700, margin: 0 }}>Eigene Ziele</h1>
</div>
<div style={{ fontSize: 14, opacity: 0.9 }}>
Erfasse Fortschritte für deine individuellen Ziele
</div>
</div>
{error && (
<div style={{
margin: '0 16px 16px',
padding: 12,
background: '#FEE2E2',
color: '#991B1B',
borderRadius: 8,
fontSize: 14
}}>
{error}
</div>
)}
{customGoals.length === 0 ? (
<div className="card" style={{ margin: 16, textAlign: 'center', padding: 40 }}>
<Target size={48} style={{ color: 'var(--text3)', margin: '0 auto 16px' }} />
<div style={{ fontSize: 16, color: 'var(--text2)', marginBottom: 8 }}>
Keine eigenen Ziele vorhanden
</div>
<div style={{ fontSize: 14, color: 'var(--text3)' }}>
Erstelle eigene Ziele über die Ziele-Seite in der Analyse
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, padding: 16 }}>
{/* Goal Selection */}
<div className="card">
<h2 style={{ fontSize: 16, marginBottom: 12, fontWeight: 600 }}>
Ziel auswählen ({customGoals.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{customGoals.map(goal => {
const progress = getProgressPercentage(goal)
const isSelected = selectedGoal?.id === goal.id
return (
<button
key={goal.id}
onClick={() => handleSelectGoal(goal)}
style={{
width: '100%',
padding: 12,
background: isSelected ? 'var(--accent)' : 'var(--surface2)',
color: isSelected ? 'white' : 'var(--text1)',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontWeight: 600 }}>
{goal.name || goal.label_de || goal.goal_type}
</span>
{goal.current_value && (
<span style={{
fontSize: 18,
fontWeight: 700,
opacity: isSelected ? 1 : 0.8
}}>
{goal.current_value} {goal.unit}
</span>
)}
</div>
{goal.target_value && (
<>
<div style={{ fontSize: 13, opacity: isSelected ? 0.9 : 0.7, marginBottom: 6 }}>
Ziel: {goal.target_value} {goal.unit}
</div>
<div style={{
width: '100%',
height: 6,
background: isSelected ? 'rgba(255,255,255,0.2)' : 'var(--surface)',
borderRadius: 3,
overflow: 'hidden'
}}>
<div style={{
width: `${progress}%`,
height: '100%',
background: isSelected ? 'white' : 'var(--accent)',
transition: 'width 0.3s'
}} />
</div>
</>
)}
</button>
)
})}
</div>
</div>
{/* Progress Entry Form */}
{selectedGoal && (
<div className="card">
<h2 style={{ fontSize: 16, marginBottom: 16, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
<TrendingUp size={20} style={{ color: 'var(--accent)' }} />
Fortschritt erfassen
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Datum
</div>
<input
type="date"
className="form-input"
value={formData.date}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
max={new Date().toISOString().split('T')[0]}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Wert ({selectedGoal.unit})
</div>
<input
type="number"
step="0.01"
className="form-input"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
placeholder={`Aktueller Wert in ${selectedGoal.unit}`}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Notiz (optional)
</div>
<textarea
className="form-input"
value={formData.note}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
placeholder="Optionale Notiz zu dieser Messung..."
rows={2}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<button
className="btn-primary"
onClick={handleSaveProgress}
disabled={!formData.value}
style={{ width: '100%' }}
>
<CheckCircle2 size={18} style={{ marginRight: 8 }} />
Wert speichern
</button>
</div>
</div>
)}
{/* Recent Progress */}
{selectedGoal && recentProgress.length > 0 && (
<div className="card">
<h2 style={{ fontSize: 16, marginBottom: 12, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8 }}>
<Calendar size={20} style={{ color: 'var(--accent)' }} />
Letzte Einträge
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{recentProgress.map(entry => (
<div key={entry.id} style={{
padding: 12,
background: 'var(--surface2)',
borderRadius: 8
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 14, color: 'var(--text2)' }}>
{dayjs(entry.date).format('DD.MM.YYYY')}
</span>
<span style={{ fontSize: 18, fontWeight: 600, color: 'var(--accent)' }}>
{entry.value} {selectedGoal.unit}
</span>
</div>
{entry.note && (
<div style={{ fontSize: 13, color: 'var(--text3)' }}>
{entry.note}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Help Card */}
{!selectedGoal && (
<div style={{
padding: 16,
background: 'var(--surface2)',
borderRadius: 8,
border: '1px solid var(--border)'
}}>
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start' }}>
<AlertCircle size={20} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
<div style={{ fontSize: 14, color: 'var(--text2)' }}>
<div style={{ fontWeight: 600, marginBottom: 4, color: 'var(--text1)' }}>
Eigene Ziele erfassen
</div>
Wähle ein Ziel aus und erfasse regelmäßig deine Fortschritte.
Die Werte werden automatisch in deine Zielverfolgung übernommen.
</div>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -704,14 +704,17 @@ export default function GoalsPage() {
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button
className="btn-secondary"
onClick={() => handleOpenProgressModal(goal)}
style={{ padding: '6px 12px' }}
title="Fortschritt erfassen"
>
<TrendingUp size={14} />
</button>
{/* Progress button only for custom goals (no automatic data source) */}
{!goal.source_table && (
<button
className="btn-secondary"
onClick={() => handleOpenProgressModal(goal)}
style={{ padding: '6px 12px' }}
title="Fortschritt erfassen"
>
<TrendingUp size={14} />
</button>
)}
<button
className="btn-secondary"
onClick={() => handleEditGoal(goal)}
@ -1051,22 +1054,37 @@ export default function GoalsPage() {
{/* Progress Form */}
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: 16, marginBottom: 12 }}>Neuer Eintrag</h3>
<h3 style={{ fontSize: 16, marginBottom: 16 }}>Neuer Eintrag</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
<label className="form-label">Datum</label>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Datum
</div>
<input
type="date"
className="form-input"
value={progressFormData.date}
onChange={(e) => setProgressFormData({ ...progressFormData, date: e.target.value })}
max={new Date().toISOString().split('T')[0]}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Wert ({progressGoal.unit})</label>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Wert ({progressGoal.unit})
</div>
<input
type="number"
step="0.01"
@ -1074,17 +1092,26 @@ export default function GoalsPage() {
value={progressFormData.value}
onChange={(e) => setProgressFormData({ ...progressFormData, value: e.target.value })}
placeholder={`z.B. ${progressGoal.current_value || progressGoal.target_value}`}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
<div>
<label className="form-label">Notiz (optional)</label>
<div style={{
fontSize: 14,
fontWeight: 600,
marginBottom: 8,
color: 'var(--text1)'
}}>
Notiz (optional)
</div>
<textarea
className="form-input"
value={progressFormData.note}
onChange={(e) => setProgressFormData({ ...progressFormData, note: e.target.value })}
placeholder="Optionale Notiz..."
rows={2}
style={{ width: '100%', textAlign: 'left' }}
/>
</div>
@ -1092,6 +1119,7 @@ export default function GoalsPage() {
className="btn-primary"
onClick={handleSaveProgress}
disabled={!progressFormData.value}
style={{ width: '100%' }}
>
Eintrag speichern
</button>