Goalsystem V1 #50
|
|
@ -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/>}/>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
370
frontend/src/pages/CustomGoalsPage.jsx
Normal file
370
frontend/src/pages/CustomGoalsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user