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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user