From 57d2a77648d4d5db6951564930b0ce24690a635d Mon Sep 17 00:00:00 2001 From: Seth Leavitt Date: Mon, 9 Feb 2026 10:20:50 -0700 Subject: [PATCH 1/3] Implement EMA and enhance WeightChart visualization Add EMA calculation and update WeightChart component --- .../BodyWeight/WeightChart/index.tsx | 234 +++++++++++++++--- 1 file changed, 206 insertions(+), 28 deletions(-) diff --git a/src/components/BodyWeight/WeightChart/index.tsx b/src/components/BodyWeight/WeightChart/index.tsx index b94063f2..23da7f42 100644 --- a/src/components/BodyWeight/WeightChart/index.tsx +++ b/src/components/BodyWeight/WeightChart/index.tsx @@ -4,7 +4,7 @@ import { WeightEntry } from "components/BodyWeight/model"; import { WgerModal } from "components/Core/Modals/WgerModal"; import React from 'react'; import { useTranslation } from "react-i18next"; -import { CartesianGrid, DotProps, Line, LineChart, Tooltip, XAxis, YAxis } from 'recharts'; +import { CartesianGrid, Line, LineChart, ReferenceLine, Tooltip, XAxis, YAxis, Legend } from 'recharts'; import { dateToLocale } from "utils/date"; export interface WeightChartProps { @@ -19,14 +19,56 @@ export interface TooltipProps { label?: string, } +/** + * Calculate exponentially weighted moving average (EMA) + * Using the Hacker's Diet approach with approximately 10% smoothing + */ +const calculateEMA = (weights: { date: number, weight: number }[], period: number = 10) => { + if (weights.length === 0) return []; + + // Smoothing factor: 2 / (period + 1) + // For period=10, this gives us ~0.18 (10% smoothing as in Hacker's Diet) + const smoothing = 2 / (period + 1); + + const emaData: { date: number, weight: number, ema: number }[] = []; + let ema = weights[0].weight; // Start with first measurement + + for (let i = 0; i < weights.length; i++) { + if (i === 0) { + ema = weights[i].weight; + } else { + // EMA = (Current Value × Smoothing) + (Previous EMA × (1 - Smoothing)) + ema = weights[i].weight * smoothing + ema * (1 - smoothing); + } + + emaData.push({ + date: weights[i].date, + weight: weights[i].weight, + ema: ema + }); + } + + return emaData; +}; + const CustomTooltip = ({ active, payload, label }: TooltipProps) => { const [t] = useTranslation(); if (active && payload && payload.length) { + const actualWeight = payload.find((p: any) => p.dataKey === 'weight'); + const trendWeight = payload.find((p: any) => p.dataKey === 'ema'); + const variance = actualWeight && trendWeight ? actualWeight.value - trendWeight.value : 0; + return (

{dateToLocale(new Date(label!))}

-

{t('weight')}: {payload[0].value}

+ {actualWeight &&

{t('weight')}: {actualWeight.value.toFixed(1)}

} + {trendWeight &&

Trend: {trendWeight.value.toFixed(1)}

} + {actualWeight && trendWeight && ( +

0 ? '#ff6b6b' : '#51cf66' }}> + Variance: {variance > 0 ? '+' : ''}{variance.toFixed(1)} +

+ )}
); } @@ -34,9 +76,48 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps) => { return null; }; +/** + * Custom shape to render vertical lines from each point to the trendline + */ +const VarianceLine = (props: any) => { + const { points, emaData } = props; + + if (!points || points.length === 0 || !emaData) return null; + + return ( + + {points.map((point: any, index: number) => { + if (!point || !emaData[index]) return null; + + const emaPoint = emaData[index]; + const x = point.x; + const y1 = point.y; // Actual weight y-coordinate + + // Find the corresponding EMA y-coordinate + // We need to calculate it based on the chart's scale + const yScale = point.y / point.payload.weight; + const y2 = emaPoint.ema * yScale; // EMA weight y-coordinate + + return ( + emaPoint.ema ? '#ff6b6b' : '#51cf66'} + strokeWidth={1} + strokeDasharray="2,2" + opacity={0.5} + /> + ); + })} + + ); +}; + export const WeightChart = ({ weights, height }: WeightChartProps) => { - const NR_OF_WEIGHTS_CHART_DOT = 30; height = height || 300; const theme = useTheme(); @@ -45,25 +126,75 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { const [currentEntry, setCurrentEntry] = React.useState(); const handleCloseModal = () => setIsModalOpen(false); - // map the list of weights to an array of objects with the date and weight - const weightData = [...weights].sort((a, b) => a.date.getTime() - b.date.getTime()).map(weight => { - return { - date: weight.date.getTime(), - weight: weight.weight, - entry: weight - }; - }); + // Sort and map the weights + const sortedWeights = [...weights].sort((a, b) => a.date.getTime() - b.date.getTime()); + const weightData = sortedWeights.map(weight => ({ + date: weight.date.getTime(), + weight: weight.weight, + entry: weight + })); + + // Calculate EMA (exponentially weighted moving average) + const emaData = calculateEMA(weightData, 10); + + // Calculate mean weight + const meanWeight = weightData.length > 0 + ? weightData.reduce((sum, w) => sum + w.weight, 0) / weightData.length + : 0; + + // Get current trend (latest EMA value) + const currentTrend = emaData.length > 0 ? emaData[emaData.length - 1].ema : 0; /* * Edit the currently selected weight */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function handleClick(e: DotProps, data: any) { - setCurrentEntry(data.payload.entry); + function handleClick(data: any) { + setCurrentEntry(data.entry); setIsModalOpen(true); } + // Custom dot component that also renders variance lines + const CustomDot = (props: any) => { + const { cx, cy, payload, index } = props; + + if (!payload || !emaData[index]) return null; + + const emaPoint = emaData[index]; + const variance = payload.weight - emaPoint.ema; + + // Calculate EMA point y-coordinate on the same scale + const yScale = (props.yAxis.scale as any); + const emaY = yScale(emaPoint.ema); + + return ( + + {/* Variance line from dot to trend */} + 0 ? '#ff6b6b' : '#51cf66'} + strokeWidth={1} + strokeDasharray="2,2" + opacity={0.5} + /> + {/* The actual dot */} + handleClick(payload)} + /> + + ); + }; + return (
{ @@ -72,19 +203,7 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { } - - NR_OF_WEIGHTS_CHART_DOT ? false : { strokeWidth: 1, r: 4 }} - activeDot={{ - stroke: 'black', - strokeWidth: 1, - r: 6, - onClick: handleClick - }} /> + @@ -95,8 +214,67 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { tickFormatter={timeStr => dateToLocale(new Date(timeStr))} /> + + {/* Mean weight reference line */} + + + {/* Current trend annotation */} + + + {/* Trend line (EMA) - smooth line */} + + + {/* Individual weight measurements - dots with variance lines */} + } + activeDot={{ + stroke: 'black', + strokeWidth: 2, + r: 6, + }} + name="Weight" + legendType="circle" + /> + } /> +
); -}; \ No newline at end of file +}; From 874dff3aed03575b193cb8e75aa29be1b255cce5 Mon Sep 17 00:00:00 2001 From: Seth Leavitt Date: Mon, 9 Feb 2026 10:27:07 -0700 Subject: [PATCH 2/3] Refactor WeightChart to use typed data structures --- .../BodyWeight/WeightChart/index.tsx | 130 ++++++++++-------- 1 file changed, 73 insertions(+), 57 deletions(-) diff --git a/src/components/BodyWeight/WeightChart/index.tsx b/src/components/BodyWeight/WeightChart/index.tsx index 23da7f42..131ff850 100644 --- a/src/components/BodyWeight/WeightChart/index.tsx +++ b/src/components/BodyWeight/WeightChart/index.tsx @@ -4,7 +4,7 @@ import { WeightEntry } from "components/BodyWeight/model"; import { WgerModal } from "components/Core/Modals/WgerModal"; import React from 'react'; import { useTranslation } from "react-i18next"; -import { CartesianGrid, Line, LineChart, ReferenceLine, Tooltip, XAxis, YAxis, Legend } from 'recharts'; +import { CartesianGrid, DotProps, Line, LineChart, ReferenceLine, Tooltip, XAxis, YAxis, Legend } from 'recharts'; import { dateToLocale } from "utils/date"; export interface WeightChartProps { @@ -19,18 +19,30 @@ export interface TooltipProps { label?: string, } +interface WeightDataPoint { + date: number; + weight: number; + entry: WeightEntry; +} + +interface EMADataPoint { + date: number; + weight: number; + ema: number; +} + /** * Calculate exponentially weighted moving average (EMA) * Using the Hacker's Diet approach with approximately 10% smoothing */ -const calculateEMA = (weights: { date: number, weight: number }[], period: number = 10) => { +const calculateEMA = (weights: WeightDataPoint[], period: number = 10): EMADataPoint[] => { if (weights.length === 0) return []; // Smoothing factor: 2 / (period + 1) // For period=10, this gives us ~0.18 (10% smoothing as in Hacker's Diet) const smoothing = 2 / (period + 1); - const emaData: { date: number, weight: number, ema: number }[] = []; + const emaData: EMADataPoint[] = []; let ema = weights[0].weight; // Start with first measurement for (let i = 0; i < weights.length; i++) { @@ -55,7 +67,9 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps) => { const [t] = useTranslation(); if (active && payload && payload.length) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any const actualWeight = payload.find((p: any) => p.dataKey === 'weight'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any const trendWeight = payload.find((p: any) => p.dataKey === 'ema'); const variance = actualWeight && trendWeight ? actualWeight.value - trendWeight.value : 0; @@ -76,45 +90,15 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps) => { return null; }; -/** - * Custom shape to render vertical lines from each point to the trendline - */ -const VarianceLine = (props: any) => { - const { points, emaData } = props; - - if (!points || points.length === 0 || !emaData) return null; - - return ( - - {points.map((point: any, index: number) => { - if (!point || !emaData[index]) return null; - - const emaPoint = emaData[index]; - const x = point.x; - const y1 = point.y; // Actual weight y-coordinate - - // Find the corresponding EMA y-coordinate - // We need to calculate it based on the chart's scale - const yScale = point.y / point.payload.weight; - const y2 = emaPoint.ema * yScale; // EMA weight y-coordinate - - return ( - emaPoint.ema ? '#ff6b6b' : '#51cf66'} - strokeWidth={1} - strokeDasharray="2,2" - opacity={0.5} - /> - ); - })} - - ); -}; +interface CustomDotProps extends DotProps { + cx?: number; + cy?: number; + payload?: EMADataPoint; + emaData: EMADataPoint[]; + yAxisDomain: [number, number]; + chartHeight: number; + onClick: (payload: EMADataPoint) => void; +} export const WeightChart = ({ weights, height }: WeightChartProps) => { @@ -128,7 +112,7 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { // Sort and map the weights const sortedWeights = [...weights].sort((a, b) => a.date.getTime() - b.date.getTime()); - const weightData = sortedWeights.map(weight => ({ + const weightData: WeightDataPoint[] = sortedWeights.map(weight => ({ date: weight.date.getTime(), weight: weight.weight, entry: weight @@ -145,27 +129,51 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { // Get current trend (latest EMA value) const currentTrend = emaData.length > 0 ? emaData[emaData.length - 1].ema : 0; + // Calculate Y-axis domain for proper variance line positioning + const allWeights = emaData.flatMap(d => [d.weight, d.ema]); + const minWeight = Math.min(...allWeights); + const maxWeight = Math.max(...allWeights); + const padding = (maxWeight - minWeight) * 0.1; // 10% padding + const yAxisDomain: [number, number] = [minWeight - padding, maxWeight + padding]; + /* * Edit the currently selected weight */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function handleClick(data: any) { - setCurrentEntry(data.entry); - setIsModalOpen(true); + function handleClick(payload: EMADataPoint) { + const entry = weights.find(w => w.date.getTime() === payload.date); + if (entry) { + setCurrentEntry(entry); + setIsModalOpen(true); + } } // Custom dot component that also renders variance lines - const CustomDot = (props: any) => { - const { cx, cy, payload, index } = props; + const CustomDot = (props: CustomDotProps) => { + const { cx, cy, payload, emaData, yAxisDomain, chartHeight, onClick } = props; - if (!payload || !emaData[index]) return null; + if (cx === undefined || cy === undefined || !payload) { + return null; + } + + // Find the EMA value for this data point + const emaPoint = emaData.find(d => d.date === payload.date); + if (!emaPoint) { + return null; + } - const emaPoint = emaData[index]; const variance = payload.weight - emaPoint.ema; - // Calculate EMA point y-coordinate on the same scale - const yScale = (props.yAxis.scale as any); - const emaY = yScale(emaPoint.ema); + // Calculate the y-coordinate for the EMA point + // Map the EMA value to the chart coordinate system + const [minY, maxY] = yAxisDomain; + const valueRange = maxY - minY; + const normalizedEma = (emaPoint.ema - minY) / valueRange; + + // In SVG, y=0 is at the top, so we need to invert + // Account for chart margins (approximately 5 pixels top and bottom) + const margin = 5; + const effectiveHeight = chartHeight - (2 * margin); + const emaY = margin + effectiveHeight * (1 - normalizedEma); return ( @@ -189,7 +197,7 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { stroke={theme.palette.secondary.dark} strokeWidth={1} style={{ cursor: 'pointer' }} - onClick={() => handleClick(payload)} + onClick={() => onClick(payload)} /> ); @@ -213,7 +221,7 @@ export const WeightChart = ({ weights, height }: WeightChartProps) => { domain={['dataMin', 'dataMax']} tickFormatter={timeStr => dateToLocale(new Date(timeStr))} /> - + {/* Mean weight reference line */} { dataKey="weight" stroke="transparent" strokeWidth={0} - dot={} + dot={(props: DotProps) => ( + + )} activeDot={{ stroke: 'black', strokeWidth: 2, From 595f331dd2ae21e383a261fa52a78b22a2e0056e Mon Sep 17 00:00:00 2001 From: Seth Leavitt Date: Mon, 9 Feb 2026 10:45:51 -0700 Subject: [PATCH 3/3] Refactor WeightChart tests for clarity and coverage --- .../BodyWeight/WeightChart/index.test.tsx | 243 +++++++++++++++++- 1 file changed, 232 insertions(+), 11 deletions(-) diff --git a/src/components/BodyWeight/WeightChart/index.test.tsx b/src/components/BodyWeight/WeightChart/index.test.tsx index 25f616da..1e37f8c1 100644 --- a/src/components/BodyWeight/WeightChart/index.test.tsx +++ b/src/components/BodyWeight/WeightChart/index.test.tsx @@ -1,5 +1,5 @@ import { QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { WeightEntry } from "components/BodyWeight/model"; import React from 'react'; import { testQueryClient } from "tests/queryClient"; @@ -24,9 +24,8 @@ afterEach(() => { }); -describe("Test BodyWeight component", () => { - test('renders without crashing', async () => { - +describe("Test WeightChart component", () => { + test('renders without crashing with weight data', async () => { // Arrange const weightData = [ new WeightEntry(new Date('2021-12-10'), 80, 1), @@ -40,11 +39,30 @@ describe("Test BodyWeight component", () => { ); - // Renders without crashing + // Assert - Component renders without crashing + expect(screen.getByRole('img', { hidden: true })).toBeInTheDocument(); // LineChart renders as SVG }); - test('errors get handled', () => { + test('renders with custom height prop', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-10'), 80, 1), + new WeightEntry(new Date('2021-12-20'), 85, 2), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Check that chart is rendered with custom height + const chart = container.querySelector('.recharts-wrapper'); + expect(chart).toBeInTheDocument(); + }); + test('handles empty weight data gracefully', () => { // Arrange const weightData: WeightEntry[] = []; @@ -55,12 +73,215 @@ describe("Test BodyWeight component", () => { ); + // Assert - No crash, chart still renders + expect(screen.queryByRole('img', { hidden: true })).toBeInTheDocument(); + }); + + test('calculates and displays EMA trend line', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-05'), 82, 2), + new WeightEntry(new Date('2021-12-10'), 81, 3), + new WeightEntry(new Date('2021-12-15'), 83, 4), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Check for trend line in legend + expect(screen.getByText('Trend')).toBeInTheDocument(); + }); + + test('displays mean weight reference line', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-10'), 90, 2), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Mean should be 85 (80 + 90) / 2 + expect(container.textContent).toContain('Mean'); + }); + + test('displays current trend reference line', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-10'), 85, 2), + ]; + + // Act + const { container } = render( + + + + ); + // Assert - // No weights are found in the document - const linkElement = screen.queryByText("80"); - expect(linkElement).toBeNull(); + expect(container.textContent).toContain('Current Trend'); + }); + + test('renders weight and trend in legend', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-10'), 85, 2), + ]; + + // Act + render( + + + + ); + + // Assert - Check legend items + expect(screen.getByText('Weight')).toBeInTheDocument(); + expect(screen.getByText('Trend')).toBeInTheDocument(); + }); + + test('sorts weight data by date', () => { + // Arrange - Weights provided out of order + const weightData = [ + new WeightEntry(new Date('2021-12-20'), 90, 2), + new WeightEntry(new Date('2021-12-10'), 80, 1), + new WeightEntry(new Date('2021-12-15'), 85, 3), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Should render without errors (sorting happens internally) + expect(container.querySelector('.recharts-wrapper')).toBeInTheDocument(); + }); + + test('handles single weight entry', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-10'), 80, 1), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Should render without errors + expect(container.querySelector('.recharts-wrapper')).toBeInTheDocument(); + expect(screen.getByText('Weight')).toBeInTheDocument(); + }); + + test('custom dot renders with variance line', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-10'), 85, 2), + new WeightEntry(new Date('2021-12-20'), 82, 3), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - Check for circles (custom dots) in the SVG + const circles = container.querySelectorAll('circle'); + expect(circles.length).toBeGreaterThan(0); + + // Check for variance lines (dashed lines) + const lines = container.querySelectorAll('line[stroke-dasharray="2,2"]'); + expect(lines.length).toBeGreaterThan(0); + }); + + test('opens modal when clicking on weight dot', async () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-10'), 80, 1), + ]; + + // Act + const { container } = render( + + + + ); + + // Click on a weight dot (circle element) + const circles = container.querySelectorAll('circle'); + const weightDot = Array.from(circles).find(circle => + circle.getAttribute('style')?.includes('cursor: pointer') + ); + + if (weightDot) { + fireEvent.click(weightDot); + } + + // Assert - Modal should open (check for "edit" in the document) + await waitFor(() => { + // The modal title uses translation key 'edit' + expect(container.textContent).toContain('edit'); + }); + }); + + test('calculates EMA correctly with multiple data points', () => { + // Arrange - Data where we can verify EMA calculation + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 100, 1), + new WeightEntry(new Date('2021-12-02'), 100, 2), + new WeightEntry(new Date('2021-12-03'), 100, 3), + ]; + + // Act + const { container } = render( + + + + ); + + // Assert - With constant weight, trend should converge to that weight + expect(container.textContent).toContain('Current Trend: 100.0'); + }); + + test('applies correct variance coloring (positive and negative)', () => { + // Arrange + const weightData = [ + new WeightEntry(new Date('2021-12-01'), 80, 1), + new WeightEntry(new Date('2021-12-10'), 85, 2), + new WeightEntry(new Date('2021-12-20'), 78, 3), + ]; + + // Act + const { container } = render( + + + + ); - const linkElement2 = screen.queryByText("90"); - expect(linkElement2).toBeNull(); + // Assert - Check for variance lines with different colors + const redLines = container.querySelectorAll('line[stroke="#ff6b6b"]'); // Positive variance + const greenLines = container.querySelectorAll('line[stroke="#51cf66"]'); // Negative variance + + // Should have both positive and negative variance lines + expect(redLines.length + greenLines.length).toBeGreaterThan(0); }); });