Add trend charts and calendar-week averages to History page

Adds Recharts-based calorie and macro trend charts plus real
calendar-week (Mon-Sun) averages, replacing the old rolling
7-day average. Colors were chosen and validated with the dataviz
skill's contrast/CVD checks separately for light and dark mode.

Fixes a timezone bug where Date.toISOString() shifted week
boundaries by one day in UTC+ timezones, and two Recharts pitfalls
(negative chart margin hiding the Y-axis, mount animation stuck
invisible in headless browsers).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 13:36:38 +02:00
co-authored by Claude Sonnet 5
parent 2f95eeda83
commit 5196ff0194
8 changed files with 685 additions and 24 deletions
@@ -0,0 +1,65 @@
import { Bar, BarChart, CartesianGrid, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
const BAR_COLOR = '#059669'
function formatShortDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-md dark:border-gray-700 dark:bg-gray-800">
<p className="mb-1 text-xs text-gray-500 dark:text-gray-400">{formatShortDate(label)}</p>
<p className="text-sm font-semibold text-gray-900 dark:text-gray-50">
{Math.round(payload[0].value)} kcal
</p>
</div>
)
}
export default function CalorieTrendChart({ days, calorieGoal, theme }) {
const data = days.map((day) => ({ date: day.date, calories: day.totals.calories }))
const gridColor = theme === 'dark' ? '#374151' : '#e5e7eb'
const tickColor = theme === 'dark' ? '#9ca3af' : '#6b7280'
return (
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-50">Kalorien-Trend</h2>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid stroke={gridColor} strokeWidth={1} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fill: tickColor, fontSize: 11 }}
stroke={gridColor}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis tick={{ fill: tickColor, fontSize: 11 }} stroke={gridColor} width={40} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: gridColor, opacity: 0.4 }} />
{calorieGoal > 0 && (
<ReferenceLine
y={calorieGoal}
stroke="#9ca3af"
strokeDasharray="4 4"
strokeWidth={1.5}
label={{ value: 'Ziel', position: 'insideTopRight', fill: tickColor, fontSize: 11 }}
/>
)}
<Bar
dataKey="calories"
fill={BAR_COLOR}
radius={[4, 4, 0, 0]}
maxBarSize={20}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)
}
@@ -0,0 +1,95 @@
import { CartesianGrid, Legend, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
const SERIES = [
{ key: 'protein', label: 'Protein', light: '#2563eb', dark: '#3b82f6' },
{ key: 'carbs', label: 'Carbs', light: '#d97706', dark: '#b45309' },
{ key: 'fat', label: 'Fett', light: '#e11d48', dark: '#f43f5e' },
]
function formatShortDate(dateStr) {
const d = new Date(`${dateStr}T00:00:00`)
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
function CustomLegend({ payload }) {
return (
<div className="mt-1 flex justify-center gap-4">
{payload.map((entry) => (
<div key={entry.value} className="flex items-center gap-1.5">
<span className="h-0.5 w-4 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-xs text-gray-600 dark:text-gray-300">{entry.value}</span>
</div>
))}
</div>
)
}
function CustomTooltip({ active, payload, label }) {
if (!active || !payload?.length) return null
return (
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2 shadow-md dark:border-gray-700 dark:bg-gray-800">
<p className="mb-1 text-xs text-gray-500 dark:text-gray-400">{formatShortDate(label)}</p>
<div className="space-y-0.5">
{payload.map((entry) => (
<div key={entry.dataKey} className="flex items-center gap-1.5 text-sm">
<span className="h-0.5 w-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="font-semibold text-gray-900 dark:text-gray-50">{Math.round(entry.value)}g</span>
<span className="text-xs text-gray-500 dark:text-gray-400">{entry.name}</span>
</div>
))}
</div>
</div>
)
}
export default function MacroTrendChart({ days, theme }) {
const data = days.map((day) => ({
date: day.date,
protein: day.totals.protein,
carbs: day.totals.carbs,
fat: day.totals.fat,
}))
const gridColor = theme === 'dark' ? '#374151' : '#e5e7eb'
const tickColor = theme === 'dark' ? '#9ca3af' : '#6b7280'
const surfaceColor = theme === 'dark' ? '#1f2937' : '#ffffff'
return (
<div className="rounded-xl bg-white p-4 shadow-sm dark:bg-gray-800">
<h2 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-50">Makro-Trend</h2>
<div className="h-48 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 4, right: 4, left: 0, bottom: 0 }}>
<CartesianGrid stroke={gridColor} strokeWidth={1} vertical={false} />
<XAxis
dataKey="date"
tickFormatter={formatShortDate}
tick={{ fill: tickColor, fontSize: 11 }}
stroke={gridColor}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis tick={{ fill: tickColor, fontSize: 11 }} stroke={gridColor} width={40} />
<Tooltip content={<CustomTooltip />} cursor={{ stroke: gridColor, strokeWidth: 1 }} />
<Legend content={<CustomLegend />} />
{SERIES.map((series) => {
const color = theme === 'dark' ? series.dark : series.light
return (
<Line
key={series.key}
type="monotone"
dataKey={series.key}
name={series.label}
stroke={color}
strokeWidth={2}
dot={{ r: 4, strokeWidth: 2, stroke: surfaceColor, fill: color }}
activeDot={{ r: 5, strokeWidth: 2, stroke: surfaceColor, fill: color }}
isAnimationActive={false}
/>
)
})}
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}