+ ))}
+
+ );
+};
+
+/**
+ * Colour of a series. Components are coloured by their position so a legend
+ * entry and its line match; the other roles have a fixed colour each.
+ */
+const seriesColor = (theme: Theme, role: ChartSeriesRole, componentIndex: number, palette: string[]): string => {
+ switch (role) {
+ case 'raw':
+ return theme.palette.primary.main;
+ case 'average':
+ return theme.palette.info.main;
+ case 'trend':
+ return theme.palette.secondary.main;
+ case 'component':
+ return palette[componentIndex % palette.length];
+ }
+};
+
+/** How a series is drawn follows from its role, never from the series itself */
+const lineProps = (role: ChartSeriesRole, color: string, showDots: boolean) => {
+ switch (role) {
+ case 'raw':
+ // Dots only: a line would assert that something was measured
+ // between two readings
+ return {
+ stroke: 'transparent',
+ dot: showDots ? { fill: color, r: 3 } : false as const,
+ activeDot: { fill: color, r: 5 },
+ };
+ case 'average':
+ return { type: 'linear' as const, stroke: color, strokeWidth: 1, dot: false as const };
+ case 'trend':
+ return { type: 'monotone' as const, stroke: color, strokeWidth: 3, dot: false as const };
+ case 'component':
+ return {
+ type: 'linear' as const,
+ stroke: color,
+ strokeWidth: 2,
+ dot: showDots ? { fill: color, r: 3 } : false as const,
+ };
+ }
+};
+
+/**
+ * The points of a series as a band, empty when it must not have one.
+ *
+ * A band means "this is the spread of the measurements", which a derived line
+ * has none of. Condensing attaches a range to every point, so an average that
+ * got downsampled along with its values would otherwise be given a second band
+ * of its own. A partly ranged series is skipped as well, its envelope would
+ * end mid-chart.
+ */
+export const bandData = (series: ChartSeries): { date: number, range: [number, number] }[] => {
+ const carriesSpread = series.role === 'raw' || series.role === 'component';
+ if (!carriesSpread || series.points.length === 0 || !series.points.every(hasRange)) {
+ return [];
+ }
+
+ return series.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
+};
+
+/**
+ * Renders a list of series into one chart, styled by the role of each series.
+ *
+ * Points that summarise a range get a band around their line, which is what
+ * shows the spread of a daily aggregate or of a condensed series.
+ */
+export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: string, height?: number }) => {
+ const theme = useTheme();
+ const [t] = useTranslation();
+
+ const roleLabels: Record = {
+ raw: t('measurements.indicatorRaw'),
+ average: t('measurements.indicatorAvg'),
+ trend: t('measurements.indicatorTrend'),
+ component: '',
+ };
+
+ const palette = [...generateChartColors(props.series.filter(s => s.role === 'component').length)];
+ let componentIndex = 0;
+ const resolved = props.series.map(series => ({
+ series: series,
+ color: seriesColor(theme, series.role, series.role === 'component' ? componentIndex++ : 0, palette),
+ name: series.label ?? roleLabels[series.role],
+ // a role appears at most once, components are told apart by their name
+ key: `${series.role}-${series.label ?? ''}`,
+ }));
+
+ const maxPoints = Math.max(0, ...props.series.map(s => s.points.length));
+ const showDots = maxPoints <= MAX_DOTS;
+ const showLegend = props.series.some(s => s.label !== undefined);
+
+ return
+
+
+ dateToLocale(new Date(timeStr))!}
+ tickCount={10}
+ />
+
+ } />
+ {showLegend && }
+
+ {/* the bands go in first so the lines paint on top of them */}
+ {resolved.map(({ series, color, key }) => {
+ const band = bandData(series);
+
+ return band.length === 0
+ ? null
+ : ;
+ })}
+
+ {resolved.map(({ series, color, name, key }) =>
+ )}
+
+ ;
+};
From d09ecfccce997300789dd8786f295ab551d70ae5 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 00:35:04 +0200
Subject: [PATCH 019/102] Size chart marks by how many share the width
---
.../Measurements/charts/density.test.ts | 25 +++++++++
src/components/Measurements/charts/density.ts | 54 +++++++++++++++++++
.../Measurements/widgets/MeasurementChart.tsx | 11 +++-
.../widgets/MeasurementSeriesChart.tsx | 21 ++++----
4 files changed, 99 insertions(+), 12 deletions(-)
create mode 100644 src/components/Measurements/charts/density.test.ts
create mode 100644 src/components/Measurements/charts/density.ts
diff --git a/src/components/Measurements/charts/density.test.ts b/src/components/Measurements/charts/density.test.ts
new file mode 100644
index 000000000..ba9c42440
--- /dev/null
+++ b/src/components/Measurements/charts/density.test.ts
@@ -0,0 +1,25 @@
+import { dotRadius, MAX_DOT_RADIUS } from "@/components/Measurements/charts/density";
+import { describe, expect, test } from 'vitest';
+
+describe('dotRadius', () => {
+ test('starts at the maximum while the chart has not been measured', () => {
+ expect(dotRadius(0, 500)).toBe(MAX_DOT_RADIUS);
+ });
+
+ test('is the maximum for a chart with room to spare', () => {
+ expect(dotRadius(400, 10)).toBe(MAX_DOT_RADIUS);
+ });
+
+ test('shrinks as the points get denser', () => {
+ expect(dotRadius(400, 100)).toBe(2);
+ expect(dotRadius(400, 200)).toBe(1);
+ });
+
+ test('never goes below a visible minimum', () => {
+ expect(dotRadius(400, 100000)).toBe(0.5);
+ });
+
+ test('is the maximum for a series without points', () => {
+ expect(dotRadius(400, 0)).toBe(MAX_DOT_RADIUS);
+ });
+});
diff --git a/src/components/Measurements/charts/density.ts b/src/components/Measurements/charts/density.ts
new file mode 100644
index 000000000..7acc55d18
--- /dev/null
+++ b/src/components/Measurements/charts/density.ts
@@ -0,0 +1,54 @@
+import { useEffect, useRef, useState } from "react";
+
+/** Radius of a dot on a chart with room to spare */
+export const MAX_DOT_RADIUS = 4;
+
+/** Smallest dot that is still visible */
+const MIN_DOT_RADIUS = 0.5;
+
+/**
+ * Widest a single bar gets, for charts with only a handful of entries.
+ *
+ * Unlike the dots, the width of a bar does not have to be computed: recharts
+ * sizes bars to the band of the axis, which already is the available width
+ * divided by how many bars share it. Only the upper bound is ours.
+ */
+export const MAX_BAR_WIDTH = 12;
+
+const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
+
+/**
+ * Radius of the dots of a series with the given number of points.
+ *
+ * Mark size is in pixels, so it has to follow from how many marks share the
+ * available space: fixed sizes look fine on demo data and turn a season of
+ * readings into a solid block. Before the chart has been measured the width is
+ * 0 and the marks start out at their largest.
+ */
+export const dotRadius = (availableWidth: number, markCount: number): number =>
+ availableWidth <= 0 || markCount <= 0
+ ? MAX_DOT_RADIUS
+ : clamp(availableWidth / markCount / 2, MIN_DOT_RADIUS, MAX_DOT_RADIUS);
+
+/**
+ * The current width of the element the returned ref is put on, 0 until it has
+ * been measured. Charts need it to size their marks.
+ */
+export const useChartWidth = () => {
+ const ref = useRef(null);
+ const [width, setWidth] = useState(0);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (element === null) {
+ return;
+ }
+
+ const observer = new ResizeObserver(entries => setWidth(entries[0].contentRect.width));
+ observer.observe(element);
+
+ return () => observer.disconnect();
+ }, []);
+
+ return [ref, width] as const;
+};
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index a92d0e2e2..d3ded091b 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -9,6 +9,7 @@ import {
moving7dAverage,
smoothedTrendline
} from "@/components/Measurements/charts/data";
+import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
import { ChartSeries } from "@/components/Measurements/charts/series";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import React from "react";
@@ -48,7 +49,13 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
const data = fillMissingDays(aggregatePerDay(points));
return
-
+ {/*
+ * Bar width follows from how many bars share the width: recharts
+ * sizes them to the band, the gap (taken off both sides, so a bar
+ * keeps 70% of its band) holds neighbours apart, and the maximum
+ * keeps a handful of bars from becoming blocks
+ */}
+ {
+ maxBarSize={MAX_BAR_WIDTH} />
;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index acfc355e0..8e300a6cc 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -1,5 +1,6 @@
import { Box, Paper, useTheme } from "@mui/material";
import { Theme } from "@mui/material/styles";
+import { dotRadius, useChartWidth } from "@/components/Measurements/charts/density";
import { ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -7,9 +8,6 @@ import { Area, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis
import { generateChartColors } from "@/core/lib/colors";
import { dateToLocale } from "@/core/lib/date";
-/** Point count above which the dots of the measured values are dropped */
-const MAX_DOTS = 30;
-
/** Opacity of the band drawn around a series of ranged points */
const BAND_OPACITY = 0.15;
@@ -55,15 +53,15 @@ const seriesColor = (theme: Theme, role: ChartSeriesRole, componentIndex: number
};
/** How a series is drawn follows from its role, never from the series itself */
-const lineProps = (role: ChartSeriesRole, color: string, showDots: boolean) => {
+const lineProps = (role: ChartSeriesRole, color: string, radius: number) => {
switch (role) {
case 'raw':
// Dots only: a line would assert that something was measured
// between two readings
return {
stroke: 'transparent',
- dot: showDots ? { fill: color, r: 3 } : false as const,
- activeDot: { fill: color, r: 5 },
+ dot: { fill: color, r: radius },
+ activeDot: { fill: color, r: radius + 2 },
};
case 'average':
return { type: 'linear' as const, stroke: color, strokeWidth: 1, dot: false as const };
@@ -74,7 +72,8 @@ const lineProps = (role: ChartSeriesRole, color: string, showDots: boolean) => {
type: 'linear' as const,
stroke: color,
strokeWidth: 2,
- dot: showDots ? { fill: color, r: 3 } : false as const,
+ dot: { fill: color, r: radius },
+ activeDot: { fill: color, r: radius + 2 },
};
}
};
@@ -106,6 +105,7 @@ export const bandData = (series: ChartSeries): { date: number, range: [number, n
export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: string, height?: number }) => {
const theme = useTheme();
const [t] = useTranslation();
+ const [chartRef, chartWidth] = useChartWidth();
const roleLabels: Record = {
raw: t('measurements.indicatorRaw'),
@@ -124,11 +124,12 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
key: `${series.role}-${series.label ?? ''}`,
}));
+ // The densest series decides the mark size: all of them share the width
const maxPoints = Math.max(0, ...props.series.map(s => s.points.length));
- const showDots = maxPoints <= MAX_DOTS;
+ const radius = dotRadius(chartWidth, maxPoints);
const showLegend = props.series.some(s => s.label !== undefined);
- return
+ return )}
+ {...lineProps(series.role, color, radius)} />)}
;
};
From 8b12692268b6c7baa6ff8d153ba24fae1dfbd1b6 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 00:40:37 +0200
Subject: [PATCH 020/102] Chart a two-component group as one bar per reading
---
.../Measurements/charts/data.test.ts | 24 +++++++
src/components/Measurements/charts/data.ts | 21 ++++++
.../Measurements/widgets/MeasurementChart.tsx | 67 +++++++++++++++++--
3 files changed, 107 insertions(+), 5 deletions(-)
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index c486d5ccf..65b43ec19 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -5,6 +5,7 @@ import {
chartPointsFor,
downsample,
fillMissingDays,
+ groupChart,
groupComponentSeries,
groupRangeEntries,
moving7dAverage,
@@ -284,6 +285,29 @@ describe('groups', () => {
expect(series.map(s => s.role)).toEqual(['component', 'component']);
expect(series[0].points.map(p => p.value)).toEqual([120]);
});
+
+ test('two components are charted as ranges', () => {
+ const chart = groupChart(bloodPressure([[day(1), 120, 80]]));
+
+ expect(chart.kind).toBe('range');
+ });
+
+ test('a group whose readings are all unpaired falls back to component lines', () => {
+ const chart = groupChart(bloodPressure([[day(1), 120, null], [day(2), 125, null]]));
+
+ expect(chart.kind).toBe('components');
+ });
+
+ test('three components cannot be a range', () => {
+ const group = bloodPressure([[day(1), 120, 80]]);
+ const third = new MeasurementCategory('c-map', 'Mean', 'mmHg', [], 'custom', false, 'g-1', 2);
+ third.entries = [new MeasurementEntry(null, 'c-map', day(1), 93, '')];
+ group.children = [...group.children, third];
+
+ const chart = groupChart(group);
+
+ expect(chart.kind).toBe('components');
+ });
});
describe('overallChange', () => {
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 9b8597118..708d696d2 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -249,6 +249,27 @@ export const groupComponentSeries = (group: MeasurementCategory): ChartSeries[]
label: child.name,
}));
+/**
+ * How the readings of a group are charted.
+ *
+ * Two components are one reading with a low and a high end, so they are drawn
+ * as a bar spanning it. Anything else stays one line per component: more than
+ * two components cannot be a range, and neither can readings that are not
+ * paired, which happens once the date of one half is edited apart from the
+ * other. Without that fallback the card would go blank while there is data.
+ */
+export type GroupChart =
+ | { kind: 'range', points: ChartPoint[] }
+ | { kind: 'components', series: ChartSeries[] };
+
+export const groupChart = (group: MeasurementCategory): GroupChart => {
+ const ranges = group.children.length === 2 ? groupRangeEntries(group) : [];
+
+ return ranges.length > 0
+ ? { kind: 'range', points: ranges }
+ : { kind: 'components', series: groupComponentSeries(group) };
+};
+
/** Difference between the first and the last point, null for an empty series */
export const overallChange = (points: ChartPoint[]): number | null =>
points.length === 0 ? null : points[points.length - 1].value - points[0].value;
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index d3ded091b..acd766112 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -5,12 +5,12 @@ import {
chartPointsFor,
downsample,
fillMissingDays,
- groupComponentSeries,
+ groupChart,
moving7dAverage,
smoothedTrendline
} from "@/components/Measurements/charts/data";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
-import { ChartSeries } from "@/components/Measurements/charts/series";
+import { ChartPoint, ChartSeries } from "@/components/Measurements/charts/series";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import React from "react";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
@@ -74,6 +74,61 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
;
};
+interface RangeTooltipProps {
+ active?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload?: any;
+ label?: string;
+ unit: string;
+}
+
+const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ const [low, high] = payload[0].value as [number, number];
+
+ return (
+
+
{dateToLocale(new Date(Number(label)))}
+ {/* a range is quoted as high over low, the way a blood pressure reading is written */}
+
{high}/{low} {unit}
+
+ );
+};
+
+/**
+ * The readings of a two-component group, each as one bar spanning from the
+ * lower component to the upper one.
+ *
+ * A reading is one event: two lines would assert interpolation, but nothing
+ * was measured between two readings, and connecting them buries the thing that
+ * matters, the gap within one reading.
+ */
+const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
+ const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
+
+ return
+
+
+ dateToLocale(new Date(timeStr))!}
+ />
+
+ } />
+
+
+ ;
+};
+
/**
* The values of a category with the average and trend derived from them.
*
@@ -103,9 +158,11 @@ const measurementSeries = (category: MeasurementCategory): ChartSeries[] => {
export const MeasurementChart = (props: { category: MeasurementCategory }) => {
if (props.category.isGroup) {
- return ;
+ const chart = groupChart(props.category);
+
+ return chart.kind === 'range'
+ ?
+ : ;
}
return isSummedPerDay(props.category.metricType)
From aa09c9dd6127e84a0fd97013e5baeb85ce412ae4 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 00:48:27 +0200
Subject: [PATCH 021/102] Format chart axes, tooltips and legends
---
public/locales/en/translation.json | 3 +-
.../Dashboard/MeasurementCard.test.tsx | 12 +++--
.../Measurements/charts/format.test.ts | 45 +++++++++++++++++++
src/components/Measurements/charts/format.ts | 27 +++++++++++
.../Measurements/widgets/ChartEmptyState.tsx | 17 +++++++
.../Measurements/widgets/MeasurementChart.tsx | 40 ++++++++++++++---
.../widgets/MeasurementSeriesChart.tsx | 40 +++++++++++++----
src/core/lib/numbers.ts | 8 ++++
8 files changed, 173 insertions(+), 19 deletions(-)
create mode 100644 src/components/Measurements/charts/format.test.ts
create mode 100644 src/components/Measurements/charts/format.ts
create mode 100644 src/components/Measurements/widgets/ChartEmptyState.tsx
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index f4b565e97..9ec4e2b0d 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -357,7 +357,8 @@
},
"indicatorRaw": "raw",
"indicatorAvg": "avg",
- "indicatorTrend": "trend"
+ "indicatorTrend": "trend",
+ "noDataAvailable": "No data available"
},
"server": {
"abs": "Abs",
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index 5d7795726..cba55a1c8 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import { MeasurementCard } from "@/components/Dashboard/MeasurementCard";
import { MeasurementCategory, useMeasurementsCategoryQuery } from "@/components/Measurements";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -78,10 +78,14 @@ describe("smoke test the MeasurementCard component", () => {
// Assert
expect(screen.getAllByText('Blood pressure').length).toBeGreaterThan(0);
expect(screen.getAllByText('Systolic').length).toBeGreaterThan(0);
- expect(screen.getAllByText('125 mmHg').length).toBeGreaterThan(0);
+
+ // scoped to the table, the values also appear on the chart's axis
+ const table = within(screen.getByRole('table'));
+ expect(table.getByText('125 mmHg')).toBeInTheDocument();
// no reading yet for the diastolic component
- expect(screen.getAllByText('—').length).toBeGreaterThan(0);
- expect(screen.queryByText('120 mmHg')).toBeNull();
+ expect(table.getByText('—')).toBeInTheDocument();
+ // only the latest reading is listed
+ expect(table.queryByText('120 mmHg')).toBeNull();
});
});
diff --git a/src/components/Measurements/charts/format.test.ts b/src/components/Measurements/charts/format.test.ts
new file mode 100644
index 000000000..3d035802c
--- /dev/null
+++ b/src/components/Measurements/charts/format.test.ts
@@ -0,0 +1,45 @@
+import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { describe, expect, test } from 'vitest';
+
+const point = (date: Date): ChartPoint => ({ date: date.getTime(), value: 0 });
+
+describe('spansYears', () => {
+ test('is false for an empty series', () => {
+ expect(spansYears([])).toBe(false);
+ });
+
+ test('is false while the points stay within one year', () => {
+ expect(spansYears([point(new Date(2023, 0, 1)), point(new Date(2023, 11, 31))])).toBe(false);
+ });
+
+ test('is true once they cross into another one', () => {
+ expect(spansYears([point(new Date(2023, 11, 31)), point(new Date(2024, 0, 1))])).toBe(true);
+ });
+});
+
+describe('dateTick', () => {
+ const date = new Date(2023, 4, 17).getTime();
+
+ test('leaves the year out while the chart stays within one', () => {
+ expect(dateTick(false)(date)).not.toContain('23');
+ });
+
+ test('shows the year once the ticks need it', () => {
+ expect(dateTick(true)(date)).toContain('23');
+ });
+});
+
+describe('valueWithUnit', () => {
+ test('separates the value from its unit', () => {
+ expect(valueWithUnit(42, 'cm', 'en')).toBe('42 cm');
+ });
+
+ test('cuts the artefacts of summing floats down to what the server stores', () => {
+ expect(valueWithUnit(11529.939999999999, 'count', 'en')).toBe('11,529.94 count');
+ });
+
+ test('formats the number for the locale', () => {
+ expect(valueWithUnit(1234.5, 'kcal', 'de')).toBe('1.234,5 kcal');
+ });
+});
diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts
new file mode 100644
index 000000000..6b42b5842
--- /dev/null
+++ b/src/components/Measurements/charts/format.ts
@@ -0,0 +1,27 @@
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { dateToLocale } from "@/core/lib/date";
+import { numberDecimalLocale } from "@/core/lib/numbers";
+
+/** Whether the points fall into more than one calendar year */
+export const spansYears = (points: ChartPoint[]): boolean => {
+ if (points.length === 0) {
+ return false;
+ }
+
+ const years = points.map(point => new Date(point.date).getFullYear());
+
+ return Math.min(...years) !== Math.max(...years);
+};
+
+/**
+ * Label of a date on an axis. The year is left out while the chart stays
+ * within one, where it is the same on every tick and only costs space.
+ */
+export const dateTick = (withYear: boolean) => (value: number): string =>
+ dateToLocale(new Date(value), undefined, withYear
+ ? { year: '2-digit', month: '2-digit', day: '2-digit' }
+ : { month: '2-digit', day: '2-digit' });
+
+/** A measured value with its unit, both localised */
+export const valueWithUnit = (value: number, unit: string, locale: string): string =>
+ `${numberDecimalLocale(value, locale)} ${unit}`;
diff --git a/src/components/Measurements/widgets/ChartEmptyState.tsx b/src/components/Measurements/widgets/ChartEmptyState.tsx
new file mode 100644
index 000000000..af6fbe7c0
--- /dev/null
+++ b/src/components/Measurements/widgets/ChartEmptyState.tsx
@@ -0,0 +1,17 @@
+import { Box, Typography } from "@mui/material";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+/** Shown in place of a chart that has nothing to draw */
+export const ChartEmptyState = (props: { height?: number }) => {
+ const [t] = useTranslation();
+
+ return
+ {t('measurements.noDataAvailable')}
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index acd766112..846b7a8fb 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -10,12 +10,16 @@ import {
smoothedTrendline
} from "@/components/Measurements/charts/data";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
+import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
import { ChartPoint, ChartSeries } from "@/components/Measurements/charts/series";
+import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import React from "react";
+import { useTranslation } from "react-i18next";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
import { theme } from "@/theme";
import { dateToLocale } from "@/core/lib/date";
+import { numberDecimalLocale } from "@/core/lib/numbers";
export interface TooltipProps {
active?: boolean,
@@ -26,6 +30,8 @@ export interface TooltipProps {
}
const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
+ const [, i18n] = useTranslation();
+
if (active && payload && payload.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = payload.find((p: any) => p.dataKey === 'value');
@@ -33,7 +39,9 @@ const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
return (
}
-
- );
- }
+/** What every tooltip here shares: the day, and under it what was measured on it */
+const TooltipFrame = (props: { label?: string, children: React.ReactNode }) =>
+
+
{dateToLocale(new Date(Number(props.label)))}
+ {props.children}
+ ;
- return null;
-};
-
-const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
+/**
+ * The frame every bar chart here is drawn in: the grid, the date axis and the
+ * value axis, which only differ in the unit they read. The bars themselves are
+ * the caller's, they are what each chart is about.
+ */
+const BarChartFrame = (props: {
+ data: { date: number }[],
+ unit: string,
+ /** Where the value axis starts for a unit that brings no axis of its own */
+ domainStart: 0 | 'auto',
+ axis: ReturnType,
+ tooltip: React.ReactElement,
+ ariaLabel?: string,
+ children: React.ReactNode,
+}) => {
const [, i18n] = useTranslation();
- // Bars need a band axis (recharts miscomputes bar heights on a numeric
- // time axis), so make the bands time-proportional by filling in the
- // missing days instead
- const data = fillMissingDays(aggregatePerDay(props.points));
-
- if (data.length === 0) {
- return ;
- }
-
- const axis = durationAxis(props.category.unit, 0, Math.max(...data.map(point => point.value)));
-
return
{/*
* Bar width follows from how many bars share the width: recharts
@@ -107,57 +97,90 @@ const MeasurementBarChart = (props: { category: MeasurementCategory, points: Cha
* keeps 70% of its band) holds neighbours apart, and the maximum
* keeps a handful of bars from becoming blocks
*/}
-
+ valueWithUnit(value, props.category.unit, i18n.language)} />
- )} />
-
+ tickFormatter={value => valueWithUnit(value, props.unit, i18n.language)} />
+
+ {props.children}
;
};
-interface RangeTooltipProps {
- active?: boolean;
+const CustomTooltip = (props: TooltipProps & { category: MeasurementCategory }) => {
+ const [t, i18n] = useTranslation();
+
+ if (!props.active || !props.payload?.length) {
+ return null;
+ }
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- payload?: any;
- label?: string;
- unit: string;
-}
+ const value = props.payload.find((entry: any) => entry.dataKey === 'value');
+
+ return
+ {value &&
}
+ ;
+};
-const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
+const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
+ // Bars need a band axis (recharts miscomputes bar heights on a numeric
+ // time axis), so make the bands time-proportional by filling in the
+ // missing days instead
+ const data = fillMissingDays(aggregatePerDay(props.points));
+
+ if (data.length === 0) {
+ return ;
+ }
+
+ return point.value)))}
+ tooltip={}>
+
+ ;
+};
+
+const RangeTooltip = (props: TooltipProps & { unit: string }) => {
const [, i18n] = useTranslation();
- if (!active || !payload?.length) {
+ if (!props.active || !props.payload?.length) {
return null;
}
- const [low, high] = payload[0].value as [number, number];
-
- return (
-
-
{dateToLocale(new Date(Number(label)))}
- {/* a range is quoted as high over low, the way a blood pressure reading is written */}
-
- {valueOnly(high, unit, i18n.language)}/
- {valueWithUnit(low, unit, i18n.language)}
-
-
- );
+ const [low, high] = props.payload[0].value as [number, number];
+
+ return
+ {/* a range is quoted as high over low, the way a blood pressure reading is written */}
+
}
+ ;
+};
+
+export const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
+ // Bars need a band axis (recharts miscomputes bar heights on a numeric
+ // time axis), so make the bands time-proportional by filling in the
+ // missing days instead
+ const data = fillMissingDays(aggregatePerDay(props.points));
+
+ if (data.length === 0) {
+ return ;
+ }
+
+ return point.value)))}
+ tooltip={}>
+
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index a66ada40b..acdbd2934 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,9 +1,7 @@
-import { alpha, Box, Paper, Typography } from "@mui/material";
import {
averageWindowOf,
binWidthFor,
categoryDisplayName,
- ChartConfig,
isSummedPerDay,
MeasurementCategory,
resolveChartType
@@ -11,20 +9,12 @@ import {
import {
aggregatePerDay,
averagePerDay,
- buildHeatmapGrid,
chartQueryFor,
- buildHistogram,
chartPointsForBuckets,
- DAYS_PER_WEEK,
DISTRIBUTION_MIN_VALUES,
- fillMissingDays,
groupChart,
groupComponentPoints,
- heatmapDayAt,
- measurementSeries,
movingAverage,
- StackedPoint,
- ValueCount,
valueHistogram,
weeklyDeltas
} from "@/components/Measurements/charts/data";
@@ -32,15 +22,6 @@ import {
useMeasurementBucketsQuery,
useMeasurementValueCountsQuery
} from "@/components/Measurements/queries";
-import { componentColor, componentPalette, deltaColor } from "@/components/Measurements/charts/colors";
-import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
-import {
- dateTick,
- durationAxis,
- spansYears,
- valueOnly,
- valueWithUnit
-} from "@/components/Measurements/charts/format";
import {
ChartRange,
cutoffFor,
@@ -48,570 +29,22 @@ import {
displayFilterFor,
pointsSince
} from "@/components/Measurements/charts/range";
-import { ChartPoint, PlanPeriod } from "@/components/Measurements/charts/series";
-import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import { PlanPeriod } from "@/components/Measurements/charts/series";
+import { MeasurementBarChart } from "@/components/Measurements/widgets/MeasurementBarChart";
+import { MeasurementDeltaBarChart } from "@/components/Measurements/widgets/MeasurementDeltaBarChart";
+import { MeasurementDistributionChart } from "@/components/Measurements/widgets/MeasurementDistributionChart";
+import { MeasurementHeatmapChart } from "@/components/Measurements/widgets/MeasurementHeatmapChart";
+import { MeasurementLineChart } from "@/components/Measurements/widgets/MeasurementLineChart";
+import { MeasurementRangeBarChart } from "@/components/Measurements/widgets/MeasurementRangeBarChart";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
+import { MeasurementStackedBarChart } from "@/components/Measurements/widgets/MeasurementStackedBarChart";
import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
-import React from "react";
import { useTranslation } from "react-i18next";
-import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, Tooltip, XAxis, YAxis } from "recharts";
-import { theme } from "@/theme";
-import { dateToLocale } from "@/core/lib/date";
-
-interface TooltipProps {
- active?: boolean,
- /** The hovered entries, read by each tooltip the way its own chart wrote them */
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- payload?: any,
- label?: string,
-}
-
-/** What every tooltip here shares: the day, and under it what was measured on it */
-const TooltipFrame = (props: { label?: string, children: React.ReactNode }) =>
-
-
{dateToLocale(new Date(Number(props.label)))}
- {props.children}
- ;
-
-/**
- * The frame every bar chart here is drawn in: the grid, the date axis and the
- * value axis, which only differ in the unit they read. The bars themselves are
- * the caller's, they are what each chart is about.
- */
-const BarChartFrame = (props: {
- data: { date: number }[],
- unit: string,
- /** Where the value axis starts for a unit that brings no axis of its own */
- domainStart: 0 | 'auto',
- axis: ReturnType,
- tooltip: React.ReactElement,
- ariaLabel?: string,
- children: React.ReactNode,
-}) => {
- const [, i18n] = useTranslation();
-
- return
- {/*
- * Bar width follows from how many bars share the width: recharts
- * sizes them to the band, the gap (taken off both sides, so a bar
- * keeps 70% of its band) holds neighbours apart, and the maximum
- * keeps a handful of bars from becoming blocks
- */}
-
-
-
- valueWithUnit(value, props.unit, i18n.language)} />
-
- {props.children}
-
- ;
-};
-
-const CustomTooltip = (props: TooltipProps & { category: MeasurementCategory }) => {
- const [t, i18n] = useTranslation();
-
- if (!props.active || !props.payload?.length) {
- return null;
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const value = props.payload.find((entry: any) => entry.dataKey === 'value');
-
- return
- {value &&
}
- ;
-};
-
-const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
- // Bars need a band axis (recharts miscomputes bar heights on a numeric
- // time axis), so make the bands time-proportional by filling in the
- // missing days instead
- const data = fillMissingDays(aggregatePerDay(props.points));
-
- if (data.length === 0) {
- return ;
- }
-
- return point.value)))}
- tooltip={}>
-
- ;
-};
-
-const RangeTooltip = (props: TooltipProps & { unit: string }) => {
- const [, i18n] = useTranslation();
-
- if (!props.active || !props.payload?.length) {
- return null;
- }
-
- const [low, high] = props.payload[0].value as [number, number];
-
- return
- {/* a range is quoted as high over low, the way a blood pressure reading is written */}
-
- ;
-};
-
-/**
- * The readings of a two-component group, each as one bar spanning from the
- * lower component to the upper one.
- *
- * A reading is one event: two lines would assert interpolation, but nothing
- * was measured between two readings, and connecting them buries the thing that
- * matters, the gap within one reading.
- */
-const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
- const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
-
- return point.min!)),
- Math.max(...props.points.map(point => point.max!)),
- )}
- tooltip={}>
-
- ;
-};
-
-/** The whole bar with its parts: a single segment says little without the night it belongs to */
-const StackedTooltip = (props: TooltipProps & { unit: string }) => {
- const [, i18n] = useTranslation();
-
- if (!props.active || !props.payload?.length) {
- return null;
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const parts = props.payload.filter((entry: any) => entry.value > 0);
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const total = parts.reduce((sum: number, entry: any) => sum + entry.value, 0);
-
- return
-
)}
- ;
-};
/**
- * Stacked bar chart for a group whose components are parts of one whole, e.g.
- * the sleep stages of a night.
- *
- * One bar per day, split into a segment per component in the components' own
- * order, so the bar's height is the night and its segments are how it was
- * spent. Colours come from the component palette by position, which is what
- * ties a segment to the row naming it.
+ * The chart of a category: which one it is follows from the metric type and
+ * what the user picked, each of them a widget of its own.
*/
-const MeasurementStackedBarChart = (props: {
- points: StackedPoint[],
- labels: string[],
- unit: string,
-}) => {
- const palette = componentPalette(props.labels.length);
- const data = props.points.map(point => ({
- date: point.date,
- ...Object.fromEntries(props.labels.map((label, index) => [label, point.values[index]])),
- }));
- // The bar is as tall as its segments together, so that is what the axis
- // has to cover
- const totals = props.points.map(
- point => point.values.reduce((sum: number, value) => sum + (value ?? 0), 0),
- );
-
- return }>
- {props.labels.map((label, index) => )}
- ;
-};
-
-const DeltaTooltip = (props: TooltipProps & { unit: string }) => {
- const [, i18n] = useTranslation();
-
- if (!props.active || !props.payload?.length) {
- return null;
- }
-
- const value = props.payload[0].value as number;
-
- return
- {/* the plus is ours, only the minus comes out of the number format */}
-
- ;
-};
-
-/**
- * Week-over-week change: one bar per calendar week, hanging off a zero line
- * and coloured by its direction. Answers "is it going the right way" more
- * directly than the trend line does.
- */
-const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string }) => {
- const [t] = useTranslation();
-
- if (props.points.length === 0) {
- return ;
- }
-
- const values = props.points.map(point => point.value);
-
- return }
- ariaLabel={t('measurements.chartTypes.delta')}>
- {/* without the baseline a chart of only decreases reads as a normal one pointing down */}
-
-
- {props.points.map(point =>
- )}
-
- ;
-};
-
-/**
- * Histogram of how often each value occurred: the values of the selected range
- * binned by size, with the median and the newest value marked.
- *
- * The one chart of the set without a time axis. It answers what is normal and
- * what is an outlier, which no chart over time shows, and the marked newest
- * value places today within that. Plain elements rather than recharts, whose
- * bar chart cannot place a marker line at an exact value on a band axis.
- */
-const MeasurementDistributionChart = (props: {
- values: ValueCount[],
- latest: number,
- unit: string,
- binWidth?: number,
- countsAreDays?: boolean,
-}) => {
- const [t, i18n] = useTranslation();
- const [selected, setSelected] = React.useState(null);
-
- if (props.values.length === 0) {
- return ;
- }
-
- const histogram = buildHistogram(props.values, props.latest, props.binWidth);
- const bins = histogram.counts.length;
- const maxCount = Math.max(...histogram.counts);
- const lowerEdgeOf = (bin: number): number => histogram.firstEdge + bin * histogram.binWidth;
-
- // A pick from before the data changed (a tap, then a range switch) could
- // point past the histogram, so it is dropped rather than read out of range
- const activeBin = selected !== null && selected < bins ? selected : null;
-
- /** Horizontal position of a value on the axis the bins tile, in percent */
- const positionOf = (value: number): string =>
- `${((value - histogram.firstEdge) / (bins * histogram.binWidth) * 100).toFixed(2)}%`;
-
- // The read-out line above the bars: the hovered bin as its range and
- // count, or the median and newest value while nothing is hovered, coloured
- // like their marker lines so the numbers say what the lines only place
- const readout = activeBin === null
- ? <>
-
- {t('measurements.distributionMedian')}
- : {valueWithUnit(histogram.median, props.unit, i18n.language)}
-
- {' · '}
-
- {t('measurements.distributionLatest')}
- : {valueWithUnit(histogram.latest, props.unit, i18n.language)}
-
- >
- : `${valueOnly(lowerEdgeOf(activeBin), props.unit, i18n.language)}`
- + `-${valueWithUnit(lowerEdgeOf(activeBin + 1), props.unit, i18n.language)}: `
- + t(
- props.countsAreDays
- ? 'measurements.distributionDayCount'
- : 'measurements.distributionEntryCount',
- { count: histogram.counts[activeBin] },
- );
-
- // Every k-th bin edge, labelled with its value: the edges are the round
- // numbers the bins were aligned to, so they are the natural ticks
- const labelEvery = Math.max(1, Math.ceil(bins / 4));
- const edgeLabels: number[] = [];
- for (let edge = 0; edge <= bins; edge += labelEvery) {
- edgeLabels.push(edge);
- }
-
- const markerStyle = {
- bottom: 0,
- pointerEvents: 'none',
- position: 'absolute',
- top: 0,
- width: '2px',
- } as const;
-
- return
- {readout}
-
-
- {/* The whole column takes the hover, so an empty bin can be read too */}
- {histogram.counts.map((count, bin) => setSelected(bin)}
- onMouseLeave={() => setSelected(null)}
- sx={{ alignItems: 'flex-end', display: 'flex', height: '100%' }}>
-
- )}
-
- {/* The markers sit at the exact value, not on a bin */}
-
-
-
-
- {edgeLabels.map(edge =>
- {valueOnly(lowerEdgeOf(edge), props.unit, i18n.language)}
- )}
-
- ;
-};
-
-/** Widest a heatmap cell gets, and the room its weekday labels need */
-const MAX_HEATMAP_CELL = 22;
-const WEEKDAY_LABEL_WIDTH = 30;
-
-/**
- * Calendar heatmap: one cell per day, coloured by that day's value.
- *
- * Where a line or a bar answers how much, this answers how regularly, which for
- * steps or sleep is often the more interesting question. It is also the only
- * chart of the set where a gap is visible: a day without a measurement is an
- * empty cell instead of a line segment that silently spans it.
- *
- * Takes one point per calendar day; how a day's readings became that value
- * (summed, averaged) is decided by the caller.
- */
-const MeasurementHeatmapChart = (props: { points: ChartPoint[], unit: string }) => {
- const [t, i18n] = useTranslation();
- const [selected, setSelected] = React.useState(null);
-
- if (props.points.length === 0) {
- return ;
- }
-
- const grid = buildHeatmapGrid(props.points);
- const today = new Date().setHours(0, 0, 0, 0);
- const weekdays = Array.from({ length: DAYS_PER_WEEK }, (_, row) => row);
- const weeks = Array.from({ length: grid.weeks }, (_, column) => column);
-
- /**
- * A day without a measurement is neutral, everything else is tinted by how
- * large its value is within the grid. The scale is continuous and starts
- * well above transparent: a day that was measured has to read as measured
- * even when its value is the smallest one.
- */
- const cellColor = (value: number | undefined): string => {
- if (value === undefined) {
- return theme.palette.action.hover;
- }
- const share = grid.maxValue <= 0 ? 1 : Math.min(1, Math.max(0, value / grid.maxValue));
-
- return alpha(theme.palette.secondary.main, 0.3 + 0.7 * share);
- };
-
- // The grid is whole weeks and its last one usually runs past today, so the
- // span it covers ends today rather than on that Sunday
- const last = heatmapDayAt(grid, grid.weeks - 1, DAYS_PER_WEEK - 1);
- const selectedValue = selected === null ? undefined : grid.values.get(selected);
- const readout = selected === null
- ? `${dateToLocale(new Date(grid.start))} - ${dateToLocale(new Date(Math.min(last, today)))}`
- : `${dateToLocale(new Date(selected))}: ${selectedValue === undefined
- ? t('measurements.noDataAvailable')
- : valueWithUnit(selectedValue, props.unit, i18n.language)}`;
-
- const cells = weekdays.flatMap(weekday => weeks.map(week => {
- const day = heatmapDayAt(grid, week, weekday);
-
- return setSelected(day)}
- onMouseLeave={() => setSelected(null)}
- sx={{
- aspectRatio: '1 / 1',
- backgroundColor: cellColor(grid.values.get(day)),
- borderRadius: '2px',
- // Days that have not happened yet are left blank rather than
- // drawn as a gap
- visibility: day > today ? 'hidden' : 'visible',
- outline: day === selected ? `1px solid ${theme.palette.text.primary}` : 'none',
- }} />;
- }));
-
- // The month above the column it starts in, which is what says where in the
- // year the grid is without a date axis
- const monthLabels = weeks.map(week => {
- const monday = heatmapDayAt(grid, week, 0);
- const day = new Date(monday);
- const previous = week === 0 ? -1 : new Date(heatmapDayAt(grid, week - 1, 0)).getMonth();
-
- return {
- day: monday,
- label: day.getMonth() === previous
- ? ''
- : day.toLocaleDateString(i18n.language, { month: 'short' }),
- };
- });
-
- const columns = `repeat(${grid.weeks}, 1fr)`;
- const labelStyle = {
- color: 'text.secondary',
- fontSize: '0.7rem',
- lineHeight: 1,
- whiteSpace: 'nowrap',
- } as const;
-
- return
- {readout}
- {/*
- * The cells are square and share the width, so a short range would
- * blow them up into a chunky calendar; the grid stops growing at a
- * width its cells stay small in and keeps the rest of the space empty
- */}
-
-
-
- {monthLabels.map(({ day, label }) =>
-
- {label}
-
- )}
-
-
- {/* Every other weekday: naming all seven needs more room than the rows have */}
-
- {weekdays.map(weekday =>
-
- {weekday % 2 === 0
- ? new Date(heatmapDayAt(grid, 0, weekday))
- .toLocaleDateString(i18n.language, { weekday: 'short' })
- : ''}
-
- )}
-
- {/* The grid carries its meaning in colour alone, so it needs a name */}
-
- {cells}
-
-
- ;
-};
-
-const MeasurementLineChart = (props: {
- unit: string,
- points: ChartPoint[],
- cutoff: Date | null,
- config: ChartConfig,
- planPeriods?: PlanPeriod[],
-}) => {
- const series = measurementSeries(props.points, props.cutoff, props.config);
-
- return <>
-
-
- >;
-};
-
export const MeasurementChart = (props: {
category: MeasurementCategory,
range?: ChartRange,
diff --git a/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx b/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
new file mode 100644
index 000000000..6cf3fc318
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
@@ -0,0 +1,54 @@
+import { deltaColor } from "@/components/Measurements/charts/colors";
+import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
+import { durationAxis, valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
+import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import { useTranslation } from "react-i18next";
+import { Bar, Cell, ReferenceLine } from "recharts";
+import { theme } from "@/theme";
+
+const DeltaTooltip = (props: TooltipProps & { unit: string }) => {
+ const [, i18n] = useTranslation();
+
+ if (!props.active || !props.payload?.length) {
+ return null;
+ }
+
+ const value = props.payload[0].value as number;
+
+ return
+ {/* the plus is ours, only the minus comes out of the number format */}
+
+ ;
+};
+
+/**
+ * Week-over-week change: one bar per calendar week, hanging off a zero line
+ * and coloured by its direction. Answers "is it going the right way" more
+ * directly than the trend line does.
+ */
+export const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string }) => {
+ const [t] = useTranslation();
+
+ if (props.points.length === 0) {
+ return ;
+ }
+
+ const values = props.points.map(point => point.value);
+
+ return }
+ ariaLabel={t('measurements.chartTypes.delta')}>
+ {/* without the baseline a chart of only decreases reads as a normal one pointing down */}
+
+
+ {props.points.map(point =>
+ )}
+
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementDistributionChart.tsx b/src/components/Measurements/widgets/MeasurementDistributionChart.tsx
new file mode 100644
index 000000000..c08d9e025
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementDistributionChart.tsx
@@ -0,0 +1,145 @@
+import { Box, Typography } from "@mui/material";
+import { buildHistogram, ValueCount } from "@/components/Measurements/charts/data";
+import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { theme } from "@/theme";
+
+/**
+ * Histogram of how often each value occurred: the values of the selected range
+ * binned by size, with the median and the newest value marked.
+ *
+ * The one chart of the set without a time axis. It answers what is normal and
+ * what is an outlier, which no chart over time shows, and the marked newest
+ * value places today within that. Plain elements rather than recharts, whose
+ * bar chart cannot place a marker line at an exact value on a band axis.
+ */
+export const MeasurementDistributionChart = (props: {
+ values: ValueCount[],
+ latest: number,
+ unit: string,
+ binWidth?: number,
+ countsAreDays?: boolean,
+}) => {
+ const [t, i18n] = useTranslation();
+ const [selected, setSelected] = React.useState(null);
+
+ if (props.values.length === 0) {
+ return ;
+ }
+
+ const histogram = buildHistogram(props.values, props.latest, props.binWidth);
+ const bins = histogram.counts.length;
+ const maxCount = Math.max(...histogram.counts);
+ const lowerEdgeOf = (bin: number): number => histogram.firstEdge + bin * histogram.binWidth;
+
+ // A pick from before the data changed (a tap, then a range switch) could
+ // point past the histogram, so it is dropped rather than read out of range
+ const activeBin = selected !== null && selected < bins ? selected : null;
+
+ /** Horizontal position of a value on the axis the bins tile, in percent */
+ const positionOf = (value: number): string =>
+ `${((value - histogram.firstEdge) / (bins * histogram.binWidth) * 100).toFixed(2)}%`;
+
+ // The read-out line above the bars: the hovered bin as its range and
+ // count, or the median and newest value while nothing is hovered, coloured
+ // like their marker lines so the numbers say what the lines only place
+ const readout = activeBin === null
+ ? <>
+
+ {t('measurements.distributionMedian')}
+ : {valueWithUnit(histogram.median, props.unit, i18n.language)}
+
+ {' · '}
+
+ {t('measurements.distributionLatest')}
+ : {valueWithUnit(histogram.latest, props.unit, i18n.language)}
+
+ >
+ : `${valueOnly(lowerEdgeOf(activeBin), props.unit, i18n.language)}`
+ + `-${valueWithUnit(lowerEdgeOf(activeBin + 1), props.unit, i18n.language)}: `
+ + t(
+ props.countsAreDays
+ ? 'measurements.distributionDayCount'
+ : 'measurements.distributionEntryCount',
+ { count: histogram.counts[activeBin] },
+ );
+
+ // Every k-th bin edge, labelled with its value: the edges are the round
+ // numbers the bins were aligned to, so they are the natural ticks
+ const labelEvery = Math.max(1, Math.ceil(bins / 4));
+ const edgeLabels: number[] = [];
+ for (let edge = 0; edge <= bins; edge += labelEvery) {
+ edgeLabels.push(edge);
+ }
+
+ const markerStyle = {
+ bottom: 0,
+ pointerEvents: 'none',
+ position: 'absolute',
+ top: 0,
+ width: '2px',
+ } as const;
+
+ return
+ {readout}
+
+
+ {/* The whole column takes the hover, so an empty bin can be read too */}
+ {histogram.counts.map((count, bin) => setSelected(bin)}
+ onMouseLeave={() => setSelected(null)}
+ sx={{ alignItems: 'flex-end', display: 'flex', height: '100%' }}>
+
+ )}
+
+ {/* The markers sit at the exact value, not on a bin */}
+
+
+
+
+ {edgeLabels.map(edge =>
+ {valueOnly(lowerEdgeOf(edge), props.unit, i18n.language)}
+ )}
+
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
new file mode 100644
index 000000000..2abafd2b6
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
@@ -0,0 +1,147 @@
+import { alpha, Box, Typography } from "@mui/material";
+import { buildHeatmapGrid, DAYS_PER_WEEK, heatmapDayAt } from "@/components/Measurements/charts/data";
+import { valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import { dateToLocale } from "@/core/lib/date";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { theme } from "@/theme";
+
+/** Widest a heatmap cell gets, and the room its weekday labels need */
+const MAX_HEATMAP_CELL = 22;
+const WEEKDAY_LABEL_WIDTH = 30;
+
+/**
+ * Calendar heatmap: one cell per day, coloured by that day's value.
+ *
+ * Where a line or a bar answers how much, this answers how regularly, which for
+ * steps or sleep is often the more interesting question. It is also the only
+ * chart of the set where a gap is visible: a day without a measurement is an
+ * empty cell instead of a line segment that silently spans it.
+ *
+ * Takes one point per calendar day; how a day's readings became that value
+ * (summed, averaged) is decided by the caller.
+ */
+export const MeasurementHeatmapChart = (props: { points: ChartPoint[], unit: string }) => {
+ const [t, i18n] = useTranslation();
+ const [selected, setSelected] = React.useState(null);
+
+ if (props.points.length === 0) {
+ return ;
+ }
+
+ const grid = buildHeatmapGrid(props.points);
+ const today = new Date().setHours(0, 0, 0, 0);
+ const weekdays = Array.from({ length: DAYS_PER_WEEK }, (_, row) => row);
+ const weeks = Array.from({ length: grid.weeks }, (_, column) => column);
+
+ /**
+ * A day without a measurement is neutral, everything else is tinted by how
+ * large its value is within the grid. The scale is continuous and starts
+ * well above transparent: a day that was measured has to read as measured
+ * even when its value is the smallest one.
+ */
+ const cellColor = (value: number | undefined): string => {
+ if (value === undefined) {
+ return theme.palette.action.hover;
+ }
+ const share = grid.maxValue <= 0 ? 1 : Math.min(1, Math.max(0, value / grid.maxValue));
+
+ return alpha(theme.palette.secondary.main, 0.3 + 0.7 * share);
+ };
+
+ // The grid is whole weeks and its last one usually runs past today, so the
+ // span it covers ends today rather than on that Sunday
+ const last = heatmapDayAt(grid, grid.weeks - 1, DAYS_PER_WEEK - 1);
+ const selectedValue = selected === null ? undefined : grid.values.get(selected);
+ const readout = selected === null
+ ? `${dateToLocale(new Date(grid.start))} - ${dateToLocale(new Date(Math.min(last, today)))}`
+ : `${dateToLocale(new Date(selected))}: ${selectedValue === undefined
+ ? t('measurements.noDataAvailable')
+ : valueWithUnit(selectedValue, props.unit, i18n.language)}`;
+
+ const cells = weekdays.flatMap(weekday => weeks.map(week => {
+ const day = heatmapDayAt(grid, week, weekday);
+
+ return setSelected(day)}
+ onMouseLeave={() => setSelected(null)}
+ sx={{
+ aspectRatio: '1 / 1',
+ backgroundColor: cellColor(grid.values.get(day)),
+ borderRadius: '2px',
+ // Days that have not happened yet are left blank rather than
+ // drawn as a gap
+ visibility: day > today ? 'hidden' : 'visible',
+ outline: day === selected ? `1px solid ${theme.palette.text.primary}` : 'none',
+ }} />;
+ }));
+
+ // The month above the column it starts in, which is what says where in the
+ // year the grid is without a date axis
+ const monthLabels = weeks.map(week => {
+ const monday = heatmapDayAt(grid, week, 0);
+ const day = new Date(monday);
+ const previous = week === 0 ? -1 : new Date(heatmapDayAt(grid, week - 1, 0)).getMonth();
+
+ return {
+ day: monday,
+ label: day.getMonth() === previous
+ ? ''
+ : day.toLocaleDateString(i18n.language, { month: 'short' }),
+ };
+ });
+
+ const columns = `repeat(${grid.weeks}, 1fr)`;
+ const labelStyle = {
+ color: 'text.secondary',
+ fontSize: '0.7rem',
+ lineHeight: 1,
+ whiteSpace: 'nowrap',
+ } as const;
+
+ return
+ {readout}
+ {/*
+ * The cells are square and share the width, so a short range would
+ * blow them up into a chunky calendar; the grid stops growing at a
+ * width its cells stay small in and keeps the rest of the space empty
+ */}
+
+
+
+ {monthLabels.map(({ day, label }) =>
+
+ {label}
+
+ )}
+
+
+ {/* Every other weekday: naming all seven needs more room than the rows have */}
+
+ {weekdays.map(weekday =>
+
+ {weekday % 2 === 0
+ ? new Date(heatmapDayAt(grid, 0, weekday))
+ .toLocaleDateString(i18n.language, { weekday: 'short' })
+ : ''}
+
+ )}
+
+ {/* The grid carries its meaning in colour alone, so it needs a name */}
+
+ {cells}
+
+
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementLineChart.tsx b/src/components/Measurements/widgets/MeasurementLineChart.tsx
new file mode 100644
index 000000000..ee2b2053d
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementLineChart.tsx
@@ -0,0 +1,24 @@
+import { ChartConfig } from "@/components/Measurements/models/Category";
+import { measurementSeries } from "@/components/Measurements/charts/data";
+import { ChartPoint, PlanPeriod } from "@/components/Measurements/charts/series";
+import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
+import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
+
+/** The default chart: the values with their moving average and trend, plus the overall change */
+export const MeasurementLineChart = (props: {
+ unit: string,
+ points: ChartPoint[],
+ cutoff: Date | null,
+ config: ChartConfig,
+ planPeriods?: PlanPeriod[],
+}) => {
+ const series = measurementSeries(props.points, props.cutoff, props.config);
+
+ return <>
+
+
+ >;
+};
diff --git a/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
new file mode 100644
index 000000000..8531499bc
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
@@ -0,0 +1,53 @@
+import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
+import { durationAxis, valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
+import { useTranslation } from "react-i18next";
+import { Bar } from "recharts";
+import { theme } from "@/theme";
+
+const RangeTooltip = (props: TooltipProps & { unit: string }) => {
+ const [, i18n] = useTranslation();
+
+ if (!props.active || !props.payload?.length) {
+ return null;
+ }
+
+ const [low, high] = props.payload[0].value as [number, number];
+
+ return
+ {/* a range is quoted as high over low, the way a blood pressure reading is written */}
+
+ ;
+};
+
+/**
+ * The readings of a two-component group, each as one bar spanning from the
+ * lower component to the upper one.
+ *
+ * A reading is one event: two lines would assert interpolation, but nothing
+ * was measured between two readings, and connecting them buries the thing that
+ * matters, the gap within one reading.
+ */
+export const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
+ const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
+
+ return point.min!)),
+ Math.max(...props.points.map(point => point.max!)),
+ )}
+ tooltip={}>
+
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementStackedBarChart.tsx b/src/components/Measurements/widgets/MeasurementStackedBarChart.tsx
new file mode 100644
index 000000000..295e37e17
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementStackedBarChart.tsx
@@ -0,0 +1,69 @@
+import { componentColor, componentPalette } from "@/components/Measurements/charts/colors";
+import { StackedPoint } from "@/components/Measurements/charts/data";
+import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
+import { durationAxis, valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
+import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
+import { useTranslation } from "react-i18next";
+import { Bar } from "recharts";
+
+/** The whole bar with its parts: a single segment says little without the night it belongs to */
+const StackedTooltip = (props: TooltipProps & { unit: string }) => {
+ const [, i18n] = useTranslation();
+
+ if (!props.active || !props.payload?.length) {
+ return null;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parts = props.payload.filter((entry: any) => entry.value > 0);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const total = parts.reduce((sum: number, entry: any) => sum + entry.value, 0);
+
+ return
+
)}
+ ;
+};
+
+/**
+ * Stacked bar chart for a group whose components are parts of one whole, e.g.
+ * the sleep stages of a night.
+ *
+ * One bar per day, split into a segment per component in the components' own
+ * order, so the bar's height is the night and its segments are how it was
+ * spent. Colours come from the component palette by position, which is what
+ * ties a segment to the row naming it.
+ */
+export const MeasurementStackedBarChart = (props: {
+ points: StackedPoint[],
+ labels: string[],
+ unit: string,
+}) => {
+ const palette = componentPalette(props.labels.length);
+ const data = props.points.map(point => ({
+ date: point.date,
+ ...Object.fromEntries(props.labels.map((label, index) => [label, point.values[index]])),
+ }));
+ // The bar is as tall as its segments together, so that is what the axis
+ // has to cover
+ const totals = props.points.map(
+ point => point.values.reduce((sum: number, value) => sum + (value ?? 0), 0),
+ );
+
+ return }>
+ {props.labels.map((label, index) => )}
+ ;
+};
diff --git a/src/components/Measurements/widgets/chartFrames.tsx b/src/components/Measurements/widgets/chartFrames.tsx
new file mode 100644
index 000000000..0e801c17d
--- /dev/null
+++ b/src/components/Measurements/widgets/chartFrames.tsx
@@ -0,0 +1,77 @@
+import { Box, Paper } from "@mui/material";
+import {
+ dateTick,
+ durationAxis,
+ spansYears,
+ valueWithUnit
+} from "@/components/Measurements/charts/format";
+import { dateToLocale } from "@/core/lib/date";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
+
+export interface TooltipProps {
+ active?: boolean,
+ /** The hovered entries, read by each tooltip the way its own chart wrote them */
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload?: any,
+ label?: string,
+}
+
+/** What every tooltip here shares: the day, and under it what was measured on it */
+export const TooltipFrame = (props: { label?: string, children: React.ReactNode }) =>
+
+
{dateToLocale(new Date(Number(props.label)))}
+ {props.children}
+ ;
+
+/**
+ * The frame every bar chart here is drawn in: the grid, the date axis and the
+ * value axis, which only differ in the unit they read. The bars themselves are
+ * the caller's, they are what each chart is about.
+ */
+export const BarChartFrame = (props: {
+ data: { date: number }[],
+ unit: string,
+ /** Where the value axis starts for a unit that brings no axis of its own */
+ domainStart: 0 | 'auto',
+ axis: ReturnType,
+ tooltip: React.ReactElement,
+ ariaLabel?: string,
+ children: React.ReactNode,
+}) => {
+ const [, i18n] = useTranslation();
+
+ return
+ {/*
+ * Bar width follows from how many bars share the width: recharts
+ * sizes them to the band, the gap (taken off both sides, so a bar
+ * keeps 70% of its band) holds neighbours apart, and the maximum
+ * keeps a handful of bars from becoming blocks
+ */}
+
+
+
+ valueWithUnit(value, props.unit, i18n.language)} />
+
+ {props.children}
+
+ ;
+};
From b5d2c945bb9b2a3cbec0e09d63419e4e91bf0ad8 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 16:24:31 +0200
Subject: [PATCH 062/102] Add blood oxygen as a metric type
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
src/components/Measurements/models/Category.ts | 6 ++++++
5 files changed, 10 insertions(+)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 2665146c4..3f47a938f 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -292,6 +292,7 @@
"blood_pressure_diastolic": "Diastolisch",
"heart_rate": "Herzfrequenz",
"resting_heart_rate": "Ruhepuls",
+ "blood_oxygen": "Sauerstoffsättigung",
"steps": "Schritte",
"distance": "Distanz",
"energy": "Energie",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index cf6b0213b..ec47008e5 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -376,6 +376,7 @@
"blood_pressure_diastolic": "Diastolic",
"heart_rate": "Heart rate",
"resting_heart_rate": "Resting heart rate",
+ "blood_oxygen": "Blood oxygen",
"steps": "Steps",
"distance": "Distance",
"energy": "Energy",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 84157171a..d51ed2b0d 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -297,6 +297,7 @@
"blood_pressure_diastolic": "Diastólica",
"heart_rate": "Frecuencia cardíaca",
"resting_heart_rate": "Frecuencia cardíaca en reposo",
+ "blood_oxygen": "Saturación de oxígeno",
"steps": "Pasos",
"distance": "Distancia",
"energy": "Energía",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index aaf8aeb1f..8d4a889d4 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -380,6 +380,7 @@
"blood_pressure_diastolic": "Diastolique",
"heart_rate": "Fréquence cardiaque",
"resting_heart_rate": "Fréquence cardiaque au repos",
+ "blood_oxygen": "Saturation en oxygène",
"steps": "Pas",
"distance": "Distance",
"energy": "Énergie",
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 7533419d0..a4248f834 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -13,6 +13,7 @@ export const METRIC_TYPES = [
'blood_pressure_diastolic',
'heart_rate',
'resting_heart_rate',
+ 'blood_oxygen',
'steps',
'distance',
'energy',
@@ -249,6 +250,7 @@ const METRIC_DEFAULTS: Partial> = {
blood_pressure_diastolic: { min: 30, max: 150, softMin: 50, softMax: 110 },
heart_rate: { min: 30, max: 250, softMin: 40, softMax: 200 },
resting_heart_rate: { min: 30, max: 120, softMin: 40, softMax: 100 },
+ // A saturation cannot exceed 100 %, and the floor is deliberately far below
+ // what a pulse oximeter still displays
+ blood_oxygen: { min: 50, max: 100, softMin: 90, softMax: 100 },
// The cumulative types hold a whole day, and a rest day really is 0 steps
steps: { min: 0, max: 100000, softMin: 0, softMax: 30000 },
distance: { min: 0, max: 500, softMin: 0, softMax: 30 },
@@ -355,6 +360,7 @@ const BIN_WIDTHS: Partial> = {
blood_pressure_diastolic: 5,
heart_rate: 2,
resting_heart_rate: 1,
+ blood_oxygen: 1,
steps: 1000,
distance: 1,
energy: 100,
From 6253aa14506856fa2ed8f270c8ba5ab4d24da4b0 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 16:48:51 +0200
Subject: [PATCH 063/102] Add lean body mass as a metric type
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
src/components/Measurements/models/Category.ts | 7 ++++++-
5 files changed, 10 insertions(+), 1 deletion(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 3f47a938f..0b75b8516 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -286,6 +286,7 @@
"custom": "Benutzerdefiniert",
"body_weight": "Körpergewicht",
"body_fat": "Körperfett",
+ "lean_body_mass": "Magermasse",
"height": "Körpergröße",
"blood_pressure": "Blutdruck",
"blood_pressure_systolic": "Systolisch",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index ec47008e5..59249a2d6 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -370,6 +370,7 @@
"custom": "Custom",
"body_weight": "Body weight",
"body_fat": "Body fat",
+ "lean_body_mass": "Lean body mass",
"height": "Height",
"blood_pressure": "Blood pressure",
"blood_pressure_systolic": "Systolic",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index d51ed2b0d..cf8ac211c 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -291,6 +291,7 @@
"custom": "Personalizado",
"body_weight": "Peso corporal",
"body_fat": "Grasa corporal",
+ "lean_body_mass": "Masa magra",
"height": "Altura",
"blood_pressure": "Presión arterial",
"blood_pressure_systolic": "Sistólica",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 8d4a889d4..b5425d7d1 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -374,6 +374,7 @@
"custom": "Personnalisé",
"body_weight": "Poids corporel",
"body_fat": "Graisse corporelle",
+ "lean_body_mass": "Masse maigre",
"height": "Taille",
"blood_pressure": "Pression artérielle",
"blood_pressure_systolic": "Systolique",
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index a4248f834..93843a442 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -7,6 +7,7 @@ export const METRIC_TYPES = [
'custom',
'body_weight',
'body_fat',
+ 'lean_body_mass',
'height',
'blood_pressure',
'blood_pressure_systolic',
@@ -190,7 +191,7 @@ export function resolveChartType(type: MetricType, picked: ChartType): ChartType
* they qualify; the typed health metrics do not.
*/
export function correlatesWithNutrition(type: MetricType): boolean {
- return type === 'body_weight' || type === 'body_fat' || type === 'custom';
+ return type === 'body_weight' || type === 'body_fat' || type === 'lean_body_mass' || type === 'custom';
}
/**
@@ -244,6 +245,7 @@ export function isPickableMetricType(type: MetricType): boolean {
const METRIC_DEFAULTS: Partial> = {
body_weight: { name: 'Weight', unit: 'kg' },
body_fat: { name: 'Body fat', unit: '%' },
+ lean_body_mass: { name: 'Lean body mass', unit: 'kg' },
height: { name: 'Height', unit: 'cm' },
blood_pressure: { name: 'Blood pressure', unit: 'mmHg' },
blood_pressure_systolic: { name: 'Systolic', unit: 'mmHg' },
@@ -299,6 +301,8 @@ export interface MetricLimits {
/* eslint-disable camelcase */
const METRIC_LIMITS: Partial> = {
body_fat: { min: 2, max: 60, softMin: 5, softMax: 50 },
+ // Always below the body weight it is part of, so the floor can sit lower
+ lean_body_mass: { min: 10, max: 250, softMin: 30, softMax: 90 },
height: { min: 50, max: 250, softMin: 140, softMax: 210 },
blood_pressure_systolic: { min: 50, max: 250, softMin: 90, softMax: 180 },
blood_pressure_diastolic: { min: 30, max: 150, softMin: 50, softMax: 110 },
@@ -355,6 +359,7 @@ export function limitsFor(type: MetricType, unit?: string): MetricLimits {
/* eslint-disable camelcase */
const BIN_WIDTHS: Partial> = {
body_fat: 0.5,
+ lean_body_mass: 0.5,
height: 1,
blood_pressure_systolic: 5,
blood_pressure_diastolic: 5,
From 61c519dafc1519151d434a3730f11cb1f0586e02 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 17:04:16 +0200
Subject: [PATCH 064/102] Drop the "count" unit from step categories
---
src/components/Measurements/charts/format.ts | 10 ++++++++--
src/components/Measurements/models/Category.ts | 3 ++-
.../screens/MeasurementCategoryOverview.tsx | 6 +++++-
3 files changed, 15 insertions(+), 4 deletions(-)
diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts
index 1971c462d..96e836ec9 100644
--- a/src/components/Measurements/charts/format.ts
+++ b/src/components/Measurements/charts/format.ts
@@ -54,9 +54,15 @@ export const valueOnly = (value: number, unit: string, locale: string): string =
*/
export const unitLabel = (unit: string): string => unit === MINUTES ? 'h' : unit;
-/** A measured value with its unit, both localised */
+/**
+ * A measured value with its unit, both localised. A value stands on its own
+ * where there is no unit: a step count is a bare number, and so may be a
+ * free-form category.
+ */
export const valueWithUnit = (value: number, unit: string, locale: string): string =>
- `${valueOnly(value, unit, locale)} ${unitLabel(unit)}`;
+ unit === ''
+ ? valueOnly(value, unit, locale)
+ : `${valueOnly(value, unit, locale)} ${unitLabel(unit)}`;
/** Ticks a duration axis aims for, few enough that the labels stay apart */
const DURATION_TICKS = 6;
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 93843a442..ef453115b 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -253,7 +253,8 @@ const METRIC_DEFAULTS: Partial
-
+ {/* A category without a unit gets no subheader rather than an empty one */}
+
From c7c4f4745b5c54b933b3b318fac5a38098d6658a Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 8 Aug 2026 00:42:23 +0200
Subject: [PATCH 065/102] Lay the measurement overview out as a grid of linked
cards
---
.../MeasurementCategoryOverview.test.tsx | 30 +++++++-
.../screens/MeasurementCategoryOverview.tsx | 75 ++++++++++++-------
src/core/ui/Widgets/Container.tsx | 2 +
3 files changed, 77 insertions(+), 30 deletions(-)
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
index 2850228e6..a7d609a32 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
@@ -1,11 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
-import { useMeasurementsCategoryQuery, useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
+import {
+ useLatestMeasurementEntriesQuery,
+ useMeasurementsCategoryQuery,
+ useReorderMeasurementCategoriesQuery
+} from "@/components/Measurements/queries";
import { MeasurementCategoryOverview } from "@/components/Measurements/screens/MeasurementCategoryOverview";
import React from 'react';
import { BrowserRouter } from "react-router-dom";
import { mockChartQueries } from "@/tests/chartQueries";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
TEST_MEASUREMENT_CATEGORY_1,
TEST_MEASUREMENT_CATEGORY_2,
@@ -29,6 +34,12 @@ describe("Test the MeasurementCategoryOverview component", () => {
(useReorderMeasurementCategoriesQuery as Mock).mockImplementation(() => ({
mutate: vi.fn()
}));
+ // The card headers show the newest entry of their category
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation((ids: string[]) => ({
+ data: [new MeasurementEntry(
+ '22222222-2222-4222-8222-222222222222', ids[0], new Date(), 42.5, '',
+ )]
+ }));
// The cards read their points from the aggregated queries
mockChartQueries([TEST_MEASUREMENT_SEED_1, TEST_MEASUREMENT_SEED_2]);
});
@@ -52,6 +63,17 @@ describe("Test the MeasurementCategoryOverview component", () => {
expect(await screen.findByText('Biceps')).toBeInTheDocument();
expect(screen.getByText('measurements.measurements')).toBeInTheDocument();
expect(screen.getByText('Body fat')).toBeInTheDocument();
+
+ // The whole card links to its category
+ expect(screen.getByText('Biceps').closest('a')).toHaveAttribute(
+ 'href',
+ expect.stringContaining(`/measurement/category/${TEST_MEASUREMENT_CATEGORY_1.id}`)
+ );
+
+ // The header carries the newest value in the category's unit; the
+ // decimal separator follows the runtime locale
+ expect(screen.getByText(/42[.,]5 cm/)).toBeInTheDocument();
+ expect(screen.getByText(/42[.,]5 %/)).toBeInTheDocument();
});
test('the add button waits while the categories are read again', async () => {
@@ -74,8 +96,10 @@ describe("Test the MeasurementCategoryOverview component", () => {
);
- // Assert
- const fab = screen.getByLabelText('add');
+ // Assert - the quick-add buttons on the cards carry the same label,
+ // so the fab is told apart by its class
+ const fab = screen.getAllByLabelText('add').find(b => b.classList.contains('MuiFab-root'))!;
+ expect(fab).toBeDefined();
expect(fab).toBeDisabled();
expect(fab.querySelector('[data-testid="AddIcon"]')).toBeNull();
expect(fab.querySelector('.MuiCircularProgress-root')).toBeInTheDocument();
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index 1f0eaa7a6..f943d3c47 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -1,18 +1,28 @@
import React from "react";
-import { Button, Card, CardActions, CardContent, CardHeader, IconButton, Stack, Tooltip, } from "@mui/material";
+import {
+ Box,
+ Card,
+ CardActionArea,
+ CardActions,
+ CardContent,
+ CardHeader,
+ IconButton,
+ Stack,
+ Tooltip,
+} from "@mui/material";
import AddIcon from '@mui/icons-material/Add';
import SortIcon from '@mui/icons-material/Sort';
import { useTranslation } from "react-i18next";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries";
import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
-import { unitLabel } from "@/components/Measurements/charts/format";
+import { CategoryLatestValue } from "@/components/Measurements/widgets/CategoryLatestValue";
import { ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty";
import { AddMeasurementCategoryFab } from "@/components/Measurements/widgets/fab";
-import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
+import { WgerContainerFullWidth } from "@/core/ui/Widgets/Container";
import { makeLink, WgerLink } from "@/core/lib/url";
import { Link } from "react-router-dom";
import { CategoryReorderList } from "@/components/Measurements/widgets/CategoryReorderList";
@@ -28,23 +38,26 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
const handleCloseModal = () => setOpenModal(false);
return <>
-
- {/* A category without a unit gets no subheader rather than an empty one */}
-
-
-
-
-
-
-
- {t("seeDetails")}
-
-
-
-
+ {/* The whole card is the way into the category; only the quick-add
+ * button below stays a control of its own */}
+
+
+ {/* The unit rides on the value; a category still without one
+ * shows it on its chart axis instead */}
+ }
+ />
+
+
+
+
+ {/* mt: auto pins the action row, so it aligns across a grid row of
+ * cards with differently sized charts */}
+
+
@@ -68,7 +81,7 @@ export const MeasurementCategoryOverview = () => {
return categoryQuery.isLoading
?
: <>
-
@@ -77,16 +90,24 @@ export const MeasurementCategoryOverview = () => {
}
- mainContent={
+ fab={}
+ >
+
{categoryQuery.data!.length === 0 && }
{categoryQuery.data!.length > 0
&& }
- {categoryQuery.data!.map(c =>
- )}
+ {/* min() keeps the column from forcing a horizontal scroll
+ * on screens narrower than one card */}
+
+ {categoryQuery.data!.map(c =>
+ )}
+
- }
- fab={}
- />
+
{
@@ -93,6 +94,7 @@ export const WgerContainerFullWidth = (props: WgerTemplateContainerFullWidthProp
{props.children}
+ {props.fab}
);
};
\ No newline at end of file
From 3bd41dd1944ffa743bfcdd9d078c4c72021a150c Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 8 Aug 2026 17:01:22 +0200
Subject: [PATCH 066/102] Lay the measurement overview out as a grid of linked
cards
---
public/locales/de/translation.json | 2 +
public/locales/en/translation.json | 2 +
public/locales/es/translation.json | 3 +
public/locales/fr/translation.json | 3 +
.../Dashboard/TrophiesCard.test.tsx | 52 +++++-
src/components/Dashboard/TrophiesCard.tsx | 4 +-
.../Measurements/api/measurements.ts | 23 +++
.../Measurements/charts/range.test.ts | 6 +
src/components/Measurements/charts/range.ts | 17 +-
src/components/Measurements/queries/index.ts | 16 ++
.../widgets/CategoryLatestValue.test.tsx | 150 ++++++++++++++++++
.../widgets/CategoryLatestValue.tsx | 83 ++++++++++
.../widgets/ChartRangeSelector.tsx | 2 +
src/core/lib/date.test.ts | 22 ++-
src/core/lib/date.ts | 29 ++++
15 files changed, 402 insertions(+), 12 deletions(-)
create mode 100644 src/components/Measurements/widgets/CategoryLatestValue.test.tsx
create mode 100644 src/components/Measurements/widgets/CategoryLatestValue.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 0b75b8516..bd1e04b6e 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -311,6 +311,8 @@
"chartRangeAll": "Gesamt",
"chartRangeMonths_one": "1 Monat",
"chartRangeMonths_other": "{{count}} Monate",
+ "chartRangeWeeks_one": "1 Woche",
+ "chartRangeWeeks_other": "{{count}} Wochen",
"chartRangeYears_one": "1 Jahr",
"chartRangeYears_other": "{{count}} Jahre",
"customMeasurement": "Eigene Messung",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 59249a2d6..904ff17ef 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -396,6 +396,8 @@
"chartRangeAll": "All",
"chartRangeMonths_one": "1 month",
"chartRangeMonths_other": "{{count}} months",
+ "chartRangeWeeks_one": "1 week",
+ "chartRangeWeeks_other": "{{count}} weeks",
"chartRangeYears_one": "1 year",
"chartRangeYears_other": "{{count}} years",
"customMeasurement": "Custom measurement",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index cf8ac211c..42a418659 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -317,6 +317,9 @@
"chartRangeMonths_one": "1 mes",
"chartRangeMonths_other": "{{count}} meses",
"chartRangeMonths_many": "{{count}} meses",
+ "chartRangeWeeks_one": "1 semana",
+ "chartRangeWeeks_other": "{{count}} semanas",
+ "chartRangeWeeks_many": "{{count}} semanas",
"chartRangeYears_one": "1 año",
"chartRangeYears_other": "{{count}} años",
"chartRangeYears_many": "{{count}} años",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index b5425d7d1..81844eabe 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -400,6 +400,9 @@
"chartRangeMonths_one": "1 mois",
"chartRangeMonths_other": "{{count}} mois",
"chartRangeMonths_many": "{{count}} mois",
+ "chartRangeWeeks_one": "1 semaine",
+ "chartRangeWeeks_other": "{{count}} semaines",
+ "chartRangeWeeks_many": "{{count}} semaines",
"chartRangeYears_one": "1 an",
"chartRangeYears_other": "{{count}} ans",
"chartRangeYears_many": "{{count}} ans",
diff --git a/src/components/Dashboard/TrophiesCard.test.tsx b/src/components/Dashboard/TrophiesCard.test.tsx
index 46f55b668..9d8fd6190 100644
--- a/src/components/Dashboard/TrophiesCard.test.tsx
+++ b/src/components/Dashboard/TrophiesCard.test.tsx
@@ -1,9 +1,9 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
import { TrophiesCard } from "@/components/Dashboard/TrophiesCard";
-import { useUserTrophiesQuery } from "@/components/Trophies";
+import { UserTrophy, useUserTrophiesQuery } from "@/components/Trophies";
import { testQueryClient } from "@/tests/queryClient";
-import { testUserTrophies } from "@/tests/trophies/trophiesTestData";
+import { testTrophies, testUserTrophies } from "@/tests/trophies/trophiesTestData";
import type { Mock } from 'vitest';
vi.mock("@/components/Trophies/queries/trophies");
@@ -35,6 +35,54 @@ describe("test the TrophiesCard component", () => {
});
+ describe("Same trophy awarded twice", () => {
+ beforeEach(() => {
+ // Two user-trophy rows for one trophy, as a repeatable award creates
+ const trophy = testTrophies()[0];
+ (useUserTrophiesQuery as Mock).mockImplementation(() => ({
+ isSuccess: true,
+ isLoading: false,
+ data: [
+ new UserTrophy({
+ id: 1,
+ trophy: trophy,
+ earnedAt: new Date('2025-12-19T10:00:00Z'),
+ progress: 100,
+ isNotified: true,
+ }),
+ new UserTrophy({
+ id: 2,
+ trophy: trophy,
+ earnedAt: new Date('2025-12-20T10:00:00Z'),
+ progress: 100,
+ isNotified: true,
+ }),
+ ]
+ }));
+ });
+
+ test('renders both awards, with unique keys', async () => {
+ // Arrange
+ const errorSpy = vi.spyOn(console, 'error');
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.getAllByText('Beginner')).toHaveLength(2);
+ const duplicateKeyErrors = errorSpy.mock.calls.filter(
+ (args) => String(args[0]).includes('same key')
+ );
+ expect(duplicateKeyErrors).toHaveLength(0);
+ errorSpy.mockRestore();
+ });
+ });
+
+
describe("No trophies available", () => {
beforeEach(() => {
diff --git a/src/components/Dashboard/TrophiesCard.tsx b/src/components/Dashboard/TrophiesCard.tsx
index 0aa87edc4..a5f95b812 100644
--- a/src/components/Dashboard/TrophiesCard.tsx
+++ b/src/components/Dashboard/TrophiesCard.tsx
@@ -46,8 +46,10 @@ function TrophiesCardContent(props: { trophies: UserTrophy[] }) {
>
+ {/* Keyed by the user-trophy row: repeatable trophies can
+ * legitimately award the same trophy more than once */}
{props.trophies.map((userTrophy) => (
-
+ => {
+ const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
+ query: {
+ category__in: categoryIds.join(','),
+ limit: categoryIds.length,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData));
+};
+
/**
* The oldest entry the filter matches, or none at all.
*
diff --git a/src/components/Measurements/charts/range.test.ts b/src/components/Measurements/charts/range.test.ts
index b09ca7a7f..d56f1575f 100644
--- a/src/components/Measurements/charts/range.test.ts
+++ b/src/components/Measurements/charts/range.test.ts
@@ -18,6 +18,12 @@ describe('fetchCutoffFor', () => {
expect(fetchCutoffFor('lastYear', noon)).toStrictEqual(new Date(2025, 4, 16));
});
+ test('a week is today plus the six days before it', () => {
+ // 2026-06-15 minus 6 days minus the 30 day average lead
+ expect(fetchCutoffFor('lastWeek', noon)).toStrictEqual(new Date(2026, 4, 10));
+ expect(displayCutoffFor('lastWeek', noon)).toStrictEqual(new Date(2026, 5, 9));
+ });
+
test('is stable across the day, so it can go into a query key', () => {
// Derived from the current instant it would differ on every render,
// and the query would refetch forever
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index ad29108c6..29ea1926e 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -2,12 +2,10 @@ import { AVERAGE_WINDOWS } from "@/components/Measurements/models/Category";
import { ChartPoint } from "@/components/Measurements/charts/series";
/**
- * How far back the charts go.
- *
- * The default is the shortest one: a chart is only readable if the span it
- * covers is, and the recent values are what tracking progress is about.
+ * How far back the charts go, in the order the selector offers them: widest
+ * first, narrowing left to right, like the flutter app.
*/
-export const CHART_RANGES = ['lastMonth', 'last3Months', 'lastYear', 'all'] as const;
+export const CHART_RANGES = ['all', 'lastYear', 'last3Months', 'lastMonth', 'lastWeek'] as const;
export type ChartRange = typeof CHART_RANGES[number];
export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
@@ -15,10 +13,13 @@ export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
const DAY_MS = 24 * 60 * 60 * 1000;
const DAYS: Record = {
- lastMonth: 30,
- last3Months: 90,
- lastYear: 365,
all: null,
+ lastYear: 365,
+ last3Months: 90,
+ lastMonth: 30,
+ // Six, not seven: the cutoff lands six days back, so the window is today
+ // plus the six days before it, i.e. one week of calendar days
+ lastWeek: 6,
};
/** Oldest date still shown, null for the full history */
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index b75d8ed16..7346a22b3 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -8,6 +8,7 @@ import {
BucketLevel,
getAllMeasurementEntries,
getCategoryEntryFlags,
+ getLatestMeasurementEntries,
getMeasurementBuckets,
getMeasurementCategories,
getMeasurementCategory,
@@ -155,6 +156,21 @@ export function useMeasurementEntriesQuery(
});
}
+/**
+ * The newest entries of a category, or of a group's components together, see
+ * getLatestMeasurementEntries. Under the entry key, so every write refreshes
+ * it along with the other entry reads.
+ */
+export function useLatestMeasurementEntriesQuery(categoryIds: string[]) {
+ return useQuery({
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'latest', categoryIds],
+ queryFn: () => getLatestMeasurementEntries(categoryIds),
+ // A group synced without its components yet has nothing to ask for
+ enabled: categoryIds.length > 0,
+ placeholderData: keepPreviousData,
+ });
+}
+
/**
* One page of a category's entries, for the tables that show a page at a time.
*
diff --git a/src/components/Measurements/widgets/CategoryLatestValue.test.tsx b/src/components/Measurements/widgets/CategoryLatestValue.test.tsx
new file mode 100644
index 000000000..11de87833
--- /dev/null
+++ b/src/components/Measurements/widgets/CategoryLatestValue.test.tsx
@@ -0,0 +1,150 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from '@testing-library/react';
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { useLatestMeasurementEntriesQuery } from "@/components/Measurements/queries";
+import {
+ CategoryLatestValue,
+ latestHeadline
+} from "@/components/Measurements/widgets/CategoryLatestValue";
+import React from 'react';
+import { getTestQueryClient } from "@/tests/queryClient";
+import { TEST_MEASUREMENT_CATEGORY_1 } from "@/tests/measurementsTestData";
+import type { Mock } from 'vitest';
+
+vi.mock("@/components/Measurements/queries");
+
+const entryFor = (categoryId: string, value: number, date: Date) =>
+ new MeasurementEntry('11111111-1111-4111-8111-111111111111', categoryId, date, value, '');
+
+const bloodPressureGroup = () => {
+ const group = new MeasurementCategory('bp', 'Blood pressure', 'mmHg', 'blood_pressure');
+ group.children = [
+ new MeasurementCategory('sys', 'Systolic', 'mmHg', 'blood_pressure_systolic'),
+ new MeasurementCategory('dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic'),
+ ];
+ return group;
+};
+
+const sleepGroup = () => {
+ const group = new MeasurementCategory('sleep', 'Sleep', 'min', 'sleep');
+ group.children = [
+ new MeasurementCategory('total', 'Total sleep', 'min', 'sleep_total'),
+ new MeasurementCategory('deep', 'Deep sleep', 'min', 'sleep_deep'),
+ ];
+ return group;
+};
+
+describe('latestHeadline', () => {
+
+ test('a leaf reads as its newest entry', () => {
+ const entries = [entryFor(TEST_MEASUREMENT_CATEGORY_1.id!, 42.5, new Date(2026, 7, 1))];
+
+ expect(latestHeadline(TEST_MEASUREMENT_CATEGORY_1, entries, 'de')).toBe('42,5 cm');
+ });
+
+ test('a paired two-component reading is quoted high over low', () => {
+ const date = new Date(2026, 7, 1, 8, 0);
+ const entries = [
+ entryFor('sys', 130, date),
+ entryFor('dia', 82, date),
+ ];
+
+ expect(latestHeadline(bloodPressureGroup(), entries, 'de')).toBe('130/82 mmHg');
+ });
+
+ test('an unpaired half-reading shows no value', () => {
+ const entries = [
+ entryFor('sys', 130, new Date(2026, 7, 2, 8, 0)),
+ entryFor('dia', 82, new Date(2026, 7, 1, 8, 0)),
+ ];
+
+ expect(latestHeadline(bloodPressureGroup(), entries, 'de')).toBeNull();
+ });
+
+ test('a group with a roll-up component reads as that component', () => {
+ const date = new Date(2026, 7, 1);
+ const entries = [
+ entryFor('deep', 95, date),
+ entryFor('total', 432, date),
+ ];
+
+ expect(latestHeadline(sleepGroup(), entries, 'de')).toBe('7:12 h');
+ });
+});
+
+describe("Test the CategoryLatestValue component", () => {
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const renderComponent = (category: MeasurementCategory) => render(
+
+
+
+ );
+
+ test('shows the newest value with how long ago it was measured', () => {
+
+ // Arrange - measured today, so the phrasing holds in any test locale
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation(() => ({
+ data: [entryFor(TEST_MEASUREMENT_CATEGORY_1.id!, 42.5, new Date())]
+ }));
+
+ // Act
+ renderComponent(TEST_MEASUREMENT_CATEGORY_1);
+
+ // Assert - the decimal separator follows the runtime locale
+ expect(useLatestMeasurementEntriesQuery).toHaveBeenCalledWith([TEST_MEASUREMENT_CATEGORY_1.id]);
+ expect(screen.getByText(/42[.,]5 cm/)).toBeInTheDocument();
+ expect(screen.getByText(/heute|today/i)).toBeInTheDocument();
+ });
+
+ test('a group asks for its components', () => {
+
+ // Arrange - unpaired halves: the time still shows, a value would lie
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation(() => ({
+ data: [
+ entryFor('sys', 130, new Date()),
+ entryFor('dia', 82, new Date(2026, 6, 1)),
+ ]
+ }));
+
+ // Act
+ renderComponent(bloodPressureGroup());
+
+ // Assert
+ expect(useLatestMeasurementEntriesQuery).toHaveBeenCalledWith(['sys', 'dia']);
+ expect(screen.queryByText(/mmHg/)).toBeNull();
+ expect(screen.getByText(/heute|today/i)).toBeInTheDocument();
+ });
+
+ test('a group with a roll-up component asks for it alone', () => {
+
+ // Arrange - the sibling stages can hold several rows per day, so the
+ // roll-up is queried by itself
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation(() => ({
+ data: [entryFor('total', 432, new Date())]
+ }));
+
+ // Act
+ renderComponent(sleepGroup());
+
+ // Assert
+ expect(useLatestMeasurementEntriesQuery).toHaveBeenCalledWith(['total']);
+ expect(screen.getByText('7:12 h')).toBeInTheDocument();
+ });
+
+ test('renders nothing while there are no entries', () => {
+
+ // Arrange
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation(() => ({ data: [] }));
+
+ // Act
+ const { container } = renderComponent(TEST_MEASUREMENT_CATEGORY_1);
+
+ // Assert
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/Measurements/widgets/CategoryLatestValue.tsx b/src/components/Measurements/widgets/CategoryLatestValue.tsx
new file mode 100644
index 000000000..56b56b2ec
--- /dev/null
+++ b/src/components/Measurements/widgets/CategoryLatestValue.tsx
@@ -0,0 +1,83 @@
+import { Stack, Typography } from "@mui/material";
+import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
+import { isGroupTotalMetricType, MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { useLatestMeasurementEntriesQuery } from "@/components/Measurements/queries";
+import { dateToRelative } from "@/core/lib/date";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+/**
+ * The value the newest entries of a category read as, null when they don't
+ * read as one.
+ *
+ * A leaf is its newest entry. A group with a roll-up component (total sleep)
+ * is that component's newest value. A two-component group whose newest
+ * entries share a timestamp is that reading, quoted high over low the way a
+ * blood pressure is written; an unpaired half would read as a whole reading,
+ * so it shows nothing.
+ */
+export const latestHeadline = (
+ category: MeasurementCategory,
+ entries: MeasurementEntry[],
+ locale: string,
+): string | null => {
+ const valueOf = (entry: MeasurementEntry) => entry.valueIn(category.unit, category.unit);
+
+ if (!category.isGroup) {
+ return valueWithUnit(valueOf(entries[0]), category.unit, locale);
+ }
+
+ const total = category.children.find(child => isGroupTotalMetricType(child.metricType));
+ if (total !== undefined) {
+ const entry = entries.find(e => e.category === total.id);
+ return entry === undefined ? null : valueWithUnit(valueOf(entry), category.unit, locale);
+ }
+
+ if (category.children.length === 2
+ && entries.length === 2
+ && entries[0].date.getTime() === entries[1].date.getTime()) {
+ const values = entries.map(valueOf);
+ return `${valueOnly(Math.max(...values), category.unit, locale)}/`
+ + valueWithUnit(Math.min(...values), category.unit, locale);
+ }
+
+ return null;
+};
+
+/**
+ * The category's newest value and how long ago it was measured, for a card
+ * header.
+ *
+ * The time stands on its own where the entries don't read as one value, and
+ * for a category the health sync feeds only every now and then it is what
+ * says an old-looking chart is not a broken one.
+ */
+export const CategoryLatestValue = ({ category }: { category: MeasurementCategory }) => {
+ const [, i18n] = useTranslation();
+ // A group with a roll-up component asks for that component alone: its
+ // siblings can hold several rows per day (raw sleep segments), so the
+ // newest-entries window across all of them may miss the roll-up.
+ const total = category.children.find(child => isGroupTotalMetricType(child.metricType));
+ const ids = total !== undefined
+ ? [total.id!]
+ : category.isGroup
+ ? category.children.map(child => child.id!)
+ : [category.id!];
+ const query = useLatestMeasurementEntriesQuery(ids);
+ const entries = query.data ?? [];
+
+ if (entries.length === 0) {
+ return null;
+ }
+ const headline = latestHeadline(category, entries, i18n.language);
+
+ return (
+
+ {headline !== null && {headline}}
+
+ {dateToRelative(entries[0].date, i18n.language)}
+
+
+ );
+};
diff --git a/src/components/Measurements/widgets/ChartRangeSelector.tsx b/src/components/Measurements/widgets/ChartRangeSelector.tsx
index 077fea5c1..b9ddb6ff3 100644
--- a/src/components/Measurements/widgets/ChartRangeSelector.tsx
+++ b/src/components/Measurements/widgets/ChartRangeSelector.tsx
@@ -10,6 +10,8 @@ import { useTranslation } from "react-i18next";
*/
const rangeLabel = (range: ChartRange, t: TFunction): string => {
switch (range) {
+ case 'lastWeek':
+ return t('measurements.chartRangeWeeks', { count: 1 });
case 'lastMonth':
return t('measurements.chartRangeMonths', { count: 1 });
case 'last3Months':
diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts
index 0a711e866..ad0ae8db4 100644
--- a/src/core/lib/date.test.ts
+++ b/src/core/lib/date.test.ts
@@ -1,4 +1,4 @@
-import { dateTimeToHHMM, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date";
+import { dateTimeToHHMM, dateToRelative, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date";
/*
* All date helpers must behave the same in every timezone, so the whole suite
@@ -84,4 +84,24 @@ describe.each([
});
});
+
+ describe('dateToRelative', () => {
+ const now = new Date(2026, 7, 7, 9, 0);
+
+ test('today and yesterday are named, not counted', () => {
+ expect(dateToRelative(new Date(2026, 7, 7, 0, 30), 'de', now)).toBe('heute');
+ // Calendar days, not elapsed hours: late yesterday is yesterday
+ expect(dateToRelative(new Date(2026, 7, 6, 23, 50), 'de', now)).toBe('gestern');
+ });
+
+ test('recent dates count in days', () => {
+ expect(dateToRelative(new Date(2026, 7, 2), 'de', now)).toBe('vor 5 Tagen');
+ });
+
+ test('older dates grow to weeks, months and years', () => {
+ expect(dateToRelative(new Date(2026, 6, 17), 'de', now)).toBe('vor 3 Wochen');
+ expect(dateToRelative(new Date(2026, 5, 1), 'de', now)).toBe('vor 2 Monaten');
+ expect(dateToRelative(new Date(2024, 7, 1), 'de', now)).toBe('vor 2 Jahren');
+ });
+ });
});
diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts
index f41ea2ad9..17f16058a 100644
--- a/src/core/lib/date.ts
+++ b/src/core/lib/date.ts
@@ -10,6 +10,35 @@ export function isSameDay(date1: Date, date2: Date): boolean {
);
}
+/*
+ * A date as a relative phrase ("today", "3 weeks ago"), in the locale's own
+ * words via Intl.
+ *
+ * Counts calendar days rather than elapsed hours, so an entry from late
+ * yesterday still reads as yesterday this morning. The unit grows with the
+ * distance: days within a week, then weeks, months, years.
+ */
+export function dateToRelative(date: Date, locale?: string, now: Date = new Date()): string {
+ const dayMs = 24 * 60 * 60 * 1000;
+ // Rounded because a DST day is 23 or 25 hours long
+ const days = Math.round((
+ new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
+ - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
+ ) / dayMs);
+
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
+ if (Math.abs(days) < 7) {
+ return format.format(-days, 'day');
+ }
+ if (Math.abs(days) < 31) {
+ return format.format(-Math.round(days / 7), 'week');
+ }
+ if (Math.abs(days) < 365) {
+ return format.format(-Math.round(days / 30), 'month');
+ }
+ return format.format(-Math.round(days / 365), 'year');
+}
+
/*
* Util function that converts a date to a YYYY-MM-DD string
*
From 43d8e99cc06798582535f036b1c5a6e300bb1852 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 8 Aug 2026 17:14:47 +0200
Subject: [PATCH 067/102] Show the card headlines at the metric's own
resolution
---
.../Measurements/charts/format.test.ts | 5 ++++
src/components/Measurements/charts/format.ts | 16 ++++++++-----
.../Measurements/models/Category.ts | 23 +++++++++++++++++++
.../widgets/CategoryLatestValue.test.tsx | 13 +++++++++++
.../widgets/CategoryLatestValue.tsx | 16 +++++++++----
src/core/lib/numbers.ts | 9 ++++----
6 files changed, 67 insertions(+), 15 deletions(-)
diff --git a/src/components/Measurements/charts/format.test.ts b/src/components/Measurements/charts/format.test.ts
index b28a2b18d..cdc316c7c 100644
--- a/src/components/Measurements/charts/format.test.ts
+++ b/src/components/Measurements/charts/format.test.ts
@@ -50,6 +50,11 @@ describe('valueWithUnit', () => {
expect(valueWithUnit(1234.5, 'kcal', 'de')).toBe('1.234,5 kcal');
});
+ test('caps the fraction digits for at-a-glance readings', () => {
+ expect(valueWithUnit(61.87, 'bpm', 'en', 0)).toBe('62 bpm');
+ expect(valueWithUnit(82.46, 'kg', 'en', 1)).toBe('82.5 kg');
+ });
+
test('shows a value stored in minutes as hours and minutes', () => {
expect(valueWithUnit(452, 'min', 'de')).toBe('7:32 h');
});
diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts
index 96e836ec9..d2cd3fc74 100644
--- a/src/components/Measurements/charts/format.ts
+++ b/src/components/Measurements/charts/format.ts
@@ -44,9 +44,13 @@ export const hoursAndMinutes = (minutes: number, locale: string): string => {
/**
* A measured value on its own, formatted the way its unit is read. For the
* ends of a range, where only the last one carries the unit.
+ *
+ * [decimals] caps the fraction digits, for at-a-glance readings (see
+ * displayDecimalsFor); without it the stored precision shows. A duration
+ * ignores it, hours and minutes have no decimals to cap.
*/
-export const valueOnly = (value: number, unit: string, locale: string): string =>
- unit === MINUTES ? hoursAndMinutes(value, locale) : numberDecimalLocale(value, locale);
+export const valueOnly = (value: number, unit: string, locale: string, decimals?: number): string =>
+ unit === MINUTES ? hoursAndMinutes(value, locale) : numberDecimalLocale(value, locale, decimals);
/**
* The unit as it is shown. A duration is stored in minutes but read in hours,
@@ -57,12 +61,12 @@ export const unitLabel = (unit: string): string => unit === MINUTES ? 'h' : unit
/**
* A measured value with its unit, both localised. A value stands on its own
* where there is no unit: a step count is a bare number, and so may be a
- * free-form category.
+ * free-form category. [decimals] as in valueOnly.
*/
-export const valueWithUnit = (value: number, unit: string, locale: string): string =>
+export const valueWithUnit = (value: number, unit: string, locale: string, decimals?: number): string =>
unit === ''
- ? valueOnly(value, unit, locale)
- : `${valueOnly(value, unit, locale)} ${unitLabel(unit)}`;
+ ? valueOnly(value, unit, locale, decimals)
+ : `${valueOnly(value, unit, locale, decimals)} ${unitLabel(unit)}`;
/** Ticks a duration axis aims for, few enough that the labels stay apart */
const DURATION_TICKS = 6;
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index ef453115b..6f7180187 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -395,6 +395,29 @@ export function binWidthFor(type: MetricType, unit?: string): number | undefined
return BIN_WIDTHS[type];
}
+/**
+ * Most decimals a value of this type is shown with at a glance (the card
+ * headline). Detail tables, forms and tooltips keep the stored value.
+ *
+ * Follows the resolution the metric is measured at, like the bin widths: a
+ * pulse has no meaningful tenths, a body weight does, and a short walk needs
+ * its hundredths of a kilometre. Durations never ask, they are read as hours
+ * and minutes. Mirrors MetricType.displayDecimals in flutter.
+ */
+export function displayDecimalsFor(type: MetricType): number {
+ switch (type) {
+ case 'body_weight':
+ case 'lean_body_mass':
+ case 'body_fat':
+ case 'custom':
+ return 1;
+ case 'distance':
+ return 2;
+ default:
+ return 0;
+ }
+}
+
/**
* One component of a group, e.g. systolic. Components exist only as the
* children of their group, which the server creates them with, so they are
diff --git a/src/components/Measurements/widgets/CategoryLatestValue.test.tsx b/src/components/Measurements/widgets/CategoryLatestValue.test.tsx
index 11de87833..20cb7b654 100644
--- a/src/components/Measurements/widgets/CategoryLatestValue.test.tsx
+++ b/src/components/Measurements/widgets/CategoryLatestValue.test.tsx
@@ -53,6 +53,19 @@ describe('latestHeadline', () => {
expect(latestHeadline(bloodPressureGroup(), entries, 'de')).toBe('130/82 mmHg');
});
+ test('the decimals follow the resolution of the metric type', () => {
+ const date = new Date(2026, 7, 1, 8, 0);
+ const heartRate = new MeasurementCategory('hr', 'Heart rate', 'bpm', 'heart_rate');
+
+ // A pulse has no meaningful tenths, however precise the aggregate is
+ expect(latestHeadline(heartRate, [entryFor('hr', 61.87, date)], 'de')).toBe('62 bpm');
+ expect(latestHeadline(
+ bloodPressureGroup(),
+ [entryFor('sys', 136.42, date), entryFor('dia', 77.04, date)],
+ 'de',
+ )).toBe('136/77 mmHg');
+ });
+
test('an unpaired half-reading shows no value', () => {
const entries = [
entryFor('sys', 130, new Date(2026, 7, 2, 8, 0)),
diff --git a/src/components/Measurements/widgets/CategoryLatestValue.tsx b/src/components/Measurements/widgets/CategoryLatestValue.tsx
index 56b56b2ec..f0f469b12 100644
--- a/src/components/Measurements/widgets/CategoryLatestValue.tsx
+++ b/src/components/Measurements/widgets/CategoryLatestValue.tsx
@@ -1,6 +1,10 @@
import { Stack, Typography } from "@mui/material";
import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
-import { isGroupTotalMetricType, MeasurementCategory } from "@/components/Measurements/models/Category";
+import {
+ displayDecimalsFor,
+ isGroupTotalMetricType,
+ MeasurementCategory
+} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { useLatestMeasurementEntriesQuery } from "@/components/Measurements/queries";
import { dateToRelative } from "@/core/lib/date";
@@ -23,23 +27,25 @@ export const latestHeadline = (
locale: string,
): string | null => {
const valueOf = (entry: MeasurementEntry) => entry.valueIn(category.unit, category.unit);
+ // At-a-glance precision: a pulse has no meaningful tenths, a weight does
+ const decimals = displayDecimalsFor(category.metricType);
if (!category.isGroup) {
- return valueWithUnit(valueOf(entries[0]), category.unit, locale);
+ return valueWithUnit(valueOf(entries[0]), category.unit, locale, decimals);
}
const total = category.children.find(child => isGroupTotalMetricType(child.metricType));
if (total !== undefined) {
const entry = entries.find(e => e.category === total.id);
- return entry === undefined ? null : valueWithUnit(valueOf(entry), category.unit, locale);
+ return entry === undefined ? null : valueWithUnit(valueOf(entry), category.unit, locale, decimals);
}
if (category.children.length === 2
&& entries.length === 2
&& entries[0].date.getTime() === entries[1].date.getTime()) {
const values = entries.map(valueOf);
- return `${valueOnly(Math.max(...values), category.unit, locale)}/`
- + valueWithUnit(Math.min(...values), category.unit, locale);
+ return `${valueOnly(Math.max(...values), category.unit, locale, decimals)}/`
+ + valueWithUnit(Math.min(...values), category.unit, locale, decimals);
}
return null;
diff --git a/src/core/lib/numbers.ts b/src/core/lib/numbers.ts
index b0bf333c8..b1b926111 100644
--- a/src/core/lib/numbers.ts
+++ b/src/core/lib/numbers.ts
@@ -13,11 +13,12 @@ export function numberLocale(num: number, locale: string) {
}
/*
- * Formats a number, localised, with up to two fraction digits: as many as the
- * server stores, and few enough to hide the artefacts of summing floats
+ * Formats a number, localised, with up to [maxDecimals] fraction digits. The
+ * default keeps as many as the server stores, and few enough to hide the
+ * artefacts of summing floats
*/
-export function numberDecimalLocale(num: number, locale: string) {
- return num.toLocaleString(locale, { maximumFractionDigits: 2 });
+export function numberDecimalLocale(num: number, locale: string, maxDecimals: number = 2) {
+ return num.toLocaleString(locale, { maximumFractionDigits: maxDecimals });
}
/*
From c4c906c463b587d46919ddcaafead4c665d48379 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 9 Aug 2026 13:23:41 +0200
Subject: [PATCH 068/102] Share and persist the chart range across the
measurement screens
---
.../Measurements/screens/BodyWeight.test.tsx | 4 ++
.../Measurements/screens/BodyWeight.tsx | 9 +--
.../screens/MeasurementCategoryDetail.tsx | 7 +-
.../screens/MeasurementCategoryOverview.tsx | 11 ++--
.../Measurements/state/chartRange.test.ts | 41 ++++++++++++
.../Measurements/state/chartRange.ts | 65 +++++++++++++++++++
6 files changed, 125 insertions(+), 12 deletions(-)
create mode 100644 src/components/Measurements/state/chartRange.test.ts
create mode 100644 src/components/Measurements/state/chartRange.ts
diff --git a/src/components/Measurements/screens/BodyWeight.test.tsx b/src/components/Measurements/screens/BodyWeight.test.tsx
index 7cc918ec8..6230f52b5 100644
--- a/src/components/Measurements/screens/BodyWeight.test.tsx
+++ b/src/components/Measurements/screens/BodyWeight.test.tsx
@@ -3,6 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements";
import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
import { testQueryClient } from "@/tests/queryClient";
+import { resetChartRange } from "@/components/Measurements/state/chartRange";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { BodyWeight } from "./BodyWeight";
import type { Mock } from 'vitest';
@@ -23,6 +24,9 @@ describe("Test BodyWeight component", () => {
// See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest
afterEach(() => {
vi.restoreAllMocks();
+ // The range store is shared module state, a picked range would leak
+ // into the next test
+ resetChartRange();
});
// Arrange
diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
index 9108f3ba5..0bc6d0452 100644
--- a/src/components/Measurements/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -1,8 +1,9 @@
import { Box, Stack } from "@mui/material";
-import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
+import { entryFilterFor } from "@/components/Measurements/charts/range";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { PlanPeriod } from "@/components/Measurements/charts/series";
+import { setChartRange, useChartRange } from "@/components/Measurements/state/chartRange";
import {
useBodyWeightCategoryQuery,
useBodyWeightQuery,
@@ -13,14 +14,14 @@ import { AddBodyWeightEntryFab } from "@/components/Measurements/widgets/fab";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty";
-import { useState } from "react";
import { useTranslation } from "react-i18next";
/** [planPeriods] come from the caller: measurements know nothing about nutrition */
export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => {
const [t] = useTranslation();
- const [range, setRange] = useState(DEFAULT_CHART_RANGE);
+ // Shared with the other measurement screens, see useChartRange
+ const range = useChartRange();
// Fetch what the range shows, rather than the whole history. The filter
// reaches a week further back than the chart draws, so the moving average
// of the first days in range still averages the days before them. The
@@ -39,7 +40,7 @@ export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => {
return
-
+
{weightyQuery.data!.length === 0 && }
{weightyQuery.data!.length !== 0 && <>
(DEFAULT_CHART_RANGE);
+ const range = useChartRange();
// eslint-disable-next-line react-hooks/rules-of-hooks
const categoryQuery = useMeasurementsQuery(categoryId);
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -103,7 +104,7 @@ export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] })
: }
mainContent={
-
+ {
const [t] = useTranslation();
const [openReorderModal, setOpenReorderModal] = React.useState(false);
- // One range for all cards: picking it per card would put a row of
- // buttons on every one of them
- const [range, setRange] = React.useState(DEFAULT_CHART_RANGE);
+ // One range for all cards, shared with the other measurement screens:
+ // picking it per card would put a row of buttons on every one of them
+ const range = useChartRange();
const categoryQuery = useMeasurementsCategoryQuery();
return categoryQuery.isLoading
@@ -95,7 +96,7 @@ export const MeasurementCategoryOverview = () => {
{categoryQuery.data!.length === 0 && }
{categoryQuery.data!.length > 0
- && }
+ && }
{/* min() keeps the column from forcing a horizontal scroll
* on screens narrower than one card */}
{
+
+ afterEach(() => {
+ resetChartRange();
+ });
+
+ test('starts at the default the screens used to seed themselves with', () => {
+ const { result } = renderHook(() => useChartRange());
+
+ expect(result.current).toBe(DEFAULT_CHART_RANGE);
+ });
+
+ test('a pick is what every watcher reads afterwards', () => {
+ // Two hooks stand in for two screens: the overview and the detail
+ // reached from it read the same store
+ const first = renderHook(() => useChartRange());
+ const second = renderHook(() => useChartRange());
+
+ act(() => setChartRange('lastWeek'));
+
+ expect(first.result.current).toBe('lastWeek');
+ expect(second.result.current).toBe('lastWeek');
+ });
+
+ test('a pick is persisted, so the next page load starts from it', () => {
+ act(() => setChartRange('lastMonth'));
+
+ // What the module reads when a full page load re-imports it
+ expect(loadChartRange()).toBe('lastMonth');
+ });
+
+ test('a stored value this release does not know falls back to the default', () => {
+ window.localStorage.setItem('wgerChartRange', 'lastDecade');
+
+ expect(loadChartRange()).toBe(DEFAULT_CHART_RANGE);
+ });
+});
diff --git a/src/components/Measurements/state/chartRange.ts b/src/components/Measurements/state/chartRange.ts
new file mode 100644
index 000000000..25b3a3286
--- /dev/null
+++ b/src/components/Measurements/state/chartRange.ts
@@ -0,0 +1,65 @@
+import { useSyncExternalStore } from 'react';
+
+import { CHART_RANGES, ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
+
+/**
+ * The chart range shared by the measurement screens (category overview,
+ * category detail, body weight): a pick follows the user through them
+ * instead of every screen starting over at its own default. The counterpart
+ * of the flutter app's ChartRangeSetting provider.
+ *
+ * Backed by localStorage, not just module state: embedded in Django pages,
+ * every navigation is a full page load that starts the components over, so
+ * memory alone would forget the pick right when it matters.
+ */
+const STORAGE_KEY = 'wgerChartRange';
+
+/**
+ * The stored pick, or the default: a value this release does not know (or a
+ * blocked storage) must never break the screens over a display preference.
+ */
+export const loadChartRange = (): ChartRange => {
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY);
+
+ return (CHART_RANGES as readonly string[]).includes(stored ?? '')
+ ? stored as ChartRange
+ : DEFAULT_CHART_RANGE;
+ } catch {
+ return DEFAULT_CHART_RANGE;
+ }
+};
+
+let currentRange: ChartRange = loadChartRange();
+const listeners = new Set<() => void>();
+
+export const setChartRange = (range: ChartRange) => {
+ currentRange = range;
+ try {
+ window.localStorage.setItem(STORAGE_KEY, range);
+ } catch {
+ // Storage full or blocked: the pick still applies for this page load
+ }
+ listeners.forEach(listener => listener());
+};
+
+/** Back to the default, so one test's pick does not leak into the next */
+export const resetChartRange = () => {
+ try {
+ window.localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // See setChartRange
+ }
+ currentRange = DEFAULT_CHART_RANGE;
+ listeners.forEach(listener => listener());
+};
+
+const subscribe = (listener: () => void) => {
+ listeners.add(listener);
+
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+export const useChartRange = (): ChartRange => useSyncExternalStore(subscribe, () => currentRange);
From b36418aaf8c70838f8e21f593eac6abc6a891adb Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 9 Aug 2026 13:54:51 +0200
Subject: [PATCH 069/102] Default the measurement charts to one month
---
src/components/Measurements/charts/range.ts | 7 +++++--
src/components/Measurements/state/chartRange.test.ts | 5 +++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index 29ea1926e..4cbebecc5 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -1,5 +1,5 @@
-import { AVERAGE_WINDOWS } from "@/components/Measurements/models/Category";
import { ChartPoint } from "@/components/Measurements/charts/series";
+import { AVERAGE_WINDOWS } from "@/components/Measurements/models/Category";
/**
* How far back the charts go, in the order the selector offers them: widest
@@ -8,7 +8,10 @@ import { ChartPoint } from "@/components/Measurements/charts/series";
export const CHART_RANGES = ['all', 'lastYear', 'last3Months', 'lastMonth', 'lastWeek'] as const;
export type ChartRange = typeof CHART_RANGES[number];
-export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
+/**
+ * The range the charts cover until the user picks another one.
+ */
+export const DEFAULT_CHART_RANGE: ChartRange = 'lastMonth';
const DAY_MS = 24 * 60 * 60 * 1000;
diff --git a/src/components/Measurements/state/chartRange.test.ts b/src/components/Measurements/state/chartRange.test.ts
index 47703cf1d..7fc7b17b6 100644
--- a/src/components/Measurements/state/chartRange.test.ts
+++ b/src/components/Measurements/state/chartRange.test.ts
@@ -27,10 +27,11 @@ describe('chartRange store', () => {
});
test('a pick is persisted, so the next page load starts from it', () => {
- act(() => setChartRange('lastMonth'));
+ // Deliberately not the default, or the test would pass without storing
+ act(() => setChartRange('lastYear'));
// What the module reads when a full page load re-imports it
- expect(loadChartRange()).toBe('lastMonth');
+ expect(loadChartRange()).toBe('lastYear');
});
test('a stored value this release does not know falls back to the default', () => {
From a242fc20d55754636324bbbb062ca3c55f79a2f9 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 9 Aug 2026 14:00:14 +0200
Subject: [PATCH 070/102] Draw the measurement charts in the theme's own
colours
This makes the charts more similar to what the flutter app uses
---
src/components/Measurements/charts/colors.ts | 5 +++--
src/components/Measurements/widgets/MeasurementBarChart.tsx | 2 +-
.../Measurements/widgets/MeasurementHeatmapChart.tsx | 2 +-
.../Measurements/widgets/MeasurementRangeBarChart.tsx | 2 +-
.../Measurements/widgets/MeasurementSeriesChart.tsx | 2 +-
src/components/Measurements/widgets/chartFrames.tsx | 5 +++--
6 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/src/components/Measurements/charts/colors.ts b/src/components/Measurements/charts/colors.ts
index cd6e27552..c430a1a0b 100644
--- a/src/components/Measurements/charts/colors.ts
+++ b/src/components/Measurements/charts/colors.ts
@@ -15,10 +15,11 @@ export const componentColor = (palette: string[], index: number): string =>
/**
* Colour of a change bar, by which way it points. Theme colours rather than
* green and red: which direction is the good one depends on the goal (losing
- * weight, building muscle), and the chart should not assert one.
+ * weight, building muscle), and the chart should not assert one. The bar
+ * already points the way it points, so the colour only has to tell them apart.
*/
export const deltaColor = (theme: Theme, delta: number): string =>
- delta < 0 ? theme.palette.info.main : theme.palette.secondary.main;
+ delta < 0 ? theme.palette.info.main : theme.palette.primary.main;
/**
* Colour of a series. Components are coloured by their position, the other
diff --git a/src/components/Measurements/widgets/MeasurementBarChart.tsx b/src/components/Measurements/widgets/MeasurementBarChart.tsx
index 0c2c27983..aecc8db3a 100644
--- a/src/components/Measurements/widgets/MeasurementBarChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementBarChart.tsx
@@ -45,7 +45,7 @@ export const MeasurementBarChart = (props: { category: MeasurementCategory, poin
tooltip={}>
;
};
diff --git a/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
index 2abafd2b6..be8488d2c 100644
--- a/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
@@ -48,7 +48,7 @@ export const MeasurementHeatmapChart = (props: { points: ChartPoint[], unit: str
}
const share = grid.maxValue <= 0 ? 1 : Math.min(1, Math.max(0, value / grid.maxValue));
- return alpha(theme.palette.secondary.main, 0.3 + 0.7 * share);
+ return alpha(theme.palette.primary.main, 0.3 + 0.7 * share);
};
// The grid is whole weeks and its last one usually runs past today, so the
diff --git a/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
index 8531499bc..63872c550 100644
--- a/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
@@ -47,7 +47,7 @@ export const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: st
tooltip={}>
;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index 3c7c06913..ed5a43ccb 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -286,7 +286,7 @@ export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
}
{
const [, i18n] = useTranslation();
+ const theme = useTheme();
return
{/*
@@ -57,7 +58,7 @@ export const BarChartFrame = (props: {
barCategoryGap="15%"
aria-label={props.ariaLabel}>
Date: Sun, 9 Aug 2026 15:14:06 +0200
Subject: [PATCH 071/102] Show a group's readings as one row per measurement
---
.../Measurements/api/measurements.test.ts | 64 ++++++++
.../Measurements/api/measurements.ts | 37 +++++
.../Measurements/charts/data.test.ts | 107 +++++++++++++
src/components/Measurements/charts/data.ts | 56 +++++++
.../queries/groupReadings.test.tsx | 104 ++++++++++++
src/components/Measurements/queries/index.ts | 50 +++++-
.../screens/MeasurementCategoryDetail.tsx | 9 +-
.../widgets/GroupReadingsGrid.test.tsx | 124 +++++++++++++++
.../widgets/GroupReadingsGrid.tsx | 150 ++++++++++++++++++
9 files changed, 694 insertions(+), 7 deletions(-)
create mode 100644 src/components/Measurements/queries/groupReadings.test.tsx
create mode 100644 src/components/Measurements/widgets/GroupReadingsGrid.test.tsx
create mode 100644 src/components/Measurements/widgets/GroupReadingsGrid.tsx
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 777f9750f..42ae0057e 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -6,6 +6,7 @@ import {
editMeasurementCategory,
editMeasurementEntry,
getCategoryEntryFlags,
+ getGroupEntryPage,
getMeasurementCategories,
getMeasurementCategory,
getMeasurementEntries,
@@ -185,6 +186,69 @@ describe('measurement service tests', () => {
expect(await getOldestMeasurementEntry(CATEGORY_UUID)).toBeNull();
});
+ describe('getGroupEntryPage', () => {
+
+ const groupResponse = (next: string | null) => ({
+ data: {
+ count: 4,
+ next: next,
+ previous: null,
+ results: [{
+ "id": ENTRY_UUID,
+ "category": CATEGORY_UUID,
+ "value": 120,
+ "date": "2021-01-01T08:00:00+01:00",
+ "notes": ""
+ }],
+ }
+ });
+
+ test('reads the components together, below the cursor', async () => {
+ (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null)));
+
+ await getGroupEntryPage(
+ [CATEGORY_UUID, CATEGORY_UUID_2],
+ 22,
+ new Date("2021-02-03T07:00:00.000Z"),
+ );
+
+ const [url] = (axios.get as Mock).mock.calls[0];
+ expect(url).toContain(`category__in=${CATEGORY_UUID}%2C${CATEGORY_UUID_2}`);
+ expect(url).toContain('limit=22');
+ expect(url).toContain('date__lt=2021-02-03T07%3A00%3A00.000Z');
+ });
+
+ test('the newest page is read without a cursor', async () => {
+ (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null)));
+
+ await getGroupEntryPage([CATEGORY_UUID], 22);
+
+ expect((axios.get as Mock).mock.calls[0][0]).not.toContain('date__lt');
+ });
+
+ test('what is left over comes from the server, not from the page size', async () => {
+ // A page the server capped below the limit that was asked for: it
+ // still says there is more, and counting the rows would not
+ (axios.get as Mock).mockImplementation(
+ () => Promise.resolve(groupResponse('http://localhost/api/v2/measurement/?offset=999'))
+ );
+
+ const page = await getGroupEntryPage([CATEGORY_UUID], 1010);
+
+ expect(page.truncated).toBe(true);
+ });
+
+ test('a page the server has nothing after is not truncated', async () => {
+ (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null)));
+
+ const page = await getGroupEntryPage([CATEGORY_UUID], 1);
+
+ // Exactly as many rows as were asked for, and still the end
+ expect(page.entries).toHaveLength(1);
+ expect(page.truncated).toBe(false);
+ });
+ });
+
test('GET measurement categories hides the official body weight category', async () => {
(axios.get as Mock).mockImplementation((url: string) => {
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 8eb2666a5..80bc0538f 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -153,6 +153,43 @@ export const getMeasurementEntryPage = async (
};
};
+/** One page of the entries of a group's components, newest first */
+export type GroupEntryPage = {
+ entries: MeasurementEntry[],
+ /** Whether the server held entries back, see groupReadingPage */
+ truncated: boolean,
+};
+
+/**
+ * The entries of a group's components down to {@link before}, the timestamp of
+ * the oldest reading already shown. A cursor rather than an offset: the limit
+ * cuts entries, which cannot be counted back into whole readings.
+ */
+export const getGroupEntryPage = async (
+ categoryIds: string[],
+ limit: number,
+ before?: Date,
+ filtersetQuery: object = {},
+): Promise => {
+ const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
+ query: {
+ category__in: categoryIds.join(','),
+ limit: limit,
+ ...(before !== undefined ? { date__lt: before.toISOString() } : {}),
+ ...filtersetQuery,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return {
+ entries: data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData)),
+ // What the server itself says is left over, rather than whether the
+ // page came back full: it caps `limit` at its own maximum, and a page
+ // cut by that cap looks unfilled
+ truncated: data.next !== null,
+ };
+};
+
/**
* The newest entries across the given categories, newest first, in a single
* request.
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 6fcac8666..789c5d08a 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -14,6 +14,8 @@ import {
groupComponentSeries,
groupComponentPoints,
groupRangeEntries,
+ groupReadingPage,
+ groupReadings,
groupStackedEntries,
movingAverage,
niceBinWidth,
@@ -593,6 +595,111 @@ describe('groups', () => {
});
});
+describe('groupReadings', () => {
+
+ const group = () => {
+ const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
+ bloodPressure.children = [
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1),
+ ];
+ return bloodPressure;
+ };
+
+ /** The entries of one reading, newest first as the API returns them */
+ const reading = (date: Date, high: number, low: number | null) => [
+ new MeasurementEntry('e-sys', 'c-sys', date, high, ''),
+ ...(low === null ? [] : [new MeasurementEntry('e-dia', 'c-dia', date, low, '')]),
+ ];
+
+ test('pairs the components sharing a timestamp into one reading', () => {
+ const readings = groupReadings(group(), reading(day(1, 8), 120, 80));
+
+ expect(readings).toHaveLength(1);
+ expect(readings[0].date).toEqual(day(1, 8));
+ expect([...readings[0].values]).toEqual([['c-sys', 120], ['c-dia', 80]]);
+ });
+
+ test('keeps a reading only some components reported', () => {
+ const readings = groupReadings(group(), reading(day(1, 8), 120, null));
+
+ expect([...readings[0].values]).toEqual([['c-sys', 120]]);
+ });
+
+ test('returns the readings newest first', () => {
+ const readings = groupReadings(group(), [
+ ...reading(day(1, 8), 120, 80),
+ ...reading(day(3, 8), 130, 90),
+ ]);
+
+ expect(readings.map(r => r.date)).toEqual([day(3, 8), day(1, 8)]);
+ });
+
+ test('ignores entries of a category that is not a component', () => {
+ const stray = new MeasurementEntry('e-x', 'c-other', day(1, 8), 42, '');
+
+ expect(groupReadings(group(), [stray])).toEqual([]);
+ });
+
+ test('reads the values through the unit helper', () => {
+ const weight = new MeasurementCategory('g-w', 'Weights', 'kg', 'custom');
+ weight.children = [new MeasurementCategory('c-kg', 'Left', 'kg', 'custom', false, 'g-w', 0)];
+ const entries = [new MeasurementEntry('e-1', 'c-kg', day(1), 220, '', 'user', { unit: 'lb' })];
+
+ expect([...groupReadings(weight, entries)[0].values]).toEqual([['c-kg', 99.79]]);
+ });
+});
+
+describe('groupReadingPage', () => {
+
+ const group = () => {
+ const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
+ bloodPressure.children = [
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1),
+ ];
+ return bloodPressure;
+ };
+
+ /** [count] complete readings, newest first */
+ const entriesFor = (count: number) => {
+ const entries: MeasurementEntry[] = [];
+ for (let index = 0; index < count; index++) {
+ entries.push(new MeasurementEntry('e-sys', 'c-sys', day(count - index), 120, ''));
+ entries.push(new MeasurementEntry('e-dia', 'c-dia', day(count - index), 80, ''));
+ }
+ return entries;
+ };
+
+ test('hands over what it was given when the page was not truncated', () => {
+ const page = groupReadingPage(group(), entriesFor(3), 10, false);
+
+ expect(page.readings).toHaveLength(3);
+ expect(page.hasMore).toBe(false);
+ });
+
+ test('drops the oldest reading of a truncated page, it may be missing components', () => {
+ const page = groupReadingPage(group(), entriesFor(3), 10, true);
+
+ expect(page.readings.map(r => r.date)).toEqual([day(3), day(2)]);
+ expect(page.hasMore).toBe(true);
+ });
+
+ test('cuts at the page size, and says there is more', () => {
+ const page = groupReadingPage(group(), entriesFor(5), 2, false);
+
+ expect(page.readings.map(r => r.date)).toEqual([day(5), day(4)]);
+ expect(page.hasMore).toBe(true);
+ });
+
+ test('keeps a page holding a single timestamp, there is nothing to drop it for', () => {
+ const page = groupReadingPage(group(), entriesFor(1), 10, true);
+
+ expect(page.readings).toHaveLength(1);
+ expect(page.hasMore).toBe(true);
+ });
+});
+
describe('sleep group', () => {
/** A sleep group: the total plus two stages, all on the same night */
const sleep = (withStages: boolean = true): SeededGroup => {
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 16762d3b0..be802bdf0 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -831,6 +831,62 @@ export const groupChart = (
: { kind: 'components', series: groupComponentSeries(group, points, labelOf) };
};
+/** One reading of a group: a timestamp, and what each component holds for it */
+export interface GroupReading {
+ date: Date;
+ /** Keyed by component id, the value in that component's own unit */
+ values: Map;
+}
+
+/**
+ * The readings of a group, newest first: one per timestamp, paired the way the
+ * importer and the group form write them. A reading only some components
+ * reported is kept, a night without deep sleep is not a broken pair.
+ */
+export const groupReadings = (
+ group: MeasurementCategory,
+ entries: MeasurementEntry[],
+): GroupReading[] => {
+ const unitOf = new Map(group.children.map(child => [child.id!, child.unit]));
+
+ // Keyed by component id, not by name: two components can share a name
+ const byDate = new Map>();
+ for (const entry of entries) {
+ const unit = unitOf.get(entry.category);
+ if (unit === undefined) {
+ continue;
+ }
+ const values = byDate.get(entry.date.getTime()) ?? new Map();
+ const value = entry.valueIn(unit, unit);
+ values.set(entry.category, (values.get(entry.category) ?? 0) + value);
+ byDate.set(entry.date.getTime(), values);
+ }
+
+ return [...byDate.entries()]
+ .map(([date, values]) => ({ date: new Date(date), values: values }))
+ .sort((a, b) => b.date.getTime() - a.date.getTime());
+};
+
+/**
+ * One page of a group's readings, cut where a reading ends.
+ * {@link truncated} says the server returned fewer entries than it had, which
+ * leaves the oldest reading half-read: dropping it keeps it off two pages.
+ */
+export const groupReadingPage = (
+ group: MeasurementCategory,
+ entries: MeasurementEntry[],
+ pageSize: number,
+ truncated: boolean,
+): { readings: GroupReading[], hasMore: boolean } => {
+ const all = groupReadings(group, entries);
+ const whole = truncated && all.length > 1 ? all.slice(0, -1) : all;
+
+ return {
+ readings: whole.slice(0, pageSize),
+ hasMore: truncated || whole.length > pageSize,
+ };
+};
+
/**
* The parts of the periods that overlap the span the chart covers, clamped to
* it. Periods entirely outside it are dropped, so a band never draws past the
diff --git a/src/components/Measurements/queries/groupReadings.test.tsx b/src/components/Measurements/queries/groupReadings.test.tsx
new file mode 100644
index 000000000..d45ca43b5
--- /dev/null
+++ b/src/components/Measurements/queries/groupReadings.test.tsx
@@ -0,0 +1,104 @@
+import { getGroupEntryPage } from "@/components/Measurements/api/measurements";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { useGroupReadingsQuery } from "@/components/Measurements/queries";
+import { getTestQueryClient } from "@/tests/queryClient";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from '@testing-library/react';
+import React from "react";
+import type { Mock } from 'vitest';
+
+vi.mock("@/components/Measurements/api/measurements");
+
+const group = () => {
+ const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
+ bloodPressure.children = [
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1),
+ ];
+ return bloodPressure;
+};
+
+/** A day's reading, as the two entries it is stored as */
+const reading = (day: number) => [
+ new MeasurementEntry('e-sys', 'c-sys', new Date(2023, 1, day, 8, 0), 120 + day, ''),
+ new MeasurementEntry('e-dia', 'c-dia', new Date(2023, 1, day, 8, 0), 80 + day, ''),
+];
+
+const renderReadings = (pageSize: number) => {
+ const client = getTestQueryClient();
+ const wrapper = ({ children }: { children: React.ReactNode }) =>
+ {children};
+
+ // Spread rather than returned: the query result tracks which fields are
+ // read during a render and only re-renders on those, and a bare hook reads
+ // none of them. The widget reads them by rendering with them.
+ return renderHook(() => ({ ...useGroupReadingsQuery(group(), pageSize) }), { wrapper });
+};
+
+describe("useGroupReadingsQuery", () => {
+
+ beforeEach(() => vi.clearAllMocks());
+
+ test('cuts the readings into pages and reports there is more', async () => {
+ // Three readings' worth of entries for a page of two, i.e. the server
+ // had more than the page holds
+ (getGroupEntryPage as Mock).mockResolvedValue({
+ entries: [...reading(9), ...reading(8), ...reading(7)],
+ truncated: true,
+ });
+
+ const { result } = renderReadings(2);
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ expect(result.current.data![0].readings.map(r => r.date)).toEqual([
+ new Date(2023, 1, 9, 8, 0),
+ new Date(2023, 1, 8, 8, 0),
+ ]);
+ expect(result.current.hasNextPage).toBe(true);
+ });
+
+ /**
+ * A two-page history, answered by the cursor it is asked for rather than
+ * by call order, so a repeated read cannot shift the pages.
+ */
+ const mockChain = () => (getGroupEntryPage as Mock).mockImplementation(
+ (_ids: string[], _limit: number, before?: Date) => Promise.resolve(before === undefined
+ ? { entries: [...reading(9), ...reading(8)], truncated: true }
+ : { entries: [...reading(7), ...reading(6)], truncated: false })
+ );
+
+ test('the next page starts below the oldest reading of the one before it', async () => {
+ mockChain();
+
+ const { result } = renderReadings(1);
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ await result.current.fetchNextPage();
+
+ // The cursor is the oldest reading the first page kept, not the oldest
+ // one it read: page 0 dropped the 8th as possibly half-read
+ expect((getGroupEntryPage as Mock).mock.calls[1][2]).toEqual(new Date(2023, 1, 9, 8, 0));
+ });
+
+ test('a second page holds other readings than the first', async () => {
+ mockChain();
+
+ const { result } = renderReadings(1);
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ await result.current.fetchNextPage();
+ await waitFor(() => expect(result.current.data).toHaveLength(2));
+
+ const dates = result.current.data!.map(page => page.readings[0].date);
+ expect(dates).toEqual([new Date(2023, 1, 9, 8, 0), new Date(2023, 1, 7, 8, 0)]);
+ });
+
+ test('asks for a page plus the reading it is cut at', async () => {
+ (getGroupEntryPage as Mock).mockResolvedValue({ entries: [], truncated: false });
+
+ const { result } = renderReadings(10);
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // (10 + 1) readings times the two components
+ expect((getGroupEntryPage as Mock).mock.calls[0][1]).toBe(22);
+ });
+});
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index 7346a22b3..032fb1d58 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -8,6 +8,8 @@ import {
BucketLevel,
getAllMeasurementEntries,
getCategoryEntryFlags,
+ getGroupEntryPage,
+ GroupEntryPage,
getLatestMeasurementEntries,
getMeasurementBuckets,
getMeasurementCategories,
@@ -19,10 +21,17 @@ import {
MeasurementQueryOptions,
updateMeasurementCategoryOrder
} from "@/components/Measurements/api/measurements";
+import { groupReadingPage } from "@/components/Measurements/charts/data";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { QueryKey } from "@/core/lib/consts";
-import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ keepPreviousData,
+ useInfiniteQuery,
+ useMutation,
+ useQuery,
+ useQueryClient
+} from "@tanstack/react-query";
/**
@@ -192,6 +201,45 @@ export function useMeasurementEntryPageQuery(
});
}
+/**
+ * The readings of a group, a page at a time. Each page carries the cursor of
+ * the next one, which is why they are fetched as a chain rather than by index.
+ */
+export function useGroupReadingsQuery(
+ group: MeasurementCategory,
+ pageSize: number,
+ filtersetQuery: object = {},
+) {
+ const categoryIds = group.children.map(child => child.id!);
+ // A page plus the reading it is cut at; asking for a page exactly would
+ // spend a row on the cut every time
+ const limit = (pageSize + 1) * categoryIds.length;
+ const readingsOf = (page: GroupEntryPage) =>
+ groupReadingPage(group, page.entries, pageSize, page.truncated);
+
+ return useInfiniteQuery({
+ queryKey: [
+ QueryKey.MEASUREMENT_ENTRIES,
+ 'group-readings',
+ categoryIds.join(','),
+ filtersetQuery,
+ pageSize,
+ ],
+ queryFn: ({ pageParam }) => getGroupEntryPage(categoryIds, limit, pageParam, filtersetQuery),
+ initialPageParam: undefined as Date | undefined,
+ getNextPageParam: page => {
+ const { readings, hasMore } = readingsOf(page);
+
+ return hasMore && readings.length > 0
+ ? readings[readings.length - 1].date
+ : undefined;
+ },
+ select: data => data.pages.map(readingsOf),
+ // A group synced without its components yet has nothing to ask for
+ enabled: categoryIds.length > 0,
+ });
+}
+
/**
* The oldest entry of a category, which the total change of every row is
* measured against. Its own query, so paging through the table doesn't read
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 208b0afd3..70a578986 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -1,4 +1,4 @@
-import { Stack, Typography } from "@mui/material";
+import { Stack } from "@mui/material";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import {
@@ -14,6 +14,7 @@ import {
import { PlanPeriod } from "@/components/Measurements/charts/series";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
+import { GroupReadingsGrid } from "@/components/Measurements/widgets/GroupReadingsGrid";
import { ChartRange, displayFilterFor } from "@/components/Measurements/charts/range";
import { setChartRange, useChartRange } from "@/components/Measurements/state/chartRange";
import { PAGINATION_OPTIONS } from "@/core/lib/consts";
@@ -110,11 +111,7 @@ export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] })
range={range}
planPeriods={props.planPeriods ?? []} />
{categoryQuery.data!.isGroup
- ? categoryQuery.data!.children.map(child =>
-
- {categoryDisplayName(child, t)}
-
- )
+ ?
: }
}
diff --git a/src/components/Measurements/widgets/GroupReadingsGrid.test.tsx b/src/components/Measurements/widgets/GroupReadingsGrid.test.tsx
new file mode 100644
index 000000000..28c124f7f
--- /dev/null
+++ b/src/components/Measurements/widgets/GroupReadingsGrid.test.tsx
@@ -0,0 +1,124 @@
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { groupReadingPage } from "@/components/Measurements/charts/data";
+import { useGroupReadingsQuery } from "@/components/Measurements/queries";
+import { GroupReadingsGrid } from "@/components/Measurements/widgets/GroupReadingsGrid";
+import { PAGINATION_OPTIONS } from "@/core/lib/consts";
+import { getTestQueryClient } from "@/tests/queryClient";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from '@testing-library/react';
+import userEvent from "@testing-library/user-event";
+import React from 'react';
+import { MemoryRouter } from "react-router-dom";
+import type { Mock } from 'vitest';
+
+vi.mock("@/components/Measurements/queries");
+
+const bloodPressure = () => {
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
+ group.children = [
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1),
+ ];
+ return group;
+};
+
+const reading = (date: Date, high: number, low: number) => [
+ new MeasurementEntry('e-sys', 'c-sys', date, high, ''),
+ new MeasurementEntry('e-dia', 'c-dia', date, low, ''),
+];
+
+/** A full page of readings, the systolic value counting down from [high] */
+const fullPage = (high: number) => Array.from(
+ { length: PAGINATION_OPTIONS.pageSize },
+ (_, index) => reading(new Date(2023, 1, 20 - index, 8, 0), high - index, 70),
+).flat();
+
+const fetchNextPage = vi.fn().mockResolvedValue({ data: [] });
+
+/** The hook hands over the readings already cut into pages */
+const mockPages = (pages: MeasurementEntry[][], hasNextPage: boolean = false) =>
+ (useGroupReadingsQuery as Mock).mockImplementation(() => ({
+ data: pages.map(entries => groupReadingPage(
+ bloodPressure(),
+ entries,
+ PAGINATION_OPTIONS.pageSize,
+ false,
+ )),
+ hasNextPage: hasNextPage,
+ fetchNextPage: fetchNextPage,
+ isFetching: false,
+ }));
+
+const renderGrid = () => render(
+
+
+
+
+
+);
+
+describe('GroupReadingsGrid', () => {
+
+ afterEach(() => vi.restoreAllMocks());
+
+ test('lists one row per reading, one column per component', () => {
+ mockPages([[
+ ...reading(new Date(2023, 1, 2, 8, 0), 130, 90),
+ ...reading(new Date(2023, 1, 1, 8, 0), 120, 80),
+ ]]);
+
+ renderGrid();
+
+ expect(screen.getAllByRole('row')).toHaveLength(3); // header plus two readings
+ expect(screen.getByRole('gridcell', { name: '130 mmHg' })).toBeInTheDocument();
+ expect(screen.getByRole('gridcell', { name: '90 mmHg' })).toBeInTheDocument();
+ expect(screen.getByRole('gridcell', { name: '120 mmHg' })).toBeInTheDocument();
+ expect(screen.getByRole('gridcell', { name: '80 mmHg' })).toBeInTheDocument();
+ });
+
+ test('the column headers lead to the component screens', () => {
+ mockPages([reading(new Date(2023, 1, 1, 8, 0), 120, 80)]);
+
+ renderGrid();
+
+ // A typed category is named after its metric type, whose key the test
+ // translator hands back untranslated
+ expect(screen.getByRole('link', { name: /blood_pressure_systolic/ }))
+ .toHaveAttribute('href', expect.stringContaining('c-sys'));
+ expect(screen.getByRole('link', { name: /blood_pressure_diastolic/ }))
+ .toHaveAttribute('href', expect.stringContaining('c-dia'));
+ });
+
+ test('the readings are shown, not edited: one row is several entries', async () => {
+ mockPages([reading(new Date(2023, 1, 1, 8, 0), 120, 80)]);
+
+ renderGrid();
+ await userEvent.dblClick(screen.getByRole('gridcell', { name: '120 mmHg' }));
+
+ expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
+ expect(screen.queryByRole('spinbutton')).not.toBeInTheDocument();
+ });
+
+ test('the next page shows other readings than the one before it', async () => {
+ // A full page and a shorter one after it, which is where the chain ends
+ mockPages([fullPage(200), [...reading(new Date(2023, 1, 2, 8, 0), 118, 70)]]);
+
+ renderGrid();
+ await userEvent.click(screen.getByRole('button', { name: /next page/i }));
+
+ expect(screen.getByRole('gridcell', { name: '118 mmHg' })).toBeInTheDocument();
+ expect(screen.queryByRole('gridcell', { name: '200 mmHg' })).not.toBeInTheDocument();
+ });
+
+ test('a page that is not there yet is fetched before it is shown', async () => {
+ mockPages([fullPage(200)], true);
+
+ renderGrid();
+ await userEvent.click(screen.getByRole('button', { name: /next page/i }));
+
+ expect(fetchNextPage).toHaveBeenCalled();
+ // The fetch came back without the page, so the table kept its rows
+ expect(screen.getByRole('gridcell', { name: '200 mmHg' })).toBeInTheDocument();
+ });
+});
diff --git a/src/components/Measurements/widgets/GroupReadingsGrid.tsx b/src/components/Measurements/widgets/GroupReadingsGrid.tsx
new file mode 100644
index 000000000..71d987874
--- /dev/null
+++ b/src/components/Measurements/widgets/GroupReadingsGrid.tsx
@@ -0,0 +1,150 @@
+import { componentColor, componentPalette } from "@/components/Measurements/charts/colors";
+import { stackableComponents } from "@/components/Measurements/charts/data";
+import { valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartRange, displayFilterFor } from "@/components/Measurements/charts/range";
+import {
+ categoryDisplayName,
+ displayDecimalsFor,
+ isSummedPerDay,
+ MeasurementCategory
+} from "@/components/Measurements/models/Category";
+import { useGroupReadingsQuery } from "@/components/Measurements/queries";
+import { PAGINATION_OPTIONS } from "@/core/lib/consts";
+import { luxonDateTimeToLocale } from "@/core/lib/date";
+import { makeLink, WgerLink } from "@/core/lib/url";
+import { Box, Link as MuiLink, Stack } from "@mui/material";
+import { DataGrid, GridColDef, GridPaginationModel } from "@mui/x-data-grid";
+import { DateTime } from "luxon";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+
+/**
+ * The readings of a multi-value group, newest first: one row per timestamp,
+ * one column per component. Shown but not edited here, since one row is
+ * several entries; the column headers lead to the component screens.
+ */
+export const GroupReadingsGrid = (props: { group: MeasurementCategory, range: ChartRange }) => {
+ const [t, i18n] = useTranslation();
+ const group = props.group;
+ const children = group.children;
+
+ // The range as it is labelled, not the chart's read: the table would list
+ // the average's lead as if it were part of the range
+ const filter = displayFilterFor(props.range);
+ const [pagination, setPagination] = React.useState({
+ page: 0,
+ pageSize: PAGINATION_OPTIONS.pageSize,
+ });
+ // Another range is another set of readings, and page seven of the last one
+ // says nothing about it
+ React.useEffect(
+ () => setPagination(model => ({ ...model, page: 0 })),
+ [props.range]
+ );
+
+ const query = useGroupReadingsQuery(group, pagination.pageSize, filter);
+ const pages = query.data ?? [];
+ const readings = pages[pagination.page]?.readings ?? [];
+
+ // Nothing counts the readings, so the total is provisional while the chain
+ // is walked and exact once its end is reached. The grid's unknown-count
+ // mode is deliberately not used: it derives a total of its own from the
+ // page it is on and lands on the wrong one.
+ const loaded = pages.reduce((sum, page) => sum + page.readings.length, 0);
+ const rowCount = query.hasNextPage
+ ? pages.length * pagination.pageSize + 1
+ : loaded;
+
+ // A page is only shown once it is there, so the table keeps the rows it
+ // has instead of blanking while the next one loads
+ const showPage = (page: number) => {
+ if (page < pages.length) {
+ setPagination(model => ({ ...model, page: page }));
+ return;
+ }
+ query.fetchNextPage().then(result => {
+ if (page < (result.data?.length ?? 0)) {
+ setPagination(model => ({ ...model, page: page }));
+ }
+ });
+ };
+
+ // Only the stacked chart leaves a component out, so the dots follow it
+ const coloured = isSummedPerDay(group.metricType) ? stackableComponents(group) : children;
+ const palette = componentPalette(coloured.length);
+
+ const columns: GridColDef[] = [
+ {
+ field: 'date',
+ headerName: t('date'),
+ type: 'dateTime',
+ width: 160,
+ // Sorting would only reach the page in hand, which is not what a
+ // sorted table means
+ sortable: false,
+ valueFormatter: (value?: Date) => value == null
+ ? ''
+ : luxonDateTimeToLocale(DateTime.fromJSDate(value), undefined, DateTime.DATETIME_SHORT),
+ },
+ ...children.map((child): GridColDef => {
+ const name = categoryDisplayName(child, t);
+ const colourIndex = coloured.findIndex(c => c.id === child.id);
+
+ return {
+ field: child.id!,
+ headerName: name,
+ type: 'number',
+ // The components share what the date column leaves, so two of
+ // them fill the width and five still fit before it scrolls
+ flex: 1,
+ minWidth: 110,
+ sortable: false,
+ renderHeader: () =>
+ {colourIndex >= 0 && }
+
+ {name}
+
+ ,
+ valueFormatter: (value?: number) => value == null
+ ? ''
+ : valueWithUnit(value, child.unit, i18n.language, displayDecimalsFor(child.metricType)),
+ };
+ }),
+ ];
+
+ const rows = readings.map(reading => ({
+ // The timestamp is what pairs the components, so it identifies the row
+ id: reading.date.getTime(),
+ date: reading.date,
+ ...Object.fromEntries(reading.values),
+ }));
+
+ return
+ model.pageSize === pagination.pageSize
+ ? showPage(model.page)
+ // Another page size cuts the readings elsewhere
+ : setPagination({ page: 0, pageSize: model.pageSize })}
+ loading={query.isFetching}
+ pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions}
+ disableColumnFilter
+ disableRowSelectionOnClick
+ />
+ ;
+};
From ca7fe141829106882c45dda98f7f9da17dafa868 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Mon, 10 Aug 2026 16:46:31 +0200
Subject: [PATCH 072/102] Use the new datetime fields for workout sessions
---
.../Calendar/Components/CalendarComponent.tsx | 6 +--
src/components/Routines/api/session.test.ts | 41 ++++++++---------
.../Routines/models/WorkoutSession.ts | 36 ++++++---------
.../widgets/forms/SessionForm.test.tsx | 45 ++++++++++++++----
.../Routines/widgets/forms/SessionForm.tsx | 46 +++++++++++--------
src/tests/workoutLogsRoutinesTestData.ts | 5 +-
src/tests/workoutRoutinesTestData.ts | 10 ++--
7 files changed, 107 insertions(+), 82 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 56f7bffe7..9e4966315 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -38,8 +38,8 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const weightsQuery = useBodyWeightQuery();
const sessionQuery = useSessionsQuery({
filtersetQuerySessions: {
- "date__gte": dateToYYYYMMDD(startOfMonth),
- "date__lte": dateToYYYYMMDD(endOfMonth),
+ "datetime_start__gte": startOfMonth.toISOString(),
+ "datetime_start__lt": new Date(currentYear, currentMonth + 1, 1).toISOString(),
},
filtersetQueryLogs: {
"date__gte": dateToYYYYMMDD(startOfMonth),
@@ -99,7 +99,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
date: new Date(date),
weightEntry: weightsQuery.data?.find(w => isSameDay(w.date, date)),
measurements: measurements.filter(m => isSameDay(m.date, date)) || [],
- workoutSession: sessionQuery.data?.find(m => isSameDay(m.date, date)) || undefined,
+ workoutSession: sessionQuery.data?.find(m => isSameDay(m.datetimeStart, date)) || undefined,
nutritionLogs: nutritionDiaryQuery.data?.filter(m => isSameDay(m.datetime, date)) || [],
});
date.setDate(date.getDate() + 1);
diff --git a/src/components/Routines/api/session.test.ts b/src/components/Routines/api/session.test.ts
index 8dea6030f..1cbac6776 100644
--- a/src/components/Routines/api/session.test.ts
+++ b/src/components/Routines/api/session.test.ts
@@ -84,11 +84,10 @@ describe("Session service tests", () => {
"id": SESSION_UUID,
"routine": 39764,
"day": null,
- "date": "2025-08-07",
"notes": null,
"impression": "3",
- "time_start": "20:10:58",
- "time_end": "23:28:21"
+ "datetime_start": "2025-08-07T20:10:58+02:00",
+ "datetime_end": "2025-08-07T23:28:21+02:00"
},
]
}
@@ -163,21 +162,21 @@ describe("Session service tests", () => {
results: [
{
id: SESSION_UUID, routine: 39764, day: 5,
- date: "2025-08-07",
notes: "ok",
impression: "3",
- time_start: "20:10:58", time_end: "23:28:21",
+ datetime_start: "2025-08-07T20:10:58+02:00",
+ datetime_end: "2025-08-07T23:28:21+02:00",
},
],
};
(axios.get as Mock).mockResolvedValue({ data: apiResponse });
- const result = await searchSession({ routine: 39764, date: "2025-08-07" });
+ const result = await searchSession({ routine: 39764, datetime_start__date: "2025-08-07" });
const url = (axios.get as Mock).mock.calls[0][0] as string;
expect(url).toContain("/api/v2/workoutsession/");
expect(url).toContain("routine=39764");
- expect(url).toContain("date=2025-08-07");
+ expect(url).toContain("datetime_start__date=2025-08-07");
expect(result).toBeInstanceOf(WorkoutSession);
expect(result?.id).toBe(SESSION_UUID);
});
@@ -205,9 +204,9 @@ describe("Session service tests", () => {
test('addSession POSTs the serialized session and returns the parsed session', async () => {
(axios.post as Mock).mockResolvedValue({
data: {
- id: SESSION_UUID_2, routine: 39764, day: 5, date: "2025-08-07",
+ id: SESSION_UUID_2, routine: 39764, day: 5,
notes: null, impression: "3",
- time_start: null, time_end: null,
+ datetime_start: "2025-08-07T00:00:00+02:00", datetime_end: null,
},
});
@@ -215,11 +214,10 @@ describe("Session service tests", () => {
id: null,
routineId: 39764,
dayId: 5,
- date: new Date(2025, 7, 7),
notes: null,
impression: "3",
- timeStart: null,
- timeEnd: null,
+ datetimeStart: new Date(2025, 7, 7, 20, 10),
+ datetimeEnd: null,
}));
expect(axios.post).toHaveBeenCalledTimes(1);
@@ -228,11 +226,10 @@ describe("Session service tests", () => {
expect(body).toEqual({
routine: 39764,
day: 5,
- date: "2025-08-07",
notes: null,
impression: "3",
- time_start: null,
- time_end: null,
+ datetime_start: new Date(2025, 7, 7, 20, 10).toISOString(),
+ datetime_end: null,
});
expect(result).toBeInstanceOf(WorkoutSession);
expect(result.id).toBe(SESSION_UUID_2);
@@ -241,9 +238,9 @@ describe("Session service tests", () => {
test('editSession PATCHes /workoutsession// with the serialized session', async () => {
(axios.patch as Mock).mockResolvedValue({
data: {
- id: SESSION_UUID, routine: 39764, day: 5, date: "2025-08-07",
+ id: SESSION_UUID, routine: 39764, day: 5,
notes: "edited", impression: "3",
- time_start: null, time_end: null,
+ datetime_start: "2025-08-07T00:00:00+02:00", datetime_end: null,
},
});
@@ -251,11 +248,10 @@ describe("Session service tests", () => {
id: SESSION_UUID,
routineId: 39764,
dayId: 5,
- date: new Date(2025, 7, 7),
notes: "edited",
impression: "3",
- timeStart: null,
- timeEnd: null,
+ datetimeStart: new Date(2025, 7, 7, 20, 10),
+ datetimeEnd: null,
}));
expect(axios.patch).toHaveBeenCalledTimes(1);
@@ -265,11 +261,10 @@ describe("Session service tests", () => {
id: SESSION_UUID,
routine: 39764,
day: 5,
- date: "2025-08-07",
notes: "edited",
impression: "3",
- time_start: null,
- time_end: null,
+ datetime_start: new Date(2025, 7, 7, 20, 10).toISOString(),
+ datetime_end: null,
});
expect(result.notes).toBe("edited");
});
diff --git a/src/components/Routines/models/WorkoutSession.ts b/src/components/Routines/models/WorkoutSession.ts
index 66d2847cd..21b25e39d 100644
--- a/src/components/Routines/models/WorkoutSession.ts
+++ b/src/components/Routines/models/WorkoutSession.ts
@@ -2,7 +2,6 @@ import { Day } from "@/components/Routines/models/Day";
import { WorkoutLog } from "@/components/Routines/models/WorkoutLog";
import i18n from 'i18next';
import { Adapter } from "@/core/lib/Adapter";
-import { dateTimeToHHMM, dateToYYYYMMDD, HHMMToDateTime, yyyymmddToDate } from "@/core/lib/date";
export const NOTES_MAX_LENGTH = 1000 as const;
@@ -14,11 +13,10 @@ interface WorkoutSessionParams {
id: string | null;
dayId: number;
routineId: number;
- date: Date;
+ datetimeStart: Date;
+ datetimeEnd: Date | null;
notes: string | null;
impression: string;
- timeStart: Date | null;
- timeEnd: Date | null;
dayObj?: Day;
logs?: WorkoutLog[];
}
@@ -28,11 +26,10 @@ export class WorkoutSession {
id: string | null;
dayId: number;
routineId: number;
- date: Date;
+ datetimeStart: Date;
+ datetimeEnd: Date | null;
notes: string | null;
impression: string;
- timeStart: Date | null;
- timeEnd: Date | null;
dayObj?: Day;
logs: WorkoutLog[] = [];
@@ -40,11 +37,10 @@ export class WorkoutSession {
this.id = params.id;
this.dayId = params.dayId;
this.routineId = params.routineId;
- this.date = params.date;
+ this.datetimeStart = params.datetimeStart;
+ this.datetimeEnd = params.datetimeEnd;
this.notes = params.notes;
this.impression = params.impression;
- this.timeStart = params.timeStart;
- this.timeEnd = params.timeEnd;
if (params.dayObj) {
this.dayObj = params.dayObj;
}
@@ -70,10 +66,10 @@ export class WorkoutSession {
}
get textRepresentation(): string {
- const time = this.timeStart && this.timeEnd ? `${this.timeStart.toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit'
- })} - ${this.timeEnd.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} /` : "";
+ const format = (date: Date) => date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ const time = this.datetimeEnd
+ ? `${format(this.datetimeStart)} - ${format(this.datetimeEnd)} /`
+ : `${format(this.datetimeStart)} /`;
const notes = this.notes ?? "";
@@ -89,11 +85,10 @@ export class WorkoutSessionAdapter implements Adapter {
id: item.id,
dayId: item.day!,
routineId: item.routine!,
- date: yyyymmddToDate(item.date!),
+ datetimeStart: new Date(item.datetime_start),
+ datetimeEnd: item.datetime_end ? new Date(item.datetime_end) : null,
notes: item.notes !== undefined ? item.notes : null,
impression: item.impression!,
- timeStart: item.time_start !== undefined ? HHMMToDateTime(item.time_start) : null,
- timeEnd: item.time_end !== undefined ? HHMMToDateTime(item.time_end) : null,
dayObj: item.dayObj,
logs: item.logs
});
@@ -102,13 +97,12 @@ export class WorkoutSessionAdapter implements Adapter {
toJson = (item: WorkoutSession) => ({
...(item.id != null ? { id: item.id } : {}),
day: item.dayId,
- date: dateToYYYYMMDD(item.date),
routine: item.routineId,
notes: item.notes,
impression: item.impression,
// eslint-disable-next-line camelcase
- time_start: dateTimeToHHMM(item.timeStart),
+ datetime_start: item.datetimeStart.toISOString(),
// eslint-disable-next-line camelcase
- time_end: dateTimeToHHMM(item.timeEnd),
+ datetime_end: item.datetimeEnd ? item.datetimeEnd.toISOString() : null,
});
-}
\ No newline at end of file
+}
diff --git a/src/components/Routines/widgets/forms/SessionForm.test.tsx b/src/components/Routines/widgets/forms/SessionForm.test.tsx
index cf04f48ad..802f6fb92 100644
--- a/src/components/Routines/widgets/forms/SessionForm.test.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.test.tsx
@@ -62,7 +62,7 @@ describe('SessionForm', () => {
// Assert
expect(mockUseFindSessionQuery).toHaveBeenCalledWith(
routineId,
- { routine: routineId, date: '2024-05-01', day: dayId }
+ { routine: routineId, datetime_start__date: '2024-05-01', day: dayId }
);
// Act - the parent selects another date
@@ -80,7 +80,7 @@ describe('SessionForm', () => {
// Assert
expect(mockUseFindSessionQuery).toHaveBeenLastCalledWith(
routineId,
- { routine: routineId, date: '2024-05-08', day: dayId }
+ { routine: routineId, datetime_start__date: '2024-05-08', day: dayId }
);
});
@@ -129,11 +129,11 @@ describe('SessionForm', () => {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001',
dayId: dayId,
routineId: routineId,
- date: date.toJSDate(),
+
notes: 'Test notes',
impression: '3',
- timeStart: timeStart.toJSDate(),
- timeEnd: timeEnd.toJSDate()
+ datetimeStart: timeStart.toJSDate(),
+ datetimeEnd: timeEnd.toJSDate()
});
mockUseFindSessionQuery.mockReturnValue({
@@ -219,6 +219,35 @@ describe('SessionForm', () => {
expect(editMutateAsync).not.toHaveBeenCalled();
});
+ test('submits a session that runs past midnight with the end on the next day', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ mockUseFindSessionQuery.mockReturnValue({
+ data: new WorkoutSession({
+ id: null,
+ dayId: dayId,
+ routineId: routineId,
+ notes: '',
+ impression: '2',
+ datetimeStart: DateTime.fromISO('2024-05-01T23:00').toJSDate(),
+ datetimeEnd: DateTime.fromISO('2024-05-01T01:30').toJSDate(),
+ }),
+ isLoading: false,
+ isSuccess: true
+ });
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ // Assert
+ await waitFor(() => expect(editMutateAsync).toHaveBeenCalled());
+ const draft = editMutateAsync.mock.calls[0][0] as WorkoutSession;
+ expect(draft.datetimeStart).toEqual(DateTime.fromISO('2024-05-01T23:00').toJSDate());
+ expect(draft.datetimeEnd).toEqual(DateTime.fromISO('2024-05-02T01:30').toJSDate());
+ });
+
test('submits an existing session through the edit mutation', async () => {
// Arrange
@@ -227,11 +256,11 @@ describe('SessionForm', () => {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001',
dayId: dayId,
routineId: routineId,
- date: DateTime.fromISO('2024-05-01').toJSDate(),
+
notes: 'Test notes',
impression: '3',
- timeStart: null,
- timeEnd: null
+ datetimeStart: DateTime.fromISO('2024-05-01').toJSDate(),
+ datetimeEnd: null
});
mockUseFindSessionQuery.mockReturnValue({
data: mockSession,
diff --git a/src/components/Routines/widgets/forms/SessionForm.tsx b/src/components/Routines/widgets/forms/SessionForm.tsx
index e43f93238..cbef9bd15 100644
--- a/src/components/Routines/widgets/forms/SessionForm.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.tsx
@@ -48,7 +48,8 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
routineId,
{
routine: routineId,
- date: dateToYYYYMMDD(selectedDate.toJSDate()),
+ // eslint-disable-next-line camelcase
+ datetime_start__date: dateToYYYYMMDD(selectedDate.toJSDate()),
day: dayId
}
);
@@ -65,13 +66,10 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
start: yup
.date()
.nullable(),
+ // An end before the start is not an error, it means the session ran over midnight
end: yup
.date()
- .nullable()
- .min(
- yup.ref('start'),
- t('forms.endBeforeStart')
- ),
+ .nullable(),
fitInWeek: yup.boolean()
});
@@ -84,18 +82,18 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
formikRef.current.setValues({
notes: findSessionQuery.data.notes || '',
impression: findSessionQuery.data.impression || IMPRESSION_NEUTRAL,
- date: findSessionQuery.data.date,
- start: findSessionQuery.data.timeStart ? DateTime.fromJSDate(findSessionQuery.data.timeStart) : null,
- end: findSessionQuery.data.timeEnd ? DateTime.fromJSDate(findSessionQuery.data.timeEnd) : null,
+ date: findSessionQuery.data.datetimeStart,
+ start: DateTime.fromJSDate(findSessionQuery.data.datetimeStart),
+ end: findSessionQuery.data.datetimeEnd ? DateTime.fromJSDate(findSessionQuery.data.datetimeEnd) : null,
});
setSession(findSessionQuery.data);
} else if (findSessionQuery.isSuccess && !findSessionQuery.data) {
formikRef.current.setValues({
notes: '',
impression: IMPRESSION_NEUTRAL,
- date: initialSession?.date || DateTime.now().toJSDate(), //JS Date, not DateTime
- start: initialSession?.timeStart ? DateTime.fromJSDate(initialSession.timeStart) : null,
- end: initialSession?.timeEnd ? DateTime.fromJSDate(initialSession.timeEnd) : null,
+ date: initialSession?.datetimeStart || DateTime.now().toJSDate(), //JS Date, not DateTime
+ start: initialSession ? DateTime.fromJSDate(initialSession.datetimeStart) : null,
+ end: initialSession?.datetimeEnd ? DateTime.fromJSDate(initialSession.datetimeEnd) : null,
});
setSession(undefined);
}
@@ -108,23 +106,35 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
enableReinitialize
initialValues={{
notes: session !== undefined ? session.notes : '',
- date: session !== undefined ? session.date : new Date(),
- start: session !== undefined && session.timeStart !== null ? DateTime.fromJSDate(session.timeStart!) : null,
- end: session !== undefined && session.timeEnd !== null ? DateTime.fromJSDate(session.timeEnd!) : null,
+ date: session !== undefined ? session.datetimeStart : new Date(),
+ start: session !== undefined ? DateTime.fromJSDate(session.datetimeStart) : null,
+ end: session?.datetimeEnd != null ? DateTime.fromJSDate(session.datetimeEnd) : null,
impression: session !== undefined ? session.impression : IMPRESSION_NEUTRAL,
}}
innerRef={formikRef}
validationSchema={validationSchema}
onSubmit={async (values) => {
+ const day = selectedDate.startOf('day');
+ const start = values.start
+ ? day.set({ hour: values.start.hour, minute: values.start.minute })
+ : day;
+ let end = values.end
+ ? day.set({ hour: values.end.hour, minute: values.end.minute })
+ : null;
+
+ // An end before the start means the session ran past midnight
+ if (end !== null && end < start) {
+ end = end.plus({ days: 1 });
+ }
+
const draft = new WorkoutSession({
id: session?.id ?? null,
dayId: dayId,
routineId: routineId,
- date: selectedDate.toJSDate(),
notes: values.notes,
impression: values.impression,
- timeStart: values.start ? values.start.toJSDate() : null,
- timeEnd: values.end ? values.end.toJSDate() : null,
+ datetimeStart: start.toJSDate(),
+ datetimeEnd: end !== null ? end.toJSDate() : null,
});
if (session !== undefined) {
diff --git a/src/tests/workoutLogsRoutinesTestData.ts b/src/tests/workoutLogsRoutinesTestData.ts
index 796056380..fa47ed814 100644
--- a/src/tests/workoutLogsRoutinesTestData.ts
+++ b/src/tests/workoutLogsRoutinesTestData.ts
@@ -82,9 +82,8 @@ export const testWorkoutSession = new WorkoutSession({
id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001',
dayId: 2,
routineId: 3,
- date: new Date(2025, 1, 10),
notes: 'everything is awesome',
impression: "1",
- timeStart: new Date(2025, 1, 10, 10, 30),
- timeEnd: new Date(2025, 1, 10, 12, 0),
+ datetimeStart: new Date(2025, 1, 10, 10, 30),
+ datetimeEnd: new Date(2025, 1, 10, 12, 0),
});
\ No newline at end of file
diff --git a/src/tests/workoutRoutinesTestData.ts b/src/tests/workoutRoutinesTestData.ts
index eeec2b12b..fc2276de4 100644
--- a/src/tests/workoutRoutinesTestData.ts
+++ b/src/tests/workoutRoutinesTestData.ts
@@ -209,11 +209,10 @@ export const testRoutineLogData = [
id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000111',
dayId: 2,
routineId: 1,
- date: yyyymmddToDate('2024-07-01'),
notes: 'everything was great today!',
impression: '1',
- timeStart: new Date('2024-12-01 12:30'),
- timeEnd: new Date('2024-12-01 17:30'),
+ datetimeStart: new Date('2024-07-01 12:30'),
+ datetimeEnd: new Date('2024-07-01 17:30'),
}),
testWorkoutLogs
)
@@ -707,11 +706,10 @@ export const responseRoutineLogData = [
id: 1,
day: 5,
routine: 1,
- date: "2024-08-01",
notes: "felt good",
impression: "3",
- time_start: "10:00",
- time_end: "11:00",
+ datetime_start: "2024-08-01T10:00:00+02:00",
+ datetime_end: "2024-08-01T11:00:00+02:00",
},
logs: [],
},
From ba00d1ae87977a7b6051e0d32165a2a5ea779f31 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Mon, 10 Aug 2026 16:50:17 +0200
Subject: [PATCH 073/102] Show session API errors in the form
---
.../widgets/forms/SessionForm.test.tsx | 26 +++++++++++++++++++
.../Routines/widgets/forms/SessionForm.tsx | 5 ++++
2 files changed, 31 insertions(+)
diff --git a/src/components/Routines/widgets/forms/SessionForm.test.tsx b/src/components/Routines/widgets/forms/SessionForm.test.tsx
index 802f6fb92..0f6d5228c 100644
--- a/src/components/Routines/widgets/forms/SessionForm.test.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.test.tsx
@@ -219,6 +219,32 @@ describe('SessionForm', () => {
expect(editMutateAsync).not.toHaveBeenCalled();
});
+ test('shows what the server rejected', async () => {
+
+ // Arrange
+ mockUseFindSessionQuery.mockReturnValue({
+ data: null,
+ isLoading: false,
+ isSuccess: true
+ });
+ mockUseAddSessionQuery.mockReturnValue({
+ data: null,
+ isPending: false,
+ mutateAsync: addMutateAsync,
+ isError: true,
+ error: {
+ message: 'Request failed with status code 400',
+ response: { data: { datetime_end: ['A session cannot be longer than 5 hours.'] } }
+ },
+ });
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+
+ // Assert
+ expect(screen.getByText(/A session cannot be longer than 5 hours/)).toBeInTheDocument();
+ });
+
test('submits a session that runs past midnight with the end on the next day', async () => {
// Arrange
diff --git a/src/components/Routines/widgets/forms/SessionForm.tsx b/src/components/Routines/widgets/forms/SessionForm.tsx
index cbef9bd15..2bfbb076a 100644
--- a/src/components/Routines/widgets/forms/SessionForm.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.tsx
@@ -8,6 +8,7 @@ import {
import { useAddSessionQuery, useEditSessionQuery, useFindSessionQuery } from "@/components/Routines/queries";
import { WgerTextField } from "@/core/forms/WgerTextField";
import { dateToYYYYMMDD } from "@/core/lib/date";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { SentimentNeutral, SentimentSatisfiedAlt, SentimentVeryDissatisfied } from "@mui/icons-material";
import { Button, ButtonGroup, Typography } from "@mui/material";
import Grid from '@mui/material/Grid';
@@ -258,6 +259,10 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
+
+
+
+
Date: Thu, 13 Aug 2026 17:56:31 +0200
Subject: [PATCH 074/102] Refresh the body weight views after an entry is
written
---
.../Measurements/queries/bodyWeight.test.tsx | 50 +++++++++++++++----
.../Measurements/queries/bodyWeight.ts | 2 +-
2 files changed, 42 insertions(+), 10 deletions(-)
diff --git a/src/components/Measurements/queries/bodyWeight.test.tsx b/src/components/Measurements/queries/bodyWeight.test.tsx
index 3e78a9b55..699eafe1b 100644
--- a/src/components/Measurements/queries/bodyWeight.test.tsx
+++ b/src/components/Measurements/queries/bodyWeight.test.tsx
@@ -1,13 +1,39 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { renderHook, waitFor } from '@testing-library/react';
+import { act, renderHook, waitFor } from '@testing-library/react';
import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
+import {
+ addMeasurementEntry,
+ deleteMeasurementEntry,
+ editMeasurementEntry
+} from "@/components/Measurements/api/measurements";
+import {
+ useAddMeasurementEntryQuery,
+ useDeleteMeasurementEntryQuery,
+ useEditMeasurementEntryQuery
+} from "@/components/Measurements/queries";
import { useBodyWeightQuery } from "@/components/Measurements/queries/bodyWeight";
-import { QueryKey } from "@/core/lib/consts";
-import { testBodyWeightCategory } from "@/tests/weight/testData";
+import { testBodyWeightCategory, testWeightEntry1 } from "@/tests/weight/testData";
import React from "react";
import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/api/bodyWeight");
+vi.mock("@/components/Measurements/api/measurements");
+
+/** Each entry mutation, wrapped in the call that writes a body weight row */
+const entryMutations: [string, () => () => void][] = [
+ ['added', () => {
+ const mutation = useAddMeasurementEntryQuery();
+ return () => mutation.mutate(testWeightEntry1);
+ }],
+ ['edited', () => {
+ const mutation = useEditMeasurementEntryQuery();
+ return () => mutation.mutate(testWeightEntry1);
+ }],
+ ['deleted', () => {
+ const mutation = useDeleteMeasurementEntryQuery();
+ return () => mutation.mutate(testWeightEntry1.id!);
+ }],
+];
describe("body weight queries", () => {
@@ -15,21 +41,27 @@ describe("body weight queries", () => {
vi.clearAllMocks();
(getBodyWeightCategory as Mock).mockResolvedValue(testBodyWeightCategory);
(getWeights as Mock).mockResolvedValue([]);
+ (addMeasurementEntry as Mock).mockResolvedValue(testWeightEntry1);
+ (editMeasurementEntry as Mock).mockResolvedValue(testWeightEntry1);
+ (deleteMeasurementEntry as Mock).mockResolvedValue(undefined);
});
- test('an entry written anywhere invalidates the body weight view', async () => {
+ // Body weight rows are measurement rows, so a write through the measurement
+ // mutations has to refresh the weight view as well
+ test.each(entryMutations)('an entry %s invalidates the body weight view', async (_name, useWrite) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const wrapper = ({ children }: { children: React.ReactNode }) =>
{children};
- const { result } = renderHook(() => useBodyWeightQuery(), { wrapper });
- await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ const { result } = renderHook(
+ () => ({ weights: useBodyWeightQuery(), write: useWrite() }),
+ { wrapper }
+ );
+ await waitFor(() => expect(result.current.weights.isSuccess).toBe(true));
expect(getWeights).toHaveBeenCalledTimes(1);
- // What the measurement entry mutations invalidate. Body weight rows are
- // measurement rows, so this view has to follow
- await queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS] });
+ act(() => result.current.write());
await waitFor(() => expect(getWeights).toHaveBeenCalledTimes(2));
});
diff --git a/src/components/Measurements/queries/bodyWeight.ts b/src/components/Measurements/queries/bodyWeight.ts
index 1fbf93d6f..9a23dd0da 100644
--- a/src/components/Measurements/queries/bodyWeight.ts
+++ b/src/components/Measurements/queries/bodyWeight.ts
@@ -52,7 +52,7 @@ export function useBodyWeightQuery(filtersetQueryEntries: object = {}) {
const queryClient = useQueryClient();
return useQuery({
- queryKey: [QueryKey.MEASUREMENTS, OFFICIAL_BODY_WEIGHT, filtersetQueryEntries],
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, OFFICIAL_BODY_WEIGHT, filtersetQueryEntries],
queryFn: async () => {
const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
return getWeights(category, filtersetQueryEntries);
From b840cdf60171c9d3484952227859187099d99ab7 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 18:08:02 +0200
Subject: [PATCH 075/102] Read the calendar month as instants instead of dates
---
.../Components/CalendarComponent.test.tsx | 23 +++++++++++
.../Calendar/Components/CalendarComponent.tsx | 40 +++++++++----------
2 files changed, 41 insertions(+), 22 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index b2bacd7b3..2e701b456 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -227,4 +227,27 @@ describe('CalendarComponent', () => {
// Assert
expect(await screen.findByText('70.0 server.kg')).toBeInTheDocument();
});
+
+ test('reads the month as the instants it spans in the browser timezone', async () => {
+ const start = new Date(currentYear, currentMonth, 1).toISOString();
+ const end = new Date(currentYear, currentMonth + 1, 1).toISOString();
+
+ renderComponent();
+ await screen.findByTestId(`day-${dateToYYYYMMDD(new Date(currentYear, currentMonth, 1))}`);
+
+ // A date bound would be read as midnight in the server's timezone and
+ // leave out the entries of the last day
+ expect(getWeights).toHaveBeenCalledWith(
+ testBodyWeightCategory,
+ { "date__gte": start, "date__lt": end },
+ );
+ expect(getAllMeasurementEntries).toHaveBeenCalledWith({ "date__gte": start, "date__lt": end });
+ expect(getSessions).toHaveBeenCalledWith({
+ filtersetQuerySessions: { "datetime_start__gte": start, "datetime_start__lt": end },
+ filtersetQueryLogs: { "date__gte": start, "date__lt": end },
+ });
+ expect(getNutritionalDiaryEntries).toHaveBeenCalledWith({
+ filtersetQuery: { "datetime__gte": start, "datetime__lt": end },
+ });
+ });
});
\ No newline at end of file
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index b77556cc1..4a2bd5724 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -10,7 +10,7 @@ import {
} from "@/components/Measurements";
import { DiaryEntry, useNutritionDiaryQuery } from "@/components/Nutrition";
import { useSessionsQuery, WorkoutSession } from "@/components/Routines";
-import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date";
+import { isSameDay } from "@/core/lib/date";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { Box, Card, CardContent, CardHeader, useMediaQuery, useTheme } from '@mui/material';
@@ -35,41 +35,37 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const [currentYear, setCurrentYear] = useState(currentDate.getFullYear());
const startOfMonth = new Date(currentYear, currentMonth, 1);
- const endOfMonth = new Date(currentYear, currentMonth + 1, 0);
+ const startOfNextMonth = new Date(currentYear, currentMonth + 1, 1);
const isStandalone = props.isStandalone ?? true;
+ /*
+ * The month the calendar shows, as the instants it begins and ends at.
+ *
+ * Everything read here is stored as a datetime, and the days are grouped in
+ * the browser's timezone: a YYYY-MM-DD bound would parse to midnight in the
+ * server's and drop the entries of the last day.
+ */
+ const monthWindow = (field: string) => ({
+ [`${field}__gte`]: startOfMonth.toISOString(),
+ [`${field}__lt`]: startOfNextMonth.toISOString(),
+ });
// The calendar shows one month, so body weight is read for the same window
// as everything else on it
- const weightsQuery = useBodyWeightQuery({
- "date__gte": dateToYYYYMMDD(startOfMonth),
- "date__lte": dateToYYYYMMDD(endOfMonth),
- });
+ const weightsQuery = useBodyWeightQuery(monthWindow('date'));
const sessionQuery = useSessionsQuery({
- filtersetQuerySessions: {
- "datetime_start__gte": startOfMonth.toISOString(),
- "datetime_start__lt": new Date(currentYear, currentMonth + 1, 1).toISOString(),
- },
- filtersetQueryLogs: {
- "date__gte": dateToYYYYMMDD(startOfMonth),
- "date__lte": dateToYYYYMMDD(endOfMonth),
- }
+ filtersetQuerySessions: monthWindow('datetime_start'),
+ filtersetQueryLogs: monthWindow('date'),
});
// The categories name the entries below, which arrive from one read over
// all of them: asking per category would be a request each, and would
// leave out the components of a group, which are categories the list does
// not return on their own
const categoryQuery = useMeasurementsCategoryQuery();
- const measurementQuery = useAllMeasurementEntriesQuery({
- "date__gte": dateToYYYYMMDD(startOfMonth),
- "date__lte": dateToYYYYMMDD(endOfMonth),
- });
+ const measurementQuery = useAllMeasurementEntriesQuery(monthWindow('date'));
const nutritionDiaryQuery = useNutritionDiaryQuery({
- filtersetQuery: {
- "datetime__gte": dateToYYYYMMDD(startOfMonth),
- "datetime__lte": dateToYYYYMMDD(endOfMonth),
- }
+ filtersetQuery: monthWindow('datetime'),
});
const isLoading = weightsQuery.isLoading || sessionQuery.isLoading || categoryQuery.isLoading || measurementQuery.isLoading || nutritionDiaryQuery.isLoading;
From 6da5885cbfb0d8fbd1fff0f206f14b04c0be9c35 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 18:16:11 +0200
Subject: [PATCH 076/102] List only the selected range in the body weight table
---
.../Measurements/screens/BodyWeight.test.tsx | 13 +++++++++++--
.../Measurements/screens/BodyWeight.tsx | 16 +++++++++++-----
2 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/components/Measurements/screens/BodyWeight.test.tsx b/src/components/Measurements/screens/BodyWeight.test.tsx
index 6230f52b5..c8a5fee71 100644
--- a/src/components/Measurements/screens/BodyWeight.test.tsx
+++ b/src/components/Measurements/screens/BodyWeight.test.tsx
@@ -30,9 +30,14 @@ describe("Test BodyWeight component", () => {
});
// Arrange
+ const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+
+ // The last entry is one the read brings along for the moving average: it
+ // lies in the lead of the default range, not in the range itself
const weightData = [
- makeWeightEntry(new Date('2021-12-10'), 80, { id: 'dddddddd-dddd-dddd-dddd-000000000001' }),
- makeWeightEntry(new Date('2021-12-20'), 90, { id: 'dddddddd-dddd-dddd-dddd-000000000002' }),
+ makeWeightEntry(daysAgo(2), 80, { id: 'dddddddd-dddd-dddd-dddd-000000000001' }),
+ makeWeightEntry(daysAgo(10), 90, { id: 'dddddddd-dddd-dddd-dddd-000000000002' }),
+ makeWeightEntry(daysAgo(40), 70, { id: 'dddddddd-dddd-dddd-dddd-000000000003' }),
];
test('renders without crashing', async () => {
@@ -50,6 +55,8 @@ describe("Test BodyWeight component", () => {
// grid shows them in
expect(await screen.findByText("80 kg")).toBeInTheDocument();
expect(await screen.findByText("90 kg")).toBeInTheDocument();
+ // the lead is read for the moving average, the table lists the range
+ expect(screen.queryByText("70 kg")).toBeNull();
// only the entries the range shows are fetched
expect(getWeights).toHaveBeenCalledWith(
testBodyWeightCategory,
@@ -77,5 +84,7 @@ describe("Test BodyWeight component", () => {
});
// the entries stay on screen while the wider range is loading
expect(screen.getByText("80 kg")).toBeInTheDocument();
+ // the full history has no lead, every entry read is one it covers
+ expect(await screen.findByText("70 kg")).toBeInTheDocument();
});
});
diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
index 0bc6d0452..be809d435 100644
--- a/src/components/Measurements/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -1,5 +1,5 @@
import { Box, Stack } from "@mui/material";
-import { entryFilterFor } from "@/components/Measurements/charts/range";
+import { displayCutoffFor, entryFilterFor } from "@/components/Measurements/charts/range";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { PlanPeriod } from "@/components/Measurements/charts/series";
@@ -23,9 +23,8 @@ export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => {
// Shared with the other measurement screens, see useChartRange
const range = useChartRange();
// Fetch what the range shows, rather than the whole history. The filter
- // reaches a week further back than the chart draws, so the moving average
- // of the first days in range still averages the days before them. The
- // table below lists the same entries, so it follows the range too
+ // reaches a month further back than the chart draws, so the moving average
+ // of the first days in range still averages the days before them
const weightyQuery = useBodyWeightQuery(entryFilterFor(range));
const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
@@ -37,6 +36,13 @@ export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => {
// Entries without their own unit fall back to the one of the category
const categoryUnit = categoryQuery.data!.unit;
+ // The range as it is labelled: the lead the chart averages over is not part
+ // of it, and the table would list those rows as if they were
+ const cutoff = displayCutoffFor(range);
+ const entriesInRange = cutoff === null
+ ? weightyQuery.data!
+ : weightyQuery.data!.filter(entry => entry.date >= cutoff);
+
return
@@ -53,7 +59,7 @@ export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => {
>}
From 59215d6b929f11317e248971740c4cf228dd88c2 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 18:28:11 +0200
Subject: [PATCH 077/102] Cut every view of a range at the same day
---
src/components/Measurements/charts/range.ts | 20 +++++++++----
.../Measurements/widgets/MeasurementChart.tsx | 4 +--
.../Measurements/widgets/WeightChart.test.tsx | 29 ++++++++++++++++++-
.../Measurements/widgets/WeightChart.tsx | 8 +++--
4 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index 4cbebecc5..1d0404be2 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -25,7 +25,13 @@ const DAYS: Record = {
lastWeek: 6,
};
-/** Oldest date still shown, null for the full history */
+/**
+ * The instant a range starts at, null for the full history.
+ *
+ * The bound the other cutoffs are derived from, not one to cut a view at: it
+ * moves with the clock, so the day it lands on would be half in and half out.
+ * Views cut at displayCutoffFor.
+ */
export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => {
const days = DAYS[range];
@@ -64,12 +70,14 @@ export const fetchCutoffFor = (range: ChartRange, now: Date = new Date()): Date
cutoffAtMidnight(range, now, AVERAGE_LEAD_DAYS);
/**
- * Oldest entry to summarise for a range, null for the full history: the range
- * itself, with no lead.
+ * The day a range starts on, null for the full history: the range itself, with
+ * no lead.
*
- * For the reads that cannot be trimmed afterwards, i.e. the counted values
- * behind the histogram: they carry no date, so a read with the average lead
- * would bin a month and a half into a chart labelled one month.
+ * Where every view of a range is cut, so that the chart, the table and the
+ * histogram agree on the day at its edge: it is in whole or not at all. The
+ * reads that cannot be trimmed afterwards use it as well, i.e. the counted
+ * values behind the histogram: they carry no date, so a read with the average
+ * lead would bin a month and a half into a chart labelled one month.
*/
export const displayCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null =>
cutoffAtMidnight(range, now, 0);
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index acdbd2934..8d7cf9bae 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -24,8 +24,8 @@ import {
} from "@/components/Measurements/queries";
import {
ChartRange,
- cutoffFor,
DEFAULT_CHART_RANGE,
+ displayCutoffFor,
displayFilterFor,
pointsSince
} from "@/components/Measurements/charts/range";
@@ -53,7 +53,7 @@ export const MeasurementChart = (props: {
const [t] = useTranslation();
const category = props.category;
const range = props.range ?? DEFAULT_CHART_RANGE;
- const cutoff = cutoffFor(range);
+ const cutoff = displayCutoffFor(range);
const summed = isSummedPerDay(category.metricType);
// A pick that does not fit the metric type falls back to the derived chart,
diff --git a/src/components/Measurements/widgets/WeightChart.test.tsx b/src/components/Measurements/widgets/WeightChart.test.tsx
index f7cf3cd1e..6de68d50c 100644
--- a/src/components/Measurements/widgets/WeightChart.test.tsx
+++ b/src/components/Measurements/widgets/WeightChart.test.tsx
@@ -1,7 +1,7 @@
import { chartPointsFor, measurementSeries, MeasurementEntry } from "@/components/Measurements";
import { makeWeightEntry } from "@/tests/weight/testData";
import { QueryClientProvider } from "@tanstack/react-query";
-import { render } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import React from 'react';
import { describe, expect, test } from 'vitest';
import { testQueryClient } from "@/tests/queryClient";
@@ -43,6 +43,33 @@ describe("WeightChart", () => {
]);
});
+ test('draws the day the range starts on whole', () => {
+ // A Monday noon; six days back is 9 June, the first day of the week
+ // the selector labels as one
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date(2026, 5, 15, 12, 0));
+
+ render(
+
+
+
+ );
+
+ // The range starts at that day's midnight, not at the hour of day the
+ // clock shows, so the morning entry is part of it. With only one entry
+ // left there would be nothing to average and no overall change
+ expect(screen.getByText(/overallChangeWeight/)).toBeInTheDocument();
+
+ vi.useRealTimers();
+ });
+
test('respects the height prop', () => {
renderChart(
[
diff --git a/src/components/Measurements/widgets/WeightChart.tsx b/src/components/Measurements/widgets/WeightChart.tsx
index 8bfedde70..f14a0c605 100644
--- a/src/components/Measurements/widgets/WeightChart.tsx
+++ b/src/components/Measurements/widgets/WeightChart.tsx
@@ -1,5 +1,9 @@
import { chartPointsFor, measurementSeries } from "@/components/Measurements/charts/data";
-import { ChartRange, cutoffFor, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
+import {
+ ChartRange,
+ DEFAULT_CHART_RANGE,
+ displayCutoffFor
+} from "@/components/Measurements/charts/range";
import { PlanPeriod } from "@/components/Measurements/charts/series";
import { ChartConfig } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -35,7 +39,7 @@ export const WeightChart = (
// before anything is derived from it
const series = measurementSeries(
chartPointsFor(weights, unit, categoryUnit),
- cutoffFor(range ?? DEFAULT_CHART_RANGE),
+ displayCutoffFor(range ?? DEFAULT_CHART_RANGE),
chartConfig,
);
From ce8494c1fa808d1adff723020256746703b3a495 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 18:34:18 +0200
Subject: [PATCH 078/102] Read the theme from context in the chart widgets
---
.../widgets/MeasurementBarChart.tsx | 3 ++-
.../widgets/MeasurementChart.test.tsx | 20 +++++++++++++++++++
.../widgets/MeasurementDeltaBarChart.tsx | 3 ++-
.../widgets/MeasurementDistributionChart.tsx | 4 ++--
.../widgets/MeasurementHeatmapChart.tsx | 4 ++--
.../widgets/MeasurementRangeBarChart.tsx | 3 ++-
6 files changed, 30 insertions(+), 7 deletions(-)
diff --git a/src/components/Measurements/widgets/MeasurementBarChart.tsx b/src/components/Measurements/widgets/MeasurementBarChart.tsx
index aecc8db3a..ec0076666 100644
--- a/src/components/Measurements/widgets/MeasurementBarChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementBarChart.tsx
@@ -5,9 +5,9 @@ import { durationAxis, valueWithUnit } from "@/components/Measurements/charts/fo
import { ChartPoint } from "@/components/Measurements/charts/series";
import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { Bar } from "recharts";
-import { theme } from "@/theme";
const CustomTooltip = (props: TooltipProps & { category: MeasurementCategory }) => {
const [t, i18n] = useTranslation();
@@ -28,6 +28,7 @@ const CustomTooltip = (props: TooltipProps & { category: MeasurementCategory })
};
export const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
+ const theme = useTheme();
// Bars need a band axis (recharts miscomputes bar heights on a numeric
// time axis), so make the bands time-proportional by filling in the
// missing days instead
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 1180d0d30..9bafa01e6 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,3 +1,4 @@
+import { createTheme, ThemeProvider } from "@mui/material";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from '@testing-library/react';
import { CategorySeed, mockChartQueries } from "@/tests/chartQueries";
@@ -83,6 +84,25 @@ describe('MeasurementChart', () => {
expect(screen.getByRole('img')).toBeInTheDocument();
});
+ test('draws with the theme it is rendered in', () => {
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap');
+ const theme = createTheme({ palette: { primary: { main: 'rgb(1, 2, 3)' } } });
+
+ mockChartQueries([seed(category, [entry('d-1', new Date(2023, 1, 1), 4000)])]);
+ render(
+
+
+
+
+
+ );
+
+ // The app mounts its own theme into a shadow root, so a chart reading
+ // the exported one draws colours the page never set
+ const cells = [...screen.getByRole('img').querySelectorAll('div')];
+ expect(cells.some(cell => getComputedStyle(cell).backgroundColor.includes('1, 2, 3'))).toBe(true);
+ });
+
test('mounts a change chart with the overall change under it', () => {
// 5 January 2026 is a Monday
const category = new MeasurementCategory('c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'delta');
diff --git a/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx b/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
index 6cf3fc318..8cd5527ce 100644
--- a/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
@@ -4,9 +4,9 @@ import { durationAxis, valueWithUnit } from "@/components/Measurements/charts/fo
import { ChartPoint } from "@/components/Measurements/charts/series";
import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { Bar, Cell, ReferenceLine } from "recharts";
-import { theme } from "@/theme";
const DeltaTooltip = (props: TooltipProps & { unit: string }) => {
const [, i18n] = useTranslation();
@@ -30,6 +30,7 @@ const DeltaTooltip = (props: TooltipProps & { unit: string }) => {
*/
export const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string }) => {
const [t] = useTranslation();
+ const theme = useTheme();
if (props.points.length === 0) {
return ;
diff --git a/src/components/Measurements/widgets/MeasurementDistributionChart.tsx b/src/components/Measurements/widgets/MeasurementDistributionChart.tsx
index c08d9e025..5a6b64f30 100644
--- a/src/components/Measurements/widgets/MeasurementDistributionChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementDistributionChart.tsx
@@ -1,10 +1,9 @@
-import { Box, Typography } from "@mui/material";
+import { Box, Typography, useTheme } from "@mui/material";
import { buildHistogram, ValueCount } from "@/components/Measurements/charts/data";
import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import React from "react";
import { useTranslation } from "react-i18next";
-import { theme } from "@/theme";
/**
* Histogram of how often each value occurred: the values of the selected range
@@ -23,6 +22,7 @@ export const MeasurementDistributionChart = (props: {
countsAreDays?: boolean,
}) => {
const [t, i18n] = useTranslation();
+ const theme = useTheme();
const [selected, setSelected] = React.useState(null);
if (props.values.length === 0) {
diff --git a/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
index be8488d2c..9922c872e 100644
--- a/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
@@ -1,4 +1,4 @@
-import { alpha, Box, Typography } from "@mui/material";
+import { alpha, Box, Typography, useTheme } from "@mui/material";
import { buildHeatmapGrid, DAYS_PER_WEEK, heatmapDayAt } from "@/components/Measurements/charts/data";
import { valueWithUnit } from "@/components/Measurements/charts/format";
import { ChartPoint } from "@/components/Measurements/charts/series";
@@ -6,7 +6,6 @@ import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptySta
import { dateToLocale } from "@/core/lib/date";
import React from "react";
import { useTranslation } from "react-i18next";
-import { theme } from "@/theme";
/** Widest a heatmap cell gets, and the room its weekday labels need */
const MAX_HEATMAP_CELL = 22;
@@ -25,6 +24,7 @@ const WEEKDAY_LABEL_WIDTH = 30;
*/
export const MeasurementHeatmapChart = (props: { points: ChartPoint[], unit: string }) => {
const [t, i18n] = useTranslation();
+ const theme = useTheme();
const [selected, setSelected] = React.useState(null);
if (props.points.length === 0) {
diff --git a/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
index 63872c550..8a5295414 100644
--- a/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
@@ -2,9 +2,9 @@ import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
import { durationAxis, valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
import { ChartPoint } from "@/components/Measurements/charts/series";
import { BarChartFrame, TooltipFrame, TooltipProps } from "@/components/Measurements/widgets/chartFrames";
+import { useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import { Bar } from "recharts";
-import { theme } from "@/theme";
const RangeTooltip = (props: TooltipProps & { unit: string }) => {
const [, i18n] = useTranslation();
@@ -33,6 +33,7 @@ const RangeTooltip = (props: TooltipProps & { unit: string }) => {
* matters, the gap within one reading.
*/
export const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
+ const theme = useTheme();
const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
return
Date: Thu, 13 Aug 2026 18:36:56 +0200
Subject: [PATCH 079/102] Show measurement categories under their metric type
on the dashboard
---
src/components/Dashboard/MeasurementCard.test.tsx | 9 ++++++---
src/components/Dashboard/MeasurementCard.tsx | 6 ++++--
2 files changed, 10 insertions(+), 5 deletions(-)
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index 78eedab46..969165d0c 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -72,8 +72,8 @@ describe("smoke test the MeasurementCard component", () => {
beforeEach(() => {
const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
- const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure', false, 'g-1');
- const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure', false, 'g-1');
+ const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1');
+ const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1');
group.children = [systolic, diastolic];
const systolicEntries = [
// sorted by date descending, like the server delivers them
@@ -106,7 +106,10 @@ describe("smoke test the MeasurementCard component", () => {
// Assert
expect(screen.getAllByText('Blood pressure').length).toBeGreaterThan(0);
- expect(screen.getAllByText('Systolic').length).toBeGreaterThan(0);
+ // a typed component is listed under its metric type; the name the
+ // server stored for it is English
+ expect(screen.getAllByText('measurements.metricTypes.blood_pressure_systolic').length)
+ .toBeGreaterThan(0);
// scoped to the table, the values also appear on the chart's axis
const table = within(screen.getByRole('table'));
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index cb98d8be7..afaff769f 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -2,6 +2,7 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { DashboardCard } from "@/components/Dashboard/DashboardCard";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
import {
+ categoryDisplayName,
CategoryForm,
componentColor,
componentPalette,
@@ -102,6 +103,7 @@ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) =>
* left out where the chart draws something else than one line per component.
*/
const ComponentRow = (props: { component: MeasurementCategory, unit: string, color?: string }) => {
+ const { t } = useTranslation();
// Only the newest one is shown, so only the newest one is read
const latest = useMeasurementEntriesQuery(props.component.id!, {}, 1).data?.[0];
@@ -114,7 +116,7 @@ const ComponentRow = (props: { component: MeasurementCategory, unit: string, col
height: 12,
width: 12,
}} />}
- {props.component.name}
+ {categoryDisplayName(props.component, t)}
@@ -152,7 +154,7 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
return (<>
- {props.category.name}
+ {categoryDisplayName(props.category, t)}
From a708964e93d36d35c6cf933d16dcf20a431a63ad Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 18:46:28 +0200
Subject: [PATCH 080/102] Deduplicate the measurement widgets and forms
---
.../Dashboard/MeasurementCard.test.tsx | 10 ++++-
src/components/Dashboard/MeasurementCard.tsx | 6 ++-
src/components/Measurements/index.ts | 1 +
.../widgets/EntryDateTimeField.tsx | 31 +++++++++++++
.../Measurements/widgets/EntryForm.tsx | 45 ++++---------------
.../widgets/MeasurementSeriesChart.tsx | 19 +++-----
.../Measurements/widgets/WeightForm.tsx | 25 +++--------
src/components/Measurements/widgets/fab.tsx | 31 +++++--------
src/types.ts | 3 ++
9 files changed, 81 insertions(+), 90 deletions(-)
create mode 100644 src/components/Measurements/widgets/EntryDateTimeField.tsx
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index 969165d0c..070f11d53 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -3,6 +3,7 @@ import { render, screen, within } from '@testing-library/react';
import { MeasurementCard } from "@/components/Dashboard/MeasurementCard";
import {
MeasurementCategory,
+ useLatestMeasurementEntriesQuery,
useMeasurementEntriesQuery,
useMeasurementsCategoryQuery
} from "@/components/Measurements";
@@ -22,10 +23,17 @@ vi.useFakeTimers();
const queryClient = new QueryClient();
/** Answers the entry reads of the table under each chart, by category */
-const mockEntryQueries = (byCategory: Record) =>
+const mockEntryQueries = (byCategory: Record) => {
(useMeasurementEntriesQuery as Mock).mockImplementation(
(categoryId: string) => ({ data: byCategory[categoryId] ?? [] })
);
+ // the component rows of a group read the newest entry of each of them
+ (useLatestMeasurementEntriesQuery as Mock).mockImplementation(
+ (categoryIds: string[]) => ({
+ data: categoryIds.flatMap(id => (byCategory[id] ?? []).slice(0, 1)),
+ })
+ );
+};
describe("smoke test the MeasurementCard component", () => {
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index afaff769f..d8c6fafd3 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -12,6 +12,7 @@ import {
groupComponentPoints,
MeasurementCategory,
MeasurementChart,
+ useLatestMeasurementEntriesQuery,
useMeasurementBucketsQuery,
useMeasurementEntriesQuery,
useMeasurementsCategoryQuery,
@@ -104,8 +105,9 @@ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) =>
*/
const ComponentRow = (props: { component: MeasurementCategory, unit: string, color?: string }) => {
const { t } = useTranslation();
- // Only the newest one is shown, so only the newest one is read
- const latest = useMeasurementEntriesQuery(props.component.id!, {}, 1).data?.[0];
+ // The same read the category headers use, so the newest value of a
+ // category is cached once rather than under a key per caller
+ const latest = useLatestMeasurementEntriesQuery([props.component.id!]).data?.[0];
return
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index f32b521c1..087c12c4f 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -37,6 +37,7 @@ export {
useAllMeasurementEntriesQuery,
useDeleteMeasurementEntryQuery,
useEditMeasurementEntryQuery,
+ useLatestMeasurementEntriesQuery,
useMeasurementBucketsQuery,
useMeasurementEntriesQuery,
useMeasurementsCategoryQuery,
diff --git a/src/components/Measurements/widgets/EntryDateTimeField.tsx b/src/components/Measurements/widgets/EntryDateTimeField.tsx
new file mode 100644
index 000000000..096bbabb7
--- /dev/null
+++ b/src/components/Measurements/widgets/EntryDateTimeField.tsx
@@ -0,0 +1,31 @@
+import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
+import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
+import { DateTime } from "luxon";
+import React from 'react';
+import { useTranslation } from "react-i18next";
+
+/**
+ * When an entry was measured: the date field every measurement form has.
+ *
+ * Keeps what the picker holds, which an incomplete input leaves empty, and
+ * hands the form only the dates it can store.
+ */
+export const EntryDateTimeField = (props: { initialDate: Date, onChange: (date: Date) => void }) => {
+ const [t, i18n] = useTranslation();
+ const [value, setValue] = React.useState(DateTime.fromJSDate(props.initialDate));
+
+ return
+ {
+ if (newValue) {
+ props.onChange(newValue.toJSDate());
+ }
+ setValue(newValue);
+ }}
+ />
+ ;
+};
diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx
index 2631cd37f..208b08519 100644
--- a/src/components/Measurements/widgets/EntryForm.tsx
+++ b/src/components/Measurements/widgets/EntryForm.tsx
@@ -1,6 +1,4 @@
import { Button, Stack, TextField } from "@mui/material";
-import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
-import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import {
categoryDisplayName,
limitsFor,
@@ -13,9 +11,8 @@ import {
useAddMeasurementEntryQuery,
useEditMeasurementEntryQuery
} from "@/components/Measurements/queries";
+import { EntryDateTimeField } from "@/components/Measurements/widgets/EntryDateTimeField";
import { Form, Formik } from "formik";
-import { DateTime } from "luxon";
-import React from 'react';
import { useTranslation } from "react-i18next";
import * as yup from 'yup';
@@ -34,11 +31,10 @@ interface EntryFormProps {
export const EntryForm = ({ entry, closeFn, category }: EntryFormProps) => {
- const [t, i18n] = useTranslation();
+ const [t] = useTranslation();
const useAddEntryQuery = useAddMeasurementEntryQuery();
const useEditEntryQuery = useEditMeasurementEntryQuery();
- const [dateValue, setDateValue] = React.useState(entry ? DateTime.fromJSDate(entry.date) : DateTime.now());
// The bounds follow the metric type of the category, and for body weight
// the unit the entry itself is in
@@ -98,20 +94,9 @@ export const EntryForm = ({ entry, closeFn, category }: EntryFormProps) => {
slotProps={{ htmlInput: { inputMode: 'decimal' } }}
{...formik.getFieldProps('value')}
/>
-
- {
- if (newValue) {
- formik.setFieldValue('date', newValue.toJSDate());
- }
- setDateValue(newValue);
- }}
- />
-
+ formik.setFieldValue('date', date)} />
{
- const [t, i18n] = useTranslation();
+ const [t] = useTranslation();
const addGroupEntriesQuery = useAddGroupEntriesQuery();
- const [dateValue, setDateValue] = React.useState(DateTime.now());
const validationSchema = yup.object({
date: yup
@@ -195,20 +179,9 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
{formik => (
- `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`,
- zIndex: 9,
- }}>
+
-
+
diff --git a/src/core/ui/Widgets/Fab.tsx b/src/core/ui/Widgets/Fab.tsx
new file mode 100644
index 000000000..45588bacd
--- /dev/null
+++ b/src/core/ui/Widgets/Fab.tsx
@@ -0,0 +1,30 @@
+import { Fab } from "@mui/material";
+import React from "react";
+
+/**
+ * The floating action button of an overview: same look and same place on every
+ * screen, only the icon behind it differs.
+ *
+ * It sits above the bottom navigation, and at the edge of the content rather
+ * than of the window: on a wide screen the content is centred, and a button
+ * pinned to the viewport would stand far away from what it adds to.
+ */
+export const WgerFab = (props: {
+ onClick: () => void,
+ /** Whether the screen is busy, e.g. reloading what the button just added to */
+ disabled?: boolean,
+ children: React.ReactNode,
+}) =>
+ `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`,
+ zIndex: 9,
+ }}>
+ {props.children}
+ ;
From dc264f290244c50d3215b425f9cf20e66c1e4a40 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 19:03:49 +0200
Subject: [PATCH 083/102] Translate the empty chart placeholder
---
public/locales/de/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
3 files changed, 3 insertions(+)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index bd1e04b6e..ded6fef59 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -307,6 +307,7 @@
"indicatorRaw": "raw",
"indicatorAvg": "Durchschn.",
"indicatorTrend": "Trend",
+ "noDataAvailable": "Keine Daten vorhanden",
"overallChangeWeight": "Allgemeine Veränderung",
"chartRangeAll": "Gesamt",
"chartRangeMonths_one": "1 Monat",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 42a418659..9cae3b1ed 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -312,6 +312,7 @@
"indicatorRaw": "Bruto",
"indicatorAvg": "medio",
"indicatorTrend": "tendencia",
+ "noDataAvailable": "No hay datos disponibles",
"overallChangeWeight": "Cambio general",
"chartRangeAll": "Todo",
"chartRangeMonths_one": "1 mes",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 81844eabe..7cf586703 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -395,6 +395,7 @@
"indicatorRaw": "brut",
"indicatorAvg": "moy",
"indicatorTrend": "tendance",
+ "noDataAvailable": "Aucune donnée disponible",
"overallChangeWeight": "Changement global",
"chartRangeAll": "Tout",
"chartRangeMonths_one": "1 mois",
From 59680404c2cb810738570094be6700650c14b7cc Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 21:41:26 +0200
Subject: [PATCH 084/102] Build the reference time of the relative-date tests
inside the test
---
src/core/lib/date.test.ts | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts
index ad0ae8db4..b52b77464 100644
--- a/src/core/lib/date.test.ts
+++ b/src/core/lib/date.test.ts
@@ -86,22 +86,25 @@ describe.each([
});
describe('dateToRelative', () => {
- const now = new Date(2026, 7, 7, 9, 0);
+ // Built in the test rather than held in a constant: the describe body
+ // runs while the tests are collected, before beforeAll switches the
+ // timezone, and the reference instant would be the runner's own
+ const now = () => new Date(2026, 7, 7, 9, 0);
test('today and yesterday are named, not counted', () => {
- expect(dateToRelative(new Date(2026, 7, 7, 0, 30), 'de', now)).toBe('heute');
+ expect(dateToRelative(new Date(2026, 7, 7, 0, 30), 'de', now())).toBe('heute');
// Calendar days, not elapsed hours: late yesterday is yesterday
- expect(dateToRelative(new Date(2026, 7, 6, 23, 50), 'de', now)).toBe('gestern');
+ expect(dateToRelative(new Date(2026, 7, 6, 23, 50), 'de', now())).toBe('gestern');
});
test('recent dates count in days', () => {
- expect(dateToRelative(new Date(2026, 7, 2), 'de', now)).toBe('vor 5 Tagen');
+ expect(dateToRelative(new Date(2026, 7, 2), 'de', now())).toBe('vor 5 Tagen');
});
test('older dates grow to weeks, months and years', () => {
- expect(dateToRelative(new Date(2026, 6, 17), 'de', now)).toBe('vor 3 Wochen');
- expect(dateToRelative(new Date(2026, 5, 1), 'de', now)).toBe('vor 2 Monaten');
- expect(dateToRelative(new Date(2024, 7, 1), 'de', now)).toBe('vor 2 Jahren');
+ expect(dateToRelative(new Date(2026, 6, 17), 'de', now())).toBe('vor 3 Wochen');
+ expect(dateToRelative(new Date(2026, 5, 1), 'de', now())).toBe('vor 2 Monaten');
+ expect(dateToRelative(new Date(2024, 7, 1), 'de', now())).toBe('vor 2 Jahren');
});
});
});
From 976b6b88c0fc517028bf4f77f3f5a2f37c43a679 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 13 Aug 2026 21:44:36 +0200
Subject: [PATCH 085/102] Give the observer test mocks the constructor of the
real API
---
src/tests/setup.ts | 19 ++++++++++++++-----
1 file changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/tests/setup.ts b/src/tests/setup.ts
index 424733c92..ce176a27d 100644
--- a/src/tests/setup.ts
+++ b/src/tests/setup.ts
@@ -33,8 +33,15 @@ global.TextEncoder = TextEncoder as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
global.TextDecoder = TextDecoder as any;
-// jsdom doesn't provide these browser APIs that recharts/react-resize-detector rely on.
+/*
+ * jsdom doesn't provide these browser APIs that recharts/react-resize-detector
+ * rely on. Both take their callback like the real ones do, and never call it:
+ * nothing is laid out here, so nothing is ever observed to change.
+ */
class MockResizeObserver {
+ constructor(readonly callback: ResizeObserverCallback) {
+ }
+
observe(): void {
}
@@ -48,8 +55,12 @@ class MockResizeObserver {
class MockIntersectionObserver {
readonly root = null;
readonly rootMargin = '';
+ readonly scrollMargin = '';
readonly thresholds: ReadonlyArray = [];
+ constructor(readonly callback: IntersectionObserverCallback) {
+ }
+
observe(): void {
}
@@ -64,10 +75,8 @@ class MockIntersectionObserver {
}
}
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-global.ResizeObserver = MockResizeObserver as any;
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-global.IntersectionObserver = MockIntersectionObserver as any;
+global.ResizeObserver = MockResizeObserver;
+global.IntersectionObserver = MockIntersectionObserver;
// runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
From 1d47f6699be479d5ebdb524f1e864b1f0f47f62d Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 15 Aug 2026 15:38:20 +0200
Subject: [PATCH 086/102] Read the calculated categories
---
.../Measurements/api/measurements.test.ts | 8 +++-
.../Measurements/models/Category.test.ts | 46 +++++++++++++++++++
.../Measurements/models/Category.ts | 26 +++++++++++
.../screens/MeasurementCategoryOverview.tsx | 6 ++-
src/components/Measurements/widgets/fab.tsx | 4 ++
5 files changed, 86 insertions(+), 4 deletions(-)
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 42ae0057e..073734d90 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -392,7 +392,9 @@ describe('measurement service tests', () => {
chart_type: null,
chart_config: {},
parent: null,
- order: 0
+ order: 0,
+ dynamic_type: "NONE",
+ dynamic_params: {}
});
expect(result).toBeInstanceOf(MeasurementCategory);
expect(result.id).toBe(CATEGORY_UUID_2);
@@ -417,7 +419,9 @@ describe('measurement service tests', () => {
chart_type: null,
chart_config: {},
parent: null,
- order: 0
+ order: 0,
+ dynamic_type: "NONE",
+ dynamic_params: {}
});
expect(result.name).toBe("Renamed");
});
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index a2c05999b..77226b1ff 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -56,9 +56,55 @@ describe('MeasurementCategory', () => {
chart_config: {},
parent: null,
order: 2,
+ dynamic_type: 'NONE',
+ dynamic_params: {},
});
});
+ test('the calculation survives the json round trip', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1',
+ name: 'Waist to height',
+ unit: '',
+ metric_type: 'custom',
+ is_official: false,
+ dynamic_type: 'WHTR',
+ dynamic_params: { category_id: 'c-waist' },
+ });
+
+ expect(category.isCalculated).toBe(true);
+ expect(MeasurementCategory.clone(category).toJson()).toMatchObject({
+ dynamic_type: 'WHTR',
+ dynamic_params: { category_id: 'c-waist' },
+ });
+ });
+
+ test('a category the server does not calculate is not calculated', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1',
+ name: 'Biceps',
+ unit: 'cm',
+ metric_type: 'custom',
+ is_official: false,
+ });
+
+ expect(category.dynamicType).toBe('NONE');
+ expect(category.isCalculated).toBe(false);
+ });
+
+ test('a calculation added after this release still reads as calculated', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1',
+ name: 'Something new',
+ unit: '',
+ metric_type: 'custom',
+ is_official: false,
+ dynamic_type: 'FUTURE_TYPE',
+ });
+
+ expect(category.isCalculated).toBe(true);
+ });
+
test('an unknown metric type from the server falls back to custom', () => {
expect(metricTypeFromApi('brain_waves')).toBe('custom');
expect(metricTypeFromApi(undefined)).toBe('custom');
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 6f7180187..ed5718232 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -457,6 +457,14 @@ export class MeasurementCategory {
public chartType: ChartType = 'auto',
/** Taste-level chart settings, read through trendOf and averageWindowOf */
public chartConfig: ChartConfig = {},
+ /**
+ * What the server calculates this category from, 'NONE' for one the
+ * user fills themselves. Deliberately a plain string: a type added
+ * after this release still has to read as calculated, see isCalculated
+ */
+ public dynamicType: string = 'NONE',
+ /** Configuration of the calculation, its keys depend on dynamicType */
+ public dynamicParams: object = {},
) {
}
@@ -464,6 +472,14 @@ export class MeasurementCategory {
return this.children.length > 0;
}
+ /**
+ * Whether the server maintains the entries of this category. They are
+ * read-only, and adding one by hand is refused
+ */
+ get isCalculated(): boolean {
+ return this.dynamicType !== 'NONE';
+ }
+
static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory {
const category = new MeasurementCategory(
overrides?.id ?? other.id,
@@ -477,6 +493,8 @@ export class MeasurementCategory {
other.order,
overrides?.chartType ?? other.chartType,
other.chartConfig,
+ other.dynamicType,
+ other.dynamicParams,
);
category.children = other.children;
return category;
@@ -519,6 +537,10 @@ class MeasurementCategoryAdapter implements Adapter {
typeof item.chart_config === 'object' && item.chart_config !== null
? item.chart_config
: {},
+ typeof item.dynamic_type === 'string' ? item.dynamic_type : 'NONE',
+ typeof item.dynamic_params === 'object' && item.dynamic_params !== null
+ ? item.dynamic_params
+ : {},
);
}
@@ -537,6 +559,10 @@ class MeasurementCategoryAdapter implements Adapter {
chart_config: item.chartConfig,
parent: item.parentId,
order: item.order,
+ // eslint-disable-next-line camelcase
+ dynamic_type: item.dynamicType,
+ // eslint-disable-next-line camelcase
+ dynamic_params: item.dynamicParams,
};
}
}
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index d0b9f87df..fb94afbbf 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -57,10 +57,12 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
{/* mt: auto pins the action row, so it aligns across a grid row of
* cards with differently sized charts */}
+ {/* The entries of a calculated category are the server's, adding
+ * one by hand is refused */}
-
+ {!props.category.isCalculated &&
-
+ }
diff --git a/src/components/Measurements/widgets/fab.tsx b/src/components/Measurements/widgets/fab.tsx
index 66917a04b..45d643cba 100644
--- a/src/components/Measurements/widgets/fab.tsx
+++ b/src/components/Measurements/widgets/fab.tsx
@@ -39,6 +39,10 @@ export const AddMeasurementEntryFab = ({ category }: { category: MeasurementCate
const handleOpenModal = () => setOpenModal(true);
const handleCloseModal = () => setOpenModal(false);
+ // The entries of a calculated category are maintained by the server
+ if (category.isCalculated) {
+ return null;
+ }
return (<>
From 1fa1283d18859199060fbef740771b5af4b91433 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 15 Aug 2026 21:34:19 +0200
Subject: [PATCH 087/102] Replace the slick carousel with CSS scroll snapping
---
package-lock.json | 72 ----------------
package.json | 3 -
src/components/Dashboard/MeasurementCard.tsx | 88 ++++++++++++++------
3 files changed, 64 insertions(+), 99 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index fcbd59c9c..0cce96bf4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,9 +36,7 @@
"react-responsive": "^10.0.1",
"react-router-dom": "^7.17.0",
"react-simple-wysiwyg": "^3.4.1",
- "react-slick": "^0.31.0",
"recharts": "^3.8.1",
- "slick-carousel": "^1.8.1",
"slug": "^12.0.1",
"typescript": "^6.0.3",
"yup": "^1.7.1"
@@ -59,7 +57,6 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-is": "^19.2.0",
- "@types/react-slick": "^0.23.13",
"@types/slug": "^5.0.9",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.7.0",
@@ -3026,16 +3023,6 @@
"@types/react": "*"
}
},
- "node_modules/@types/react-slick": {
- "version": "0.23.13",
- "resolved": "https://registry.npmjs.org/@types/react-slick/-/react-slick-0.23.13.tgz",
- "integrity": "sha512-bNZfDhe/L8t5OQzIyhrRhBr/61pfBcWaYJoq6UDqFtv5LMwfg4NsVDD2J8N01JqdAdxLjOt66OZEp6PX+dGs/A==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/react": "*"
- }
- },
"node_modules/@types/react-transition-group": {
"version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
@@ -4193,12 +4180,6 @@
"url": "https://paulmillr.com/funding/"
}
},
- "node_modules/classnames": {
- "version": "2.5.1",
- "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
- "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
- "license": "MIT"
- },
"node_modules/cli-cursor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -6130,13 +6111,6 @@
"jiti": "lib/jiti-cli.mjs"
}
},
- "node_modules/jquery": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/jquery/-/jquery-4.0.0.tgz",
- "integrity": "sha512-TXCHVR3Lb6TZdtw1l3RTLf8RBWVGexdxL6AC8/e0xZKEpBflBsjh9/8LXw+dkNFuOyW9B7iB3O1sP7hS0Kiacg==",
- "license": "MIT",
- "peer": true
- },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6258,15 +6232,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/json2mq": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz",
- "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==",
- "license": "MIT",
- "dependencies": {
- "string-convert": "^0.2.0"
- }
- },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -6606,12 +6571,6 @@
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
- "node_modules/lodash.debounce": {
- "version": "4.0.8",
- "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
- "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
- "license": "MIT"
- },
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
@@ -7560,22 +7519,6 @@
"react": ">=16.8"
}
},
- "node_modules/react-slick": {
- "version": "0.31.0",
- "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.31.0.tgz",
- "integrity": "sha512-zo6VLT8wuSBJffg/TFPbzrw2dEnfZ/cUKmYsKByh3AgatRv29m2LoFbq5vRMa3R3A4wp4d8gwbJKO2fWZFaI3g==",
- "license": "MIT",
- "dependencies": {
- "classnames": "^2.2.5",
- "json2mq": "^0.2.0",
- "lodash.debounce": "^4.0.8",
- "resize-observer-polyfill": "^1.5.0"
- },
- "peerDependencies": {
- "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
- "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
- }
- },
"node_modules/react-transition-group": {
"version": "4.4.5",
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
@@ -7895,15 +7838,6 @@
"node": ">=18"
}
},
- "node_modules/slick-carousel": {
- "version": "1.8.1",
- "resolved": "https://registry.npmjs.org/slick-carousel/-/slick-carousel-1.8.1.tgz",
- "integrity": "sha512-XB9Ftrf2EEKfzoQXt3Nitrt/IPbT+f1fgqBdoxO3W/+JYvtEOW6EgxnWfr9GH6nmULv7Y2tPmEX3koxThVmebA==",
- "license": "MIT",
- "peerDependencies": {
- "jquery": ">=1.8.0"
- }
- },
"node_modules/slug": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/slug/-/slug-12.0.1.tgz",
@@ -7968,12 +7902,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/string-convert": {
- "version": "0.2.1",
- "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz",
- "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==",
- "license": "MIT"
- },
"node_modules/string-ts": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/string-ts/-/string-ts-2.3.1.tgz",
diff --git a/package.json b/package.json
index f962c95ca..73f0d5b0d 100644
--- a/package.json
+++ b/package.json
@@ -60,9 +60,7 @@
"react-responsive": "^10.0.1",
"react-router-dom": "^7.17.0",
"react-simple-wysiwyg": "^3.4.1",
- "react-slick": "^0.31.0",
"recharts": "^3.8.1",
- "slick-carousel": "^1.8.1",
"slug": "^12.0.1",
"typescript": "^6.0.3",
"yup": "^1.7.1"
@@ -83,7 +81,6 @@
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/react-is": "^19.2.0",
- "@types/react-slick": "^0.23.13",
"@types/slug": "^5.0.9",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.7.0",
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index d8c6fafd3..4ae3daee6 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -20,7 +20,6 @@ import {
} from "@/components/Measurements";
import i18n from "@/i18n";
import { makeLink, WgerLink } from "@/core/lib/url";
-import "slick-carousel/slick/slick.css";
import { Box, Stack } from "@mui/material";
import Button from "@mui/material/Button";
import Table from "@mui/material/Table";
@@ -29,10 +28,8 @@ import TableCell from "@mui/material/TableCell";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import Typography from "@mui/material/Typography";
-import React from "react";
+import React, { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
-import Slider, { Settings } from "react-slick";
-import "slick-carousel/slick/slick-theme.css";
/** Entries the table under each chart lists, at most */
@@ -58,21 +55,6 @@ export const MeasurementCard = () => {
const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) => {
const { t } = useTranslation();
- // TODO: is there a better solution for this?
- // Workaround for react-slick import issue where it returns a module object
- // instead of the component
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const SlickSlider = (Slider as any).default ?? Slider;
-
- const settings: Settings = {
- dots: true,
- infinite: true,
- speed: 500,
- slidesToShow: 1,
- slidesToScroll: 1,
- arrows: false,
- };
-
return (<>
>
}
>
-
-
- {props.categories.map(c => )}
-
-
+
>);
};
+/**
+ * The categories side by side, one at a time.
+ *
+ * Snap points do the paging, so a swipe always comes to rest on a category
+ * rather than between two of them.
+ */
+const CategoryCarousel = (props: { categories: MeasurementCategory[] }) => {
+ const { t } = useTranslation();
+ const strip = useRef(null);
+ const [current, setCurrent] = useState(0);
+
+ return (<>
+ setCurrent(Math.round(
+ event.currentTarget.scrollLeft / event.currentTarget.clientWidth
+ ))}
+ sx={{
+ display: 'flex',
+ overflowX: 'auto',
+ scrollSnapType: 'x mandatory',
+ // the dots are the visible position indicator
+ scrollbarWidth: 'none',
+ '&::-webkit-scrollbar': { display: 'none' },
+ }}
+ >
+ {props.categories.map(category =>
+
+
+
+ )}
+
+
+ {props.categories.map((category, index) =>
+ strip.current?.scrollTo({
+ left: index * strip.current.clientWidth,
+ behavior: 'smooth',
+ })}
+ sx={{
+ backgroundColor: index === current ? 'primary.main' : 'action.disabled',
+ border: 0,
+ borderRadius: '50%',
+ cursor: 'pointer',
+ height: 10,
+ p: 0,
+ width: 10,
+ }}
+ />
+ )}
+
+ >);
+};
+
+
/**
* One component of a group, with its latest reading.
*
From 0fb4bffd31d2b693a53377c736a34cebf182ac2f Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Tue, 18 Aug 2026 16:43:35 +0200
Subject: [PATCH 088/102] Filter the workout sessions by day
There can be more than one workout session per day
---
public/locales/de/translation.json | 3 +
public/locales/en/translation.json | 3 +
public/locales/es/translation.json | 3 +
public/locales/fr/translation.json | 3 +
.../Calendar/Components/CalendarComponent.tsx | 10 +-
.../Calendar/Components/CalendarDay.tsx | 2 +-
.../Calendar/Components/Entries.test.tsx | 35 +-
.../Calendar/Components/Entries.tsx | 30 +-
src/components/Routines/api/session.test.ts | 35 +-
src/components/Routines/api/session.ts | 16 +-
src/components/Routines/queries/index.ts | 4 +-
.../Routines/queries/sessions.test.tsx | 83 ++++-
src/components/Routines/queries/sessions.ts | 36 +-
.../screens/Detail/SessionAdd.test.tsx | 54 ++-
.../Routines/screens/Detail/SessionAdd.tsx | 14 +-
.../widgets/forms/SessionForm.test.tsx | 248 +++++++++-----
.../Routines/widgets/forms/SessionForm.tsx | 322 +++++++++---------
.../widgets/forms/SessionLogsForm.test.tsx | 33 +-
.../widgets/forms/SessionLogsForm.tsx | 10 +-
19 files changed, 629 insertions(+), 315 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 27bb1f9da..d4cc35679 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -176,6 +176,9 @@
"logsHeader": "Trainingsprotokoll für das Workout",
"addLogToDay": "Protokoll zu diesem Tag hinzufügen",
"otherLoggedExercises": "Weitere protokollierte Übungen",
+ "multipleSessions": "An diesem Tag wurde mehr als eine Trainingseinheit protokolliert, wähle die aus, die du bearbeiten willst",
+ "newSession": "Neue Trainingseinheit",
+ "changeSession": "Andere Trainingseinheit auswählen",
"backToRoutine": "Zurück zur Routine",
"maxLengthRoutine": "Die Routine darf maximal {{number}} Wochen lang sein",
"resultingRoutine": "Resultierende Routine",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 889edf76d..0d0952795 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -280,6 +280,9 @@
"logsFilterNote": "Note that only entries with a weight unit of kg or lb and repetitions are charted, other combinations such as time or until failure are ignored here",
"addLogToDay": "Add log to this day",
"otherLoggedExercises": "Other logged exercises",
+ "multipleSessions": "More than one session was logged on this day, pick the one to edit",
+ "newSession": "New session",
+ "changeSession": "Pick another session",
"routine": "Routine",
"routines": "Routines",
"workoutSession": "Workout session",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 5b36b8b9a..201d21b16 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -150,6 +150,9 @@
"addWeightLog": "Añadir un registro para el entrenamiento",
"addLogToDay": "Añadir un registro a este día",
"otherLoggedExercises": "Otros ejercicios registrados",
+ "multipleSessions": "Este día tiene más de una sesión de entrenamiento, elige la que quieres editar",
+ "newSession": "Nueva sesión de entrenamiento",
+ "changeSession": "Elegir otra sesión",
"routine": "Rutina",
"logsHeader": "Registro de entrenamientos",
"logsFilterNote": "Ten en cuenta que sólo se registran las entradas con una unidad de peso de kg o lb y las repeticiones, aquí se ignoran otras combinaciones como el tiempo o hasta los errores",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index dc84d0be5..e24980812 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -156,6 +156,9 @@
"logsFilterNote": "Notez que seules les entrées avec un poids dont l'unité est kg ou lb et contenant des répétitions sont classées, les autres combinaisons telles que le temps ou jusqu'à l'échec sont ignorées",
"addLogToDay": "Ajouter un journal à ce jour",
"otherLoggedExercises": "Autres exercices enregistrés",
+ "multipleSessions": "Plus d'une séance d'entraînement a été enregistrée ce jour, choisissez celle à modifier",
+ "newSession": "Nouvelle séance d'entraînement",
+ "changeSession": "Choisir une autre séance",
"routine": "Routine",
"routines": "Routines",
"logsHeader": "Journal de poids pour cet entraînement",
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 4a2bd5724..c1b507206 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -23,7 +23,7 @@ export interface DayProps {
weightEntry: MeasurementEntry | undefined,
measurements: CalendarMeasurement[],
nutritionLogs: DiaryEntry[],
- workoutSession: WorkoutSession | undefined,
+ workoutSessions: WorkoutSession[],
}
@@ -74,7 +74,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const defaultDay: DayProps = {
date: currentDate,
weightEntry: undefined,
- workoutSession: undefined,
+ workoutSessions: [],
measurements: [],
nutritionLogs: []
};
@@ -115,7 +115,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
weightEntry: undefined,
measurements: [],
nutritionLogs: [],
- workoutSession: undefined
+ workoutSessions: []
});
}
@@ -124,7 +124,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
date: new Date(date),
weightEntry: weightsQuery.data?.find(w => isSameDay(w.date, date)),
measurements: measurements.filter(m => isSameDay(m.date, date)) || [],
- workoutSession: sessionQuery.data?.find(m => isSameDay(m.datetimeStart, date)) || undefined,
+ workoutSessions: sessionQuery.data?.filter(m => isSameDay(m.datetimeStart, date)) ?? [],
nutritionLogs: nutritionDiaryQuery.data?.filter(m => isSameDay(m.datetime, date)) || [],
});
date.setDate(date.getDate() + 1);
@@ -138,7 +138,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
result.push({
date: new Date(year, month + 1, i),
weightEntry: undefined,
- workoutSession: undefined,
+ workoutSessions: [],
measurements: [],
nutritionLogs: []
});
diff --git a/src/components/Calendar/Components/CalendarDay.tsx b/src/components/Calendar/Components/CalendarDay.tsx
index 84680fc93..92c8eb953 100644
--- a/src/components/Calendar/Components/CalendarDay.tsx
+++ b/src/components/Calendar/Components/CalendarDay.tsx
@@ -42,7 +42,7 @@ const CalendarDay: React.FC = ({ day, currentMonth, currentDat
const hasDayEntry = () => {
return day.measurements.length > 0 ||
day.weightEntry !== undefined ||
- day.workoutSession !== undefined;
+ day.workoutSessions.length > 0;
};
const handleClick = () => {
diff --git a/src/components/Calendar/Components/Entries.test.tsx b/src/components/Calendar/Components/Entries.test.tsx
index 7f7fae9a8..717b270f9 100644
--- a/src/components/Calendar/Components/Entries.test.tsx
+++ b/src/components/Calendar/Components/Entries.test.tsx
@@ -28,7 +28,7 @@ describe('Entries Component', () => {
weightEntry: undefined,
measurements: [],
nutritionLogs: [],
- workoutSession: undefined
+ workoutSessions: []
};
test('Correctly shows date and title', () => {
@@ -106,7 +106,7 @@ describe('Entries Component', () => {
test('Shows the workout session logs in a collapsible', async () => {
const propsWithSession = {
...defaultProps,
- workoutSession: new WorkoutSession({ ...testWorkoutSession, logs: testWorkoutLogs })
+ workoutSessions: [new WorkoutSession({ ...testWorkoutSession, logs: testWorkoutLogs })]
};
render(
@@ -129,6 +129,37 @@ describe('Entries Component', () => {
expect(screen.getByText(/^8 × 82.5/)).toBeInTheDocument();
});
+ test('Shows every session of the day', async () => {
+ const propsWithSessions = {
+ ...defaultProps,
+ workoutSessions: [
+ new WorkoutSession({ ...testWorkoutSession, notes: 'morning workout', logs: testWorkoutLogs }),
+ new WorkoutSession({
+ ...testWorkoutSession,
+ id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000002',
+ notes: 'evening workout',
+ logs: []
+ }),
+ ]
+ };
+
+ render(
+
+
+
+ );
+
+ // Both are listed, and expanding one leaves the other closed
+ expect(screen.getAllByText('routines.workoutSession')).toHaveLength(2);
+ expect(screen.getByText(/morning workout/)).toBeInTheDocument();
+ expect(screen.getByText(/evening workout/)).toBeInTheDocument();
+
+ const user = userEvent.setup();
+ await user.click(screen.getByText(/morning workout/));
+
+ expect(screen.getByText(/^8 × 80/)).toBeInTheDocument();
+ });
+
test('Shows the nutrition diary entries in a collapsible', async () => {
const propsWithNutrition = {
...defaultProps,
diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx
index fd7f0d9ee..d1ac8ace8 100644
--- a/src/components/Calendar/Components/Entries.tsx
+++ b/src/components/Calendar/Components/Entries.tsx
@@ -1,3 +1,5 @@
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements";
+import { dateToLocale } from "@/core/lib/date";
import { ExpandLess, ExpandMore } from '@mui/icons-material';
import {
Card,
@@ -12,8 +14,6 @@ import {
} from '@mui/material';
import React from 'react';
import { useTranslation } from "react-i18next";
-import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements";
-import { dateToLocale } from "@/core/lib/date";
import type { DayProps } from "./CalendarComponent";
interface LogProps {
@@ -28,7 +28,8 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => {
const categoryUnit = useBodyWeightCategoryQuery().data?.unit ?? 'kg';
const [openMeasurements, setOpenMeasurements] = React.useState(false);
- const [openSession, setOpenSession] = React.useState(false);
+ // A day can hold several sessions, at most one of them is expanded
+ const [openSessionId, setOpenSessionId] = React.useState(null);
const [openNutritionDiary, setOpenNutritionDiary] = React.useState(false);
isStandalone = isStandalone ?? true;
@@ -98,7 +99,9 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => {
{selectedDay.measurements.map((measurement) => (
-
+ = ({ selectedDay, isStandalone }) => {
>}
- {/* Workout session */}
- {selectedDay.workoutSession && <>
+ {/* Workout sessions */}
+ {selectedDay.workoutSessions.map((session) => setOpenSession(!openSession)}
- selected={openSession}
+ onClick={() => setOpenSessionId(openSessionId === session.id ? null : session.id)}
+ selected={openSessionId === session.id}
>
= ({ selectedDay, isStandalone }) => {
}
}}
/>
- {openSession ? : }
+ {openSessionId === session.id ? : }
-
+
- {selectedDay.workoutSession.logs.map((log) => (
+ {session.logs.map((log) => (
= ({ selectedDay, isStandalone }) => {
))}
-
- >}
+ )}
{/* Nutrition diary */}
{selectedDay.nutritionLogs.length > 0 && <>
diff --git a/src/components/Routines/api/session.test.ts b/src/components/Routines/api/session.test.ts
index 1cbac6776..34cda26ed 100644
--- a/src/components/Routines/api/session.test.ts
+++ b/src/components/Routines/api/session.test.ts
@@ -1,5 +1,5 @@
import * as exerciseService from "@/components/Exercises/api/exercise";
-import { addSession, editSession, getSessions, searchSession } from "@/components/Routines/api/session";
+import { addSession, editSession, getSessions, searchSessions } from "@/components/Routines/api/session";
import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
import { testExerciseBenchPress, testExerciseSquats } from "@/tests/exerciseTestdata";
import axios from "axios";
@@ -156,9 +156,9 @@ describe("Session service tests", () => {
);
});
- test('searchSession returns the parsed session when count === 1', async () => {
+ test('searchSessions parses every session of the query', async () => {
const apiResponse = {
- count: 1, next: null, previous: null,
+ count: 2, next: null, previous: null,
results: [
{
id: SESSION_UUID, routine: 39764, day: 5,
@@ -167,38 +167,35 @@ describe("Session service tests", () => {
datetime_start: "2025-08-07T20:10:58+02:00",
datetime_end: "2025-08-07T23:28:21+02:00",
},
+ {
+ id: SESSION_UUID_2, routine: 39764, day: 5,
+ notes: null,
+ impression: "2",
+ datetime_start: "2025-08-07T08:00:00+02:00",
+ datetime_end: null,
+ },
],
};
(axios.get as Mock).mockResolvedValue({ data: apiResponse });
- const result = await searchSession({ routine: 39764, datetime_start__date: "2025-08-07" });
+ const result = await searchSessions({ routine: 39764, datetime_start__date: "2025-08-07" });
const url = (axios.get as Mock).mock.calls[0][0] as string;
expect(url).toContain("/api/v2/workoutsession/");
expect(url).toContain("routine=39764");
expect(url).toContain("datetime_start__date=2025-08-07");
- expect(result).toBeInstanceOf(WorkoutSession);
- expect(result?.id).toBe(SESSION_UUID);
+ expect(result.every(session => session instanceof WorkoutSession)).toBe(true);
+ expect(result.map(session => session.id)).toEqual([SESSION_UUID, SESSION_UUID_2]);
});
- test('searchSession returns null when count !== 1', async () => {
+ test('searchSessions returns an empty list when nothing matches', async () => {
(axios.get as Mock).mockResolvedValue({
data: { count: 0, next: null, previous: null, results: [] },
});
- const result = await searchSession({ routine: 1 });
-
- expect(result).toBeNull();
- });
-
- test('searchSession returns null when count is greater than 1 (ambiguous)', async () => {
- (axios.get as Mock).mockResolvedValue({
- data: { count: 2, next: null, previous: null, results: [{}, {}] },
- });
-
- const result = await searchSession({ routine: 1 });
+ const result = await searchSessions({ routine: 1 });
- expect(result).toBeNull();
+ expect(result).toEqual([]);
});
test('addSession POSTs the serialized session and returns the parsed session', async () => {
diff --git a/src/components/Routines/api/session.ts b/src/components/Routines/api/session.ts
index 88a80c041..b200af4fb 100644
--- a/src/components/Routines/api/session.ts
+++ b/src/components/Routines/api/session.ts
@@ -11,18 +11,22 @@ export type SessionQueryOptions = {
filtersetQueryLogs?: object,
}
+/*
+ * Look up sessions, e.g. the ones of a single day
+ *
+ * A day can hold several sessions, so this returns all of them in the order the
+ * server sends them, by start time.
+ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const searchSession = async (queryParams: Record): Promise => {
+export const searchSessions = async (queryParams: Record): Promise => {
const response = await axios.get(
makeUrl(ApiPath.SESSION, { query: queryParams }),
{ headers: makeHeader() }
);
- if (response.data.count === 1) {
- return new WorkoutSessionAdapter().fromJson(response.data.results[0]);
- }
-
- return null;
+ const adapter = new WorkoutSessionAdapter();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return response.data.results.map((session: any) => adapter.fromJson(session));
};
export const getSessions = async (options?: SessionQueryOptions): Promise => {
diff --git a/src/components/Routines/queries/index.ts b/src/components/Routines/queries/index.ts
index 4539a0ac1..9562ab573 100644
--- a/src/components/Routines/queries/index.ts
+++ b/src/components/Routines/queries/index.ts
@@ -61,4 +61,6 @@ export {
useEditSlotsQuery,
} from "./slots";
-export { useAddSessionQuery, useEditSessionQuery, useFindSessionQuery, useSessionsQuery } from "./sessions";
\ No newline at end of file
+export {
+ useAddSessionQuery, useEditSessionQuery, useFindSessionsQuery, useSessionOfDay, useSessionsQuery
+} from "./sessions";
\ No newline at end of file
diff --git a/src/components/Routines/queries/sessions.test.tsx b/src/components/Routines/queries/sessions.test.tsx
index acdc72d76..88d897a07 100644
--- a/src/components/Routines/queries/sessions.test.tsx
+++ b/src/components/Routines/queries/sessions.test.tsx
@@ -1,11 +1,14 @@
-import { addSession, editSession, searchSession } from "@/components/Routines/api/session";
+import { addSession, editSession, searchSessions } from "@/components/Routines/api/session";
import {
useAddSessionQuery,
useEditSessionQuery,
- useFindSessionQuery
+ useFindSessionsQuery,
+ useSessionOfDay
} from "@/components/Routines/queries";
import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { DateTime } from "luxon";
+import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
import { act, renderHook, waitFor } from '@testing-library/react';
import React from "react";
import type { Mock } from 'vitest';
@@ -28,11 +31,79 @@ describe("session queries", () => {
beforeEach(() => {
vi.clearAllMocks();
- (searchSession as Mock).mockResolvedValue(null);
+ (searchSessions as Mock).mockResolvedValue([]);
(addSession as Mock).mockResolvedValue(testWorkoutSession);
(editSession as Mock).mockResolvedValue(testWorkoutSession);
});
+ describe('useSessionOfDay', () => {
+
+ const wrapper = () => {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ return ({ children }: { children: React.ReactNode }) =>
+ {children};
+ };
+
+ const sessionOn = (id: string, hour: number) => new WorkoutSession({
+ id: id,
+ dayId: 5,
+ routineId: 1,
+ notes: null,
+ impression: '2',
+ datetimeStart: DateTime.fromISO(`2024-05-01T${hour}:00`).toJSDate(),
+ datetimeEnd: null,
+ });
+
+ test('asks for the instants the local day spans', async () => {
+ (searchSessions as Mock).mockResolvedValue([]);
+
+ const { result } = renderHook(
+ () => useSessionOfDay(1, 5, DateTime.fromISO('2024-05-01T18:30'), null),
+ { wrapper: wrapper() }
+ );
+
+ // A date bound would be cut in the server's timezone, and a session
+ // logged shortly after midnight looked for on the wrong day
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(searchSessions).toHaveBeenCalledWith({
+ routine: 1,
+ day: 5,
+ datetime_start__gte: DateTime.fromISO('2024-05-01').startOf('day').toJSDate().toISOString(),
+ datetime_start__lt: DateTime.fromISO('2024-05-02').startOf('day').toJSDate().toISOString(),
+ });
+ });
+
+ test('takes the only session of the day without being asked', async () => {
+ const session = sessionOn('bbbbbbbb-bbbb-bbbb-bbbb-000000000001', 8);
+ (searchSessions as Mock).mockResolvedValue([session]);
+
+ const { result } = renderHook(
+ () => useSessionOfDay(1, 5, DateTime.fromISO('2024-05-01'), null),
+ { wrapper: wrapper() }
+ );
+
+ await waitFor(() => expect(result.current.session).toEqual(session));
+ });
+
+ test('waits for a pick when the day holds several', async () => {
+ const morning = sessionOn('bbbbbbbb-bbbb-bbbb-bbbb-000000000001', 8);
+ const evening = sessionOn('bbbbbbbb-bbbb-bbbb-bbbb-000000000002', 18);
+ (searchSessions as Mock).mockResolvedValue([morning, evening]);
+
+ const { result, rerender } = renderHook(
+ ({ chosenId }: { chosenId: string | null }) =>
+ useSessionOfDay(1, 5, DateTime.fromISO('2024-05-01'), chosenId),
+ { wrapper: wrapper(), initialProps: { chosenId: null as string | null } }
+ );
+
+ await waitFor(() => expect(result.current.sessions).toHaveLength(2));
+ expect(result.current.session).toBeUndefined();
+
+ rerender({ chosenId: evening.id });
+ expect(result.current.session).toEqual(evening);
+ });
+ });
+
// The session form looks up the day it is editing; a search still
// answering "none" after a write is what makes it save a second session
test.each(sessionMutations)('a session %s invalidates the session search', async (_name, useWrite) => {
@@ -43,16 +114,16 @@ describe("session queries", () => {
const { result } = renderHook(
() => ({
- search: useFindSessionQuery(testWorkoutSession.routineId, { day: testWorkoutSession.dayId }),
+ search: useFindSessionsQuery(testWorkoutSession.routineId, { day: testWorkoutSession.dayId }),
write: useWrite(),
}),
{ wrapper }
);
await waitFor(() => expect(result.current.search.isSuccess).toBe(true));
- expect(searchSession).toHaveBeenCalledTimes(1);
+ expect(searchSessions).toHaveBeenCalledTimes(1);
act(() => result.current.write());
- await waitFor(() => expect(searchSession).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(searchSessions).toHaveBeenCalledTimes(2));
});
});
diff --git a/src/components/Routines/queries/sessions.ts b/src/components/Routines/queries/sessions.ts
index 948f12482..e30b3cdc8 100644
--- a/src/components/Routines/queries/sessions.ts
+++ b/src/components/Routines/queries/sessions.ts
@@ -2,11 +2,12 @@ import {
addSession,
editSession,
getSessions,
- searchSession,
+ searchSessions,
SessionQueryOptions
} from "@/components/Routines/api/session";
import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
import { QueryKey, } from "@/core/lib/consts";
+import { DateTime } from "luxon";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -25,11 +26,40 @@ const invalidateSessionReads = (
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const useFindSessionQuery = (routineId: number, queryParams: Record) => useQuery({
- queryFn: () => searchSession(queryParams),
+export const useFindSessionsQuery = (routineId: number, queryParams: Record) => useQuery({
+ queryFn: () => searchSessions(queryParams),
queryKey: [QueryKey.SESSION_SEARCH, routineId, queryParams],
});
+/*
+ * The sessions logged on one day of a routine, and the one being worked on
+ *
+ * A single session is the one, several need the caller to pick one by id. The
+ * day is read as the instants it spans in the browser's timezone: a date bound
+ * would be cut in the server's, and a session logged shortly after midnight
+ * would be looked for on the wrong day.
+ */
+export const useSessionOfDay = (routineId: number, dayId: number, date: DateTime, chosenId: string | null) => {
+ const dayStart = date.startOf('day');
+ const query = useFindSessionsQuery(routineId, {
+ routine: routineId,
+ // eslint-disable-next-line camelcase
+ datetime_start__gte: dayStart.toJSDate().toISOString(),
+ // eslint-disable-next-line camelcase
+ datetime_start__lt: dayStart.plus({ days: 1 }).toJSDate().toISOString(),
+ day: dayId,
+ });
+
+ const sessions = query.data ?? [];
+
+ return {
+ sessions: sessions,
+ session: sessions.length === 1 ? sessions[0] : sessions.find(entry => entry.id === chosenId),
+ isLoading: query.isLoading,
+ isSuccess: query.isSuccess,
+ };
+};
+
export const useAddSessionQuery = () => {
const queryClient = useQueryClient();
diff --git a/src/components/Routines/screens/Detail/SessionAdd.test.tsx b/src/components/Routines/screens/Detail/SessionAdd.test.tsx
index 20251bc4b..3cba35927 100644
--- a/src/components/Routines/screens/Detail/SessionAdd.test.tsx
+++ b/src/components/Routines/screens/Detail/SessionAdd.test.tsx
@@ -1,13 +1,16 @@
import { QueryClientProvider } from "@tanstack/react-query";
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import { SessionAdd } from "@/components/Routines/screens/Detail/SessionAdd";
import React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { getLanguages } from "@/components/Exercises/api/language";
import { getRoutine } from "@/components/Routines/api/routine";
-import { searchSession } from "@/components/Routines/api/session";
+import { searchSessions } from "@/components/Routines/api/session";
import { testLanguages } from "@/tests/exerciseTestdata";
import { testQueryClient } from "@/tests/queryClient";
+import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
+import { DateTime } from "luxon";
import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { testRoutine1 } from "@/tests/workoutRoutinesTestData";
import type { Mock } from 'vitest';
@@ -21,7 +24,7 @@ describe("Smoke tests the SessionAdd component", () => {
beforeEach(() => {
(getRoutine as Mock).mockResolvedValue(testRoutine1);
(getLanguages as Mock).mockResolvedValue(testLanguages);
- (searchSession as Mock).mockResolvedValue(testWorkoutSession);
+ (searchSessions as Mock).mockResolvedValue([testWorkoutSession]);
});
test('renders the form page', async () => {
@@ -41,8 +44,51 @@ describe("Smoke tests the SessionAdd component", () => {
await waitFor(() => {
expect(getRoutine).toHaveBeenCalled();
expect(getLanguages).toHaveBeenCalled();
- expect(searchSession).toHaveBeenCalled();
+ expect(searchSessions).toHaveBeenCalled();
});
expect(screen.getByText('routines.addWeightLog')).toBeInTheDocument();
});
+
+ test('asks which session to work on again after the date changed', async () => {
+
+ // Arrange
+ const sessionOn = (id: string, hour: number, notes: string) => new WorkoutSession({
+ id: id,
+ dayId: 5,
+ routineId: 101,
+ notes: notes,
+ impression: '2',
+ datetimeStart: DateTime.now().startOf('day').set({ hour: hour }).toJSDate(),
+ datetimeEnd: null,
+ });
+ (searchSessions as Mock).mockResolvedValue([
+ sessionOn('bbbbbbbb-bbbb-bbbb-bbbb-000000000001', 8, 'morning workout'),
+ sessionOn('bbbbbbbb-bbbb-bbbb-bbbb-000000000002', 18, 'evening workout'),
+ ]);
+ const user = userEvent.setup();
+
+ // Act
+ render(
+
+
+
+ } />
+
+
+
+ );
+ await waitFor(() => expect(screen.getByText(/morning workout/)).toBeInTheDocument());
+ await user.click(screen.getByText(/morning workout/));
+ await waitFor(() => expect(screen.getByRole('textbox', { name: /notes/i })).toBeInTheDocument());
+
+ // Act - move the form to another day
+ const dateGroup = screen.getByRole('group', { name: /date/i });
+ await user.click(within(dateGroup).getByRole('spinbutton', { name: /year/i }));
+ await user.keyboard('2025');
+
+ // Assert
+ // The sessions of another date are different ones, so the screen drops
+ // the pick instead of carrying it over
+ await waitFor(() => expect(screen.getByText('routines.multipleSessions')).toBeInTheDocument());
+ });
});
diff --git a/src/components/Routines/screens/Detail/SessionAdd.tsx b/src/components/Routines/screens/Detail/SessionAdd.tsx
index aedb65088..d7dd3d987 100644
--- a/src/components/Routines/screens/Detail/SessionAdd.tsx
+++ b/src/components/Routines/screens/Detail/SessionAdd.tsx
@@ -12,6 +12,15 @@ export const SessionAdd = () => {
const params = useParams<{ routineId: string, dayId: string }>();
const { t, i18n } = useTranslation();
const [selectedDate, setSelectedDate] = useState(DateTime.now());
+ // Which session of the day the screen works on. Both forms read it: one
+ // edits it, the other writes its logs into it. Another date has its own
+ // sessions, so the pick doesn't travel along
+ const [chosenSessionId, setChosenSessionId] = useState(null);
+
+ const selectDate = (date: DateTime) => {
+ setSelectedDate(date);
+ setChosenSessionId(null);
+ };
const routineId = parseInt(params.routineId ?? '');
if (Number.isNaN(routineId)) {
@@ -32,7 +41,9 @@ export const SessionAdd = () => {
routineId={routineId}
dayId={dayId}
selectedDate={selectedDate}
- setSelectedDate={setSelectedDate}
+ setSelectedDate={selectDate}
+ chosenSessionId={chosenSessionId}
+ setChosenSessionId={setChosenSessionId}
/>
{t('exercises.exercises')}
@@ -43,6 +54,7 @@ export const SessionAdd = () => {
routineId={routineId}
dayId={dayId}
selectedDate={selectedDate}
+ chosenSessionId={chosenSessionId}
/>
}
diff --git a/src/components/Routines/widgets/forms/SessionForm.test.tsx b/src/components/Routines/widgets/forms/SessionForm.test.tsx
index c528ac5e1..b5bb16d06 100644
--- a/src/components/Routines/widgets/forms/SessionForm.test.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.test.tsx
@@ -2,7 +2,7 @@ import React from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
-import { useAddSessionQuery, useEditSessionQuery, useFindSessionQuery } from "@/components/Routines/queries";
+import { useAddSessionQuery, useEditSessionQuery, useSessionOfDay } from "@/components/Routines/queries";
import { DateTime } from 'luxon';
import { BrowserRouter } from "react-router-dom";
import { SessionForm } from './SessionForm';
@@ -10,7 +10,7 @@ import type { Mock } from 'vitest';
vi.mock("@/components/Routines/queries");
-const mockUseFindSessionQuery = useFindSessionQuery as Mock;
+const mockUseSessionOfDay = useSessionOfDay as Mock;
const mockUseAddSessionQuery = useAddSessionQuery as Mock;
const mockUseEditSessionQuery = useEditSessionQuery as Mock;
@@ -22,7 +22,7 @@ describe('SessionForm', () => {
let editMutateAsync: Mock;
beforeEach(() => {
- mockUseFindSessionQuery.mockClear();
+ mockUseSessionOfDay.mockClear();
addMutateAsync = vi.fn().mockResolvedValue(undefined);
editMutateAsync = vi.fn().mockResolvedValue(undefined);
mockUseAddSessionQuery.mockReturnValue({
@@ -37,57 +37,65 @@ describe('SessionForm', () => {
});
});
- const renderForm = (selectedDate: DateTime, setSelectedDate: React.Dispatch> = () => {
+ /* Stands in for the screen, which owns the session the form works on */
+ const Harness = (props: { selectedDate: DateTime, setSelectedDate: (date: DateTime) => void }) => {
+ const [chosenSessionId, setChosenSessionId] = React.useState(null);
+
+ return ;
+ };
+
+ const renderForm = (selectedDate: DateTime, setSelectedDate: (date: DateTime) => void = () => {
}) => render(
-
+
);
- /** The instants a local calendar day spans, which is the window the form searches */
- const dayWindow = (day: string) => ({
- datetime_start__gte: DateTime.fromISO(day).startOf('day').toJSDate().toISOString(),
- datetime_start__lt: DateTime.fromISO(day).startOf('day').plus({ days: 1 }).toJSDate().toISOString(),
- });
+ /* What the real hook does: a lone session is the one, several wait for a pick */
+ const lookupReturning = (sessions: WorkoutSession[]) =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (...args: any[]) => ({
+ sessions: sessions,
+ session: sessions.length === 1 ? sessions[0] : sessions.find(entry => entry.id === args[3]),
+ isLoading: false,
+ isSuccess: true,
+ });
test('looks up the session for the currently selected date', async () => {
// Arrange
- mockUseFindSessionQuery.mockReturnValue({
- data: null,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([]));
// Act
const { rerender } = renderForm(DateTime.fromISO('2024-05-01'));
- // Assert - as instants, so a session logged after midnight is not
- // looked for on the day the server's timezone puts it on
- expect(mockUseFindSessionQuery).toHaveBeenCalledWith(
+ // Assert
+ expect(mockUseSessionOfDay).toHaveBeenCalledWith(
routineId,
- { routine: routineId, ...dayWindow('2024-05-01'), day: dayId }
+ dayId,
+ DateTime.fromISO('2024-05-01'),
+ null
);
// Act - the parent selects another date
rerender(
- {
- }} />
+ {
+ }} />
);
// Assert
- expect(mockUseFindSessionQuery).toHaveBeenLastCalledWith(
+ expect(mockUseSessionOfDay).toHaveBeenLastCalledWith(
routineId,
- { routine: routineId, ...dayWindow('2024-05-08'), day: dayId }
+ dayId,
+ DateTime.fromISO('2024-05-08'),
+ null
);
});
@@ -95,11 +103,7 @@ describe('SessionForm', () => {
// Arrange
const user = userEvent.setup();
const setSelectedDate = vi.fn();
- mockUseFindSessionQuery.mockReturnValue({
- data: null,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([]));
// Act
renderForm(DateTime.fromISO('2024-05-01'), setSelectedDate);
@@ -143,23 +147,10 @@ describe('SessionForm', () => {
datetimeEnd: timeEnd.toJSDate()
});
- mockUseFindSessionQuery.mockReturnValue({
- data: mockSession,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([mockSession]));
// Act
- render(
-
- {
- }} />
-
- );
+ renderForm(DateTime.now());
// Assert
await waitFor(() => {
@@ -179,22 +170,9 @@ describe('SessionForm', () => {
});
test('sets default values when no session is found', async () => {
- mockUseFindSessionQuery.mockReturnValue({
- data: null,
- isLoading: false,
- isSuccess: true,
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([]));
- render(
-
- {
- }} />
-
- );
+ renderForm(DateTime.now());
await waitFor(() => {
expect((screen.getByRole('textbox', { name: /notes/i }) as HTMLTextAreaElement).value).toBe('');
@@ -205,11 +183,7 @@ describe('SessionForm', () => {
// Arrange
const user = userEvent.setup();
- mockUseFindSessionQuery.mockReturnValue({
- data: null,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([]));
// Act
renderForm(DateTime.fromISO('2024-05-01'));
@@ -229,11 +203,7 @@ describe('SessionForm', () => {
test('shows what the server rejected', async () => {
// Arrange
- mockUseFindSessionQuery.mockReturnValue({
- data: null,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([]));
mockUseAddSessionQuery.mockReturnValue({
data: null,
isPending: false,
@@ -252,23 +222,117 @@ describe('SessionForm', () => {
expect(screen.getByText(/A session cannot be longer than 5 hours/)).toBeInTheDocument();
});
+ /* Two sessions on the same day, which the server allows since 2.7 */
+ const twoSessions = () => [
+ new WorkoutSession({
+ id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000001',
+ dayId: dayId,
+ routineId: routineId,
+ notes: 'morning workout',
+ impression: '3',
+ datetimeStart: DateTime.fromISO('2024-05-01T08:00').toJSDate(),
+ datetimeEnd: null,
+ }),
+ new WorkoutSession({
+ id: 'bbbbbbbb-bbbb-bbbb-bbbb-000000000002',
+ dayId: dayId,
+ routineId: routineId,
+ notes: 'evening workout',
+ impression: '2',
+ datetimeStart: DateTime.fromISO('2024-05-01T18:30').toJSDate(),
+ datetimeEnd: null,
+ }),
+ ];
+
+ test('lets the user pick when the day has several sessions', async () => {
+
+ // Arrange
+ mockUseSessionOfDay.mockImplementation(lookupReturning(twoSessions()));
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+
+ // Assert
+ // Both are offered, and nothing is edited until one is chosen
+ expect(screen.getByText('routines.multipleSessions')).toBeInTheDocument();
+ expect(screen.getByText(/morning workout/)).toBeInTheDocument();
+ expect(screen.getByText(/evening workout/)).toBeInTheDocument();
+ expect(screen.getByText('routines.newSession')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /notes/i })).not.toBeInTheDocument();
+ });
+
+ test('edits the session picked from the list', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ mockUseSessionOfDay.mockImplementation(lookupReturning(twoSessions()));
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+ await user.click(screen.getByText(/evening workout/));
+
+ // Assert
+ await waitFor(() =>
+ expect((screen.getByRole('textbox', { name: /notes/i }) as HTMLTextAreaElement).value)
+ .toBe('evening workout')
+ );
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => expect(editMutateAsync).toHaveBeenCalled());
+ const draft = editMutateAsync.mock.calls[0][0] as WorkoutSession;
+ expect(draft.id).toBe('bbbbbbbb-bbbb-bbbb-bbbb-000000000002');
+ });
+
+ test('adds another session to a day that already has some', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ mockUseSessionOfDay.mockImplementation(lookupReturning(twoSessions()));
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+ await user.click(screen.getByText('routines.newSession'));
+
+ // Assert
+ await waitFor(() =>
+ expect((screen.getByRole('textbox', { name: /notes/i }) as HTMLTextAreaElement).value).toBe('')
+ );
+
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+ await waitFor(() => expect(addMutateAsync).toHaveBeenCalled());
+ expect(editMutateAsync).not.toHaveBeenCalled();
+ });
+
+ test('offers the choice again after going back', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ mockUseSessionOfDay.mockImplementation(lookupReturning(twoSessions()));
+
+ // Act
+ renderForm(DateTime.fromISO('2024-05-01'));
+ await user.click(screen.getByText(/evening workout/));
+ await waitFor(() => expect(screen.getByRole('textbox', { name: /notes/i })).toBeInTheDocument());
+ await user.click(screen.getByText('routines.changeSession'));
+
+ // Assert
+ expect(screen.getByText('routines.multipleSessions')).toBeInTheDocument();
+ expect(screen.queryByRole('textbox', { name: /notes/i })).not.toBeInTheDocument();
+ });
+
test('submits a session that runs past midnight with the end on the next day', async () => {
// Arrange
const user = userEvent.setup();
- mockUseFindSessionQuery.mockReturnValue({
- data: new WorkoutSession({
- id: null,
- dayId: dayId,
- routineId: routineId,
- notes: '',
- impression: '2',
- datetimeStart: DateTime.fromISO('2024-05-01T23:00').toJSDate(),
- datetimeEnd: DateTime.fromISO('2024-05-01T01:30').toJSDate(),
- }),
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([new WorkoutSession({
+ id: null,
+ dayId: dayId,
+ routineId: routineId,
+ notes: '',
+ impression: '2',
+ datetimeStart: DateTime.fromISO('2024-05-01T23:00').toJSDate(),
+ datetimeEnd: DateTime.fromISO('2024-05-01T01:30').toJSDate(),
+ })]));
// Act
renderForm(DateTime.fromISO('2024-05-01'));
@@ -295,11 +359,7 @@ describe('SessionForm', () => {
datetimeStart: DateTime.fromISO('2024-05-01').toJSDate(),
datetimeEnd: null
});
- mockUseFindSessionQuery.mockReturnValue({
- data: mockSession,
- isLoading: false,
- isSuccess: true
- });
+ mockUseSessionOfDay.mockImplementation(lookupReturning([mockSession]));
// Act
renderForm(DateTime.fromISO('2024-05-01'));
diff --git a/src/components/Routines/widgets/forms/SessionForm.tsx b/src/components/Routines/widgets/forms/SessionForm.tsx
index 7c0e357ec..31ff0db51 100644
--- a/src/components/Routines/widgets/forms/SessionForm.tsx
+++ b/src/components/Routines/widgets/forms/SessionForm.tsx
@@ -5,63 +5,69 @@ import {
NOTES_MAX_LENGTH,
WorkoutSession
} from "@/components/Routines/models/WorkoutSession";
-import { useAddSessionQuery, useEditSessionQuery, useFindSessionQuery } from "@/components/Routines/queries";
+import { useAddSessionQuery, useEditSessionQuery, useSessionOfDay } from "@/components/Routines/queries";
import { WgerTextField } from "@/core/forms/WgerTextField";
import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
-import { SentimentNeutral, SentimentSatisfiedAlt, SentimentVeryDissatisfied } from "@mui/icons-material";
-import { Button, ButtonGroup, Typography } from "@mui/material";
+import { Add, SentimentNeutral, SentimentSatisfiedAlt, SentimentVeryDissatisfied } from "@mui/icons-material";
+import {
+ Button,
+ ButtonGroup,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemIcon,
+ ListItemText,
+ Typography
+} from "@mui/material";
import Grid from '@mui/material/Grid';
import { DatePicker, LocalizationProvider, TimePicker } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
-import { Form, Formik, FormikProps } from "formik";
+import { Form, Formik } from "formik";
import { DateTime } from "luxon";
-import React, { useEffect, useRef } from 'react';
+import React from 'react';
import { useTranslation } from "react-i18next";
import * as yup from 'yup';
interface SessionFormProps {
- initialSession?: WorkoutSession;
dayId: number,
routineId: number,
selectedDate: DateTime,
- setSelectedDate: React.Dispatch>
+ setSelectedDate: (date: DateTime) => void,
+ chosenSessionId: string | null,
+ setChosenSessionId: (id: string | null) => void
}
-type SessionFormValues = {
- notes: string | null;
- date: Date;
- start: DateTime | null;
- end: DateTime | null;
- impression: string;
-};
-
-export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, setSelectedDate }: SessionFormProps) => {
+/* Stands in for the session id while the user is adding one to a day that
+ * already has sessions */
+const NEW_SESSION = 'new';
- const formikRef = useRef | null>(null);
+export const SessionForm = (
+ {
+ dayId,
+ routineId,
+ selectedDate,
+ setSelectedDate,
+ chosenSessionId,
+ setChosenSessionId
+ }: SessionFormProps) => {
const [t, i18n] = useTranslation();
- const [session, setSession] = React.useState(initialSession);
const addSessionQuery = useAddSessionQuery();
const editSessionQuery = useEditSessionQuery();
- // The day as the instants it spans in the browser's timezone. A date bound
- // would be cut in the server's, and a session logged shortly after
- // midnight would go looking on the wrong day: the form would find nothing
- // and save a second session next to the one that is already there
- const dayStart = selectedDate.startOf('day');
- const findSessionQuery = useFindSessionQuery(
+ const { sessions, session, isLoading: isLoadingSessions } = useSessionOfDay(
routineId,
- {
- routine: routineId,
- // eslint-disable-next-line camelcase
- datetime_start__gte: dayStart.toJSDate().toISOString(),
- // eslint-disable-next-line camelcase
- datetime_start__lt: dayStart.plus({ days: 1 }).toJSDate().toISOString(),
- day: dayId
- }
+ dayId,
+ selectedDate,
+ chosenSessionId
);
- const isLoading = addSessionQuery.isPending || editSessionQuery.isPending || findSessionQuery.isLoading;
+ // A day can hold several sessions. One is edited right away, more than one
+ // has to be picked apart by the user first, otherwise the form would either
+ // edit an arbitrary one or add yet another next to them
+ const needsChoice = sessions.length > 1 && session === undefined && chosenSessionId !== NEW_SESSION;
+
+ const isLoading = addSessionQuery.isPending || editSessionQuery.isPending || isLoadingSessions;
const validationSchema = yup.object({
notes: yup
@@ -81,33 +87,6 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
});
- useEffect(() => {
- if (!formikRef.current) {
- return;
- }
- if (findSessionQuery.data) {
- formikRef.current.setValues({
- notes: findSessionQuery.data.notes || '',
- impression: findSessionQuery.data.impression || IMPRESSION_NEUTRAL,
- date: findSessionQuery.data.datetimeStart,
- start: DateTime.fromJSDate(findSessionQuery.data.datetimeStart),
- end: findSessionQuery.data.datetimeEnd ? DateTime.fromJSDate(findSessionQuery.data.datetimeEnd) : null,
- });
- setSession(findSessionQuery.data);
- } else if (findSessionQuery.isSuccess && !findSessionQuery.data) {
- formikRef.current.setValues({
- notes: '',
- impression: IMPRESSION_NEUTRAL,
- date: initialSession?.datetimeStart || DateTime.now().toJSDate(), //JS Date, not DateTime
- start: initialSession ? DateTime.fromJSDate(initialSession.datetimeStart) : null,
- end: initialSession?.datetimeEnd ? DateTime.fromJSDate(initialSession.datetimeEnd) : null,
- });
- setSession(undefined);
- }
-
- }, [findSessionQuery.data, findSessionQuery.isSuccess, initialSession, selectedDate]);
-
-
return (
{
const day = selectedDate.startOf('day');
@@ -147,7 +125,10 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
if (session !== undefined) {
await editSessionQuery.mutateAsync(draft);
} else {
- await addSessionQuery.mutateAsync(draft);
+ // Keep editing what was just added, a second submit would
+ // otherwise write another session
+ const added = await addSessionQuery.mutateAsync(draft);
+ setChosenSessionId(added?.id ?? null);
}
}}
>
@@ -178,107 +159,136 @@ export const SessionForm = ({ initialSession, dayId, routineId, selectedDate, se
}}
/>
+ {sessions.length > 1 && !needsChoice &&
+ setChosenSessionId(null)}>
+ {t('routines.changeSession')}
+ }
-
-
- {
- if (newValue) {
- formik.setFieldValue('start', newValue);
- }
- }}
- slotProps={{
- textField: {
- variant: "standard",
- fullWidth: true,
- error: formik.touched.start && Boolean(formik.errors.start),
- helperText: formik.touched.start && formik.errors.start
- }
- }}
- />
-
-
-
-
- {
- if (newValue) {
- formik.setFieldValue('end', newValue);
- }
- }}
- slotProps={{
- textField: {
- variant: "standard",
- fullWidth: true,
- error: formik.touched.end && Boolean(formik.errors.end),
- helperText: formik.touched.end && formik.errors.end
- }
- }}
+
+ {needsChoice ?
+
+ {t('routines.multipleSessions')}
+
+
+ {sessions.map(entry =>
+
+ setChosenSessionId(entry.id)}>
+
+
+
+ )}
+
+ setChosenSessionId(NEW_SESSION)}>
+
+
+
+
+
+
+
+ : <>
+
+
+ {
+ if (newValue) {
+ formik.setFieldValue('start', newValue);
+ }
+ }}
+ slotProps={{
+ textField: {
+ variant: "standard",
+ fullWidth: true,
+ error: formik.touched.start && Boolean(formik.errors.start),
+ helperText: formik.touched.start && formik.errors.start
+ }
+ }}
+ />
+
+
+
+
+ {
+ if (newValue) {
+ formik.setFieldValue('end', newValue);
+ }
+ }}
+ slotProps={{
+ textField: {
+ variant: "standard",
+ fullWidth: true,
+ error: formik.touched.end && Boolean(formik.errors.end),
+ helperText: formik.touched.end && formik.errors.end
+ }
+ }}
+ />
+
+
+
+
-
-
-
-
-
+
-
- {t('routines.impression')}
-
- formik.setFieldValue('impression', IMPRESSION_BAD)}
- >
-
- {t('routines.impressionBad')}
-
- formik.setFieldValue('impression', IMPRESSION_NEUTRAL)}
+
+ {t('routines.impression')}
+
-
- {t('routines.impressionNeutral')}
-
+ formik.setFieldValue('impression', IMPRESSION_BAD)}
+ >
+
+ {t('routines.impressionBad')}
+
+ formik.setFieldValue('impression', IMPRESSION_NEUTRAL)}
+ >
+
+ {t('routines.impressionNeutral')}
+
+ formik.setFieldValue('impression', IMPRESSION_GOOD)}
+ >
+
+ {t('routines.impressionGood')}
+
+
+
+
+
+
+
+
+ formik.setFieldValue('impression', IMPRESSION_GOOD)}
- >
-
- {t('routines.impressionGood')}
+ disabled={isLoading}
+ color="primary"
+ variant="contained"
+ type="submit"
+ sx={{ mt: 2 }}>
+ {t('submit')}
+
-
-
-
-
-
-
-
-
- {t('submit')}
-
-
+ >}
)}
diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx
index 9e31e3170..69e7979b8 100644
--- a/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx
+++ b/src/components/Routines/widgets/forms/SessionLogsForm.test.tsx
@@ -1,10 +1,11 @@
import { render, screen } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { useLanguageQuery } from "@/components/Exercises";
-import { useAddRoutineLogsQuery, useRoutineDetailQuery } from "@/components/Routines/queries";
+import { useAddRoutineLogsQuery, useRoutineDetailQuery, useSessionOfDay } from "@/components/Routines/queries";
import { SessionLogsForm } from '@/components/Routines/widgets/forms/SessionLogsForm';
import { DateTime } from "luxon";
import { testLanguages } from "@/tests/exerciseTestdata";
+import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { testRoutine1 } from "@/tests/workoutRoutinesTestData";
import type { Mock } from 'vitest';
@@ -17,6 +18,7 @@ describe('SessionLogsForm', () => {
const mockUseLanguageQuery = useLanguageQuery as Mock;
const mockAddLogsQuery = useAddRoutineLogsQuery as Mock;
const mockRoutineDetailQuery = useRoutineDetailQuery as Mock;
+ const mockUseSessionOfDay = useSessionOfDay as Mock;
const mockMutateAsync = vi.fn();
beforeEach(() => {
@@ -34,6 +36,12 @@ describe('SessionLogsForm', () => {
isLoading: false,
data: testLanguages,
});
+ mockUseSessionOfDay.mockReturnValue({
+ sessions: [testWorkoutSession],
+ session: testWorkoutSession,
+ isLoading: false,
+ isSuccess: true,
+ });
});
@@ -42,6 +50,7 @@ describe('SessionLogsForm', () => {
dayId={5}
routineId={1}
selectedDate={DateTime.now()}
+ chosenSessionId={null}
/>);
expect(screen.getByText('Squats')).toBeInTheDocument();
@@ -70,6 +79,7 @@ describe('SessionLogsForm', () => {
dayId={5}
routineId={1}
selectedDate={DateTime.fromISO('2024-05-05T12:00:00')}
+ chosenSessionId={null}
/>);
const weightElements = screen.getAllByRole('textbox').filter(input => (input as HTMLInputElement).value === '20');
@@ -92,6 +102,25 @@ describe('SessionLogsForm', () => {
expect(mockMutateAsync.mock.calls[0][0][3]).toMatchObject(originalData);
});
+ test('writes the logs into the session the screen works on', async () => {
+ // Arrange
+ const user = userEvent.setup();
+
+ // Act
+ render();
+ await user.click(screen.getByRole('button', { name: /submit/i }));
+
+ // Assert
+ // Without the id the server would sort the logs into a session by their
+ // time, which is a guess as soon as the day holds more than one
+ expect(mockMutateAsync.mock.calls[0][0][0].session).toEqual(testWorkoutSession.id);
+ });
+
test('add log action buttons works', async () => {
// Arrange
const user = userEvent.setup();
@@ -101,6 +130,7 @@ describe('SessionLogsForm', () => {
dayId={5}
routineId={1}
selectedDate={DateTime.fromISO('2024-05-05T12:00:00')}
+ chosenSessionId={null}
/>);
await user.click(screen.getByTestId('AddIcon'));
await user.click(screen.getByRole('button', { name: /submit/i }));
@@ -118,6 +148,7 @@ describe('SessionLogsForm', () => {
dayId={5}
routineId={1}
selectedDate={DateTime.now()}
+ chosenSessionId={null}
/>);
await user.click(screen.getAllByTestId('DeleteOutlinedIcon')[0]);
diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.tsx
index d904ec9cc..cbd74bb54 100644
--- a/src/components/Routines/widgets/forms/SessionLogsForm.tsx
+++ b/src/components/Routines/widgets/forms/SessionLogsForm.tsx
@@ -3,7 +3,7 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { Exercise, getLanguageByShortName, NameAutocompleter, useLanguageQuery } from "@/components/Exercises";
import { RIR_VALUES_SELECT } from "@/components/Routines/models/BaseConfig";
import { LogEntryForm } from "@/components/Routines/models/WorkoutLog";
-import { useAddRoutineLogsQuery, useRoutineDetailQuery } from "@/components/Routines/queries";
+import { useAddRoutineLogsQuery, useRoutineDetailQuery, useSessionOfDay } from "@/components/Routines/queries";
import { REP_UNIT_REPETITIONS, SNACKBAR_AUTO_HIDE_DURATION } from "@/core/lib/consts";
import { SwapHoriz } from "@mui/icons-material";
import AddIcon from "@mui/icons-material/Add";
@@ -20,13 +20,18 @@ interface SessionLogsFormProps {
dayId: number,
routineId: number,
selectedDate: DateTime,
+ chosenSessionId: string | null,
}
-export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsFormProps) => {
+export const SessionLogsForm = ({ dayId, routineId, selectedDate, chosenSessionId }: SessionLogsFormProps) => {
const { t, i18n } = useTranslation();
const [snackbarOpen, setSnackbarOpen] = useState(false);
const routineQuery = useRoutineDetailQuery(routineId);
+ // The session the form above works on, so the logs end up in the one the
+ // user has in front of them. Without it the server would sort them into a
+ // session by their time, which on a day with several of them is a guess
+ const { session } = useSessionOfDay(routineId, dayId, selectedDate, chosenSessionId);
const addLogsQuery = useAddRoutineLogsQuery(routineId);
const languageQuery = useLanguageQuery();
const handleSnackbarClose = () => setSnackbarOpen(false);
@@ -67,6 +72,7 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF
.filter(l => l.rir !== '' || l.repetitions !== '' || l.weight !== '')
.map(l => ({
date: selectedDate.toISO(),
+ session: session?.id,
iteration: iteration,
exercise: l.exercise?.id,
day: dayId,
From 2c2f6c7cfff33d522ae866ecd21cad132e55a664 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 20 Aug 2026 16:53:03 +0200
Subject: [PATCH 089/102] Allow users setting up calculated categories
---
public/locales/de/translation.json | 31 ++
public/locales/en/translation.json | 33 +-
public/locales/es/translation.json | 31 ++
public/locales/fr/translation.json | 31 ++
src/components/Exercises/api/exercise.ts | 21 ++
src/components/Exercises/index.ts | 3 +
.../Measurements/api/measurements.ts | 7 +-
.../Measurements/models/Calculation.test.ts | 93 ++++++
.../Measurements/models/Calculation.ts | 170 +++++++++++
src/components/Measurements/models/Entry.ts | 8 +-
.../screens/MeasurementCategoryDetail.tsx | 21 ++
.../screens/MeasurementCategoryOverview.tsx | 31 +-
.../Measurements/widgets/CalculationMark.tsx | 57 ++++
.../widgets/CalculationParams.test.tsx | 118 ++++++++
.../widgets/CalculationParams.tsx | 204 +++++++++++++
.../widgets/CalculationSection.tsx | 132 ++++++++
.../widgets/CategoryDetailDataGrid.test.tsx | 24 ++
.../widgets/CategoryDetailDataGrid.tsx | 22 +-
.../widgets/CategoryDetailDropdown.tsx | 8 +-
.../widgets/CategoryForm.test.tsx | 286 ++++++++++++++++++
.../Measurements/widgets/CategoryForm.tsx | 244 ++++++++++++---
.../widgets/MetricPicker.test.tsx | 17 ++
.../Measurements/widgets/MetricPicker.tsx | 34 ++-
src/components/Measurements/widgets/fab.tsx | 4 +-
.../screens/Detail/SlotProgressionEdit.tsx | 4 +-
src/core/ui/Modals/WgerModal.tsx | 53 +++-
26 files changed, 1618 insertions(+), 69 deletions(-)
create mode 100644 src/components/Measurements/models/Calculation.test.ts
create mode 100644 src/components/Measurements/models/Calculation.ts
create mode 100644 src/components/Measurements/widgets/CalculationMark.tsx
create mode 100644 src/components/Measurements/widgets/CalculationParams.test.tsx
create mode 100644 src/components/Measurements/widgets/CalculationParams.tsx
create mode 100644 src/components/Measurements/widgets/CalculationSection.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index d4cc35679..8a6cd51e2 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -254,6 +254,37 @@
"preferences": "Voreinstellungen",
"success": "Geschafft!",
"measurements": {
+ "calculations": {
+ "sourceManual": "Von Hand eingetragen",
+ "sourceCalculated": "Von wger berechnet",
+ "type": "Berechnung",
+ "locked": "Die Berechnung kann nicht geändert werden. Lösche die Kategorie, um sie zu beenden.",
+ "paramsIncomplete": "Die Berechnung ist noch nicht vollständig eingerichtet",
+ "missingHeight": "In deinem Profil fehlt die Körpergröße, daher können noch keine Werte berechnet werden.",
+ "noSourceCategory": "Du hast noch keine andere Kategorie, aus der gelesen werden kann.",
+ "badge": "Berechnet",
+ "entryInfo": "Dieser Wert wird von wger berechnet und kann nicht geändert werden",
+ "names": {
+ "BMI": "BMI",
+ "WHTR": "Taille-Größe-Verhältnis",
+ "ONE_REP_MAX": "1RM",
+ "ONE_RM_TOTAL": "1RM-Summe"
+ },
+ "descriptions": {
+ "BMI": "Aus deinem Körpergewicht und der Körpergröße in deinem Profil",
+ "WHTR": "Aus {{category}} und der Körpergröße in deinem Profil"
+ },
+ "params": {
+ "category_id": "Quellkategorie",
+ "max_reps": "Maximale Wiederholungen",
+ "window_days": "Zeitfenster in Tagen"
+ },
+ "paramsHelp": {
+ "exercise_ids": "Zwischen {{min}} und {{max}} Übungen, {{count}} ausgewählt.",
+ "max_reps": "{{min}} bis {{max}}. Sätze darüber werden ignoriert.",
+ "window_days": "{{min}} bis {{max}}. Wie weit zurück ein bester Satz zählt."
+ }
+ },
"reorderCategories": "Kategorien neu anordnen",
"deleteInfo": "Dies wird die Kategorie sowie alle seine Einträge löschen",
"deleteInfoGroup": "Dies wird die Gruppe sowie alle ihre Komponenten und deren Einträge löschen",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 0d0952795..3573c32b7 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -406,7 +406,38 @@
"chartRangeYears_other": "{{count}} years",
"customMeasurement": "Custom measurement",
"metricAlreadyTracked": "Already tracked",
- "categoryFormHelpText": "Measurement category, such as 'biceps' or 'body fat'"
+ "categoryFormHelpText": "Measurement category, such as 'biceps' or 'body fat'",
+ "calculations": {
+ "sourceManual": "Entered by hand",
+ "sourceCalculated": "Calculated by wger",
+ "type": "Calculation",
+ "locked": "The calculation cannot be changed. Delete the category to stop calculating it.",
+ "paramsIncomplete": "The calculation is not configured completely yet",
+ "missingHeight": "The height in your profile is missing, so no values can be computed yet.",
+ "noSourceCategory": "You have no other category to read from yet.",
+ "badge": "Calculated",
+ "entryInfo": "This value is calculated by wger and cannot be edited",
+ "names": {
+ "BMI": "BMI",
+ "WHTR": "Waist to height ratio",
+ "ONE_REP_MAX": "One-rep max",
+ "ONE_RM_TOTAL": "One-rep max total"
+ },
+ "descriptions": {
+ "BMI": "From your body weight and the height in your profile",
+ "WHTR": "From {{category}} and the height in your profile"
+ },
+ "params": {
+ "category_id": "Source category",
+ "max_reps": "Max reps counted",
+ "window_days": "Window in days"
+ },
+ "paramsHelp": {
+ "exercise_ids": "Between {{min}} and {{max}} exercises, {{count}} selected.",
+ "max_reps": "{{min}} to {{max}}. Sets above this are ignored.",
+ "window_days": "{{min}} to {{max}}. How far back a best set counts."
+ }
+ }
},
"server": {
"abs": "Abs",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 201d21b16..3bc76c263 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -256,6 +256,37 @@
"notes": "Notas personales",
"seeDetails": "Ver los detalles",
"measurements": {
+ "calculations": {
+ "sourceManual": "Introducido a mano",
+ "sourceCalculated": "Calculado por wger",
+ "type": "Cálculo",
+ "locked": "El cálculo no se puede cambiar. Borre la categoría para dejar de calcularla.",
+ "paramsIncomplete": "El cálculo todavía no está configurado por completo",
+ "missingHeight": "Falta la altura en su perfil, por lo que todavía no se pueden calcular valores.",
+ "noSourceCategory": "Todavía no tiene otra categoría de la que leer.",
+ "badge": "Calculado",
+ "entryInfo": "Este valor lo calcula wger y no se puede editar",
+ "names": {
+ "BMI": "IMC",
+ "WHTR": "Relación cintura-estatura",
+ "ONE_REP_MAX": "1RM",
+ "ONE_RM_TOTAL": "Suma de 1RM"
+ },
+ "descriptions": {
+ "BMI": "De su peso corporal y la altura de su perfil",
+ "WHTR": "De {{category}} y la altura de su perfil"
+ },
+ "params": {
+ "category_id": "Categoría de origen",
+ "max_reps": "Repeticiones máximas contadas",
+ "window_days": "Ventana en días"
+ },
+ "paramsHelp": {
+ "exercise_ids": "Entre {{min}} y {{max}} ejercicios, {{count}} seleccionados.",
+ "max_reps": "De {{min}} a {{max}}. Las series por encima se ignoran.",
+ "window_days": "De {{min}} a {{max}}. Hasta dónde cuenta la mejor serie."
+ }
+ },
"measurements": "Mediciones",
"reorderCategories": "Reordenar categorías",
"unitFormHelpText": "La unidad en la que se medirá la categoría, como cm o %",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index e24980812..66ab570af 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -339,6 +339,37 @@
},
"filters": "Filtres",
"measurements": {
+ "calculations": {
+ "sourceManual": "Saisi à la main",
+ "sourceCalculated": "Calculé par wger",
+ "type": "Calcul",
+ "locked": "Le calcul ne peut pas être modifié. Supprimez la catégorie pour arrêter de la calculer.",
+ "paramsIncomplete": "Le calcul n'est pas encore entièrement configuré",
+ "missingHeight": "La taille manque dans votre profil, aucune valeur ne peut donc encore être calculée.",
+ "noSourceCategory": "Vous n'avez pas encore d'autre catégorie à lire.",
+ "badge": "Calculé",
+ "entryInfo": "Cette valeur est calculée par wger et ne peut pas être modifiée",
+ "names": {
+ "BMI": "IMC",
+ "WHTR": "Rapport tour de taille sur taille",
+ "ONE_REP_MAX": "1RM",
+ "ONE_RM_TOTAL": "Total 1RM"
+ },
+ "descriptions": {
+ "BMI": "À partir de votre poids et de la taille indiquée dans votre profil",
+ "WHTR": "À partir de {{category}} et de la taille indiquée dans votre profil"
+ },
+ "params": {
+ "category_id": "Catégorie source",
+ "max_reps": "Répétitions maximales comptées",
+ "window_days": "Fenêtre en jours"
+ },
+ "paramsHelp": {
+ "exercise_ids": "Entre {{min}} et {{max}} exercices, {{count}} sélectionnés.",
+ "max_reps": "De {{min}} à {{max}}. Les séries au-dessus sont ignorées.",
+ "window_days": "De {{min}} à {{max}}. Jusqu’où remonte la meilleure série."
+ }
+ },
"measurements": "Mesures",
"reorderCategories": "Réorganiser les catégories",
"unitFormHelpText": "L'unité dans laquelle la catégorie sera mesurée, telle que cm ou %",
diff --git a/src/components/Exercises/api/exercise.ts b/src/components/Exercises/api/exercise.ts
index c6a0de2e8..b45e4746c 100644
--- a/src/components/Exercises/api/exercise.ts
+++ b/src/components/Exercises/api/exercise.ts
@@ -80,6 +80,27 @@ export const getExercisesByIds = async (ids: number[]): Promise => {
};
+/*
+ * Fetch exercises by their uuid, the id that is the same on every instance
+ *
+ * The API filters one uuid at a time (no uuid__in), so this asks for them in
+ * parallel. A uuid the instance does not know is left out of the result.
+ */
+export const getExercisesByUuids = async (uuids: string[]): Promise => {
+ const responses = await Promise.all(uuids.map(uuid =>
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ axios.get>(
+ makeUrl(EXERCISE_INFO_PATH, { query: { uuid: uuid } }),
+ { headers: makeHeader() },
+ )
+ ));
+
+ return responses.flatMap(response => processExerciseApiData({
+ results: response.data.results,
+ }));
+};
+
+
/*
* Fetch exercises that belong to the same variation group
*/
diff --git a/src/components/Exercises/index.ts b/src/components/Exercises/index.ts
index bc362e959..9ee3aaa88 100644
--- a/src/components/Exercises/index.ts
+++ b/src/components/Exercises/index.ts
@@ -25,3 +25,6 @@ export { ExerciseVideo, ExerciseVideoAdapter } from "./models/video";
// Query hooks
export { useLanguageQuery, useMusclesQuery } from "./queries";
+
+// API
+export { getExercise, getExercisesByUuids } from "./api/exercise";
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 80bc0538f..3245eec92 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -315,7 +315,12 @@ export type CategoryEntryFlag = {
export const getCategoryEntryFlags = async (): Promise => {
const categories = await getMeasurementCategories();
- return Promise.all(categories.map(async (category) => ({
+ // The list nests the components of a group in their parent, but a
+ // component is a category like any other here: it is the one that holds
+ // the entries of its group, since a parent never does
+ const flat = categories.flatMap(category => [category, ...category.children]);
+
+ return Promise.all(flat.map(async (category) => ({
category: category,
hasEntries: (await getMeasurementEntries(category.id!, {}, 1)).length > 0,
})));
diff --git a/src/components/Measurements/models/Calculation.test.ts b/src/components/Measurements/models/Calculation.test.ts
new file mode 100644
index 000000000..aa5b44b93
--- /dev/null
+++ b/src/components/Measurements/models/Calculation.test.ts
@@ -0,0 +1,93 @@
+import translations from "@/locales/en/translation.json";
+import {
+ CALCULATION_TYPES,
+ calculationType,
+ defaultParams,
+ isKnownCalculation,
+ missingParams,
+ unitMatches
+} from "./Calculation";
+
+describe('the calculation table', () => {
+
+ test('a new calculation starts at the numbers the server would use', () => {
+ const total = calculationType('ONE_RM_TOTAL')!;
+
+ expect(defaultParams(total)).toStrictEqual({
+ exercise_ids: [],
+ max_reps: 5,
+ window_days: 30,
+ });
+ });
+
+ test('an absent number is the server default, a wrong one is refused', () => {
+ const oneRm = calculationType('ONE_REP_MAX')!;
+ const params = { exercise_id: 7 };
+
+ expect(missingParams(oneRm, params)).toStrictEqual([]);
+ expect(missingParams(oneRm, { ...params, max_reps: '' })).toStrictEqual([]);
+ expect(missingParams(oneRm, { ...params, max_reps: 5.5 })).toStrictEqual(['max_reps']);
+ expect(missingParams(oneRm, { ...params, max_reps: '5' })).toStrictEqual(['max_reps']);
+ });
+
+ test('a calculation is incomplete until its exercises are picked', () => {
+ const total = calculationType('ONE_RM_TOTAL')!;
+ const params = defaultParams(total);
+
+ expect(missingParams(total, params)).toStrictEqual(['exercise_ids']);
+ expect(missingParams(total, { ...params, exercise_ids: [1] })).toStrictEqual(['exercise_ids']);
+ expect(missingParams(total, { ...params, exercise_ids: [1, 2] })).toStrictEqual([]);
+ });
+
+ test('a number outside its bounds counts as missing', () => {
+ const oneRm = calculationType('ONE_REP_MAX')!;
+ const params = { ...defaultParams(oneRm), exercise_id: 7 };
+
+ expect(missingParams(oneRm, { ...params, max_reps: 5 })).toStrictEqual([]);
+ expect(missingParams(oneRm, { ...params, max_reps: 42 })).toStrictEqual(['max_reps']);
+ });
+
+ test('BMI takes no parameters at all', () => {
+ const bmi = calculationType('BMI')!;
+
+ expect(defaultParams(bmi)).toStrictEqual({});
+ expect(missingParams(bmi, {})).toStrictEqual([]);
+ });
+
+ test('the source of a ratio has to be a length', () => {
+ const param = calculationType('WHTR')!.params[0];
+
+ expect(unitMatches(param, 'cm')).toBe(true);
+ expect(unitMatches(param, ' IN ')).toBe(true);
+ expect(unitMatches(param, 'kg')).toBe(false);
+ });
+
+ test('a calculation of a newer server is not known here', () => {
+ expect(isKnownCalculation('NONE')).toBe(true);
+ expect(isKnownCalculation('BMI')).toBe(true);
+ expect(isKnownCalculation('FFMI')).toBe(false);
+ });
+
+ test('every calculation brings the strings the form needs', () => {
+ const strings = translations.measurements.calculations;
+
+ for (const type of CALCULATION_TYPES) {
+ expect(strings.names).toHaveProperty(type.slug);
+ for (const param of type.params) {
+ // An exercise picker labels itself, the other fields do not
+ if (param.kind === 'category' || param.kind === 'int') {
+ expect(strings.params).toHaveProperty(param.key);
+ }
+ // the bounds of a number are explained under the field
+ if (param.kind === 'int') {
+ expect(strings.paramsHelp).toHaveProperty(param.key);
+ }
+ }
+ }
+
+ // A description is optional, but a stray one is never rendered
+ for (const slug of Object.keys(strings.descriptions)) {
+ expect(isKnownCalculation(slug)).toBe(true);
+ }
+ });
+});
diff --git a/src/components/Measurements/models/Calculation.ts b/src/components/Measurements/models/Calculation.ts
new file mode 100644
index 000000000..5d68a0818
--- /dev/null
+++ b/src/components/Measurements/models/Calculation.ts
@@ -0,0 +1,170 @@
+/**
+ * The calculations the server can run for a measurement category.
+ *
+ * Mirrored here rather than fetched: the labels are translated in the client
+ * anyway, so a descriptor would carry what the client already knows. Keep in
+ * step with wger/measurements/dynamic/types.py.
+ */
+
+/** The server's dynamic_type for a category the user fills in themselves */
+export const CALCULATION_NONE = 'NONE';
+
+/**
+ * The keys are literal types so the labels and help texts resolve to actual
+ * translation keys (measurements.calculations.params.)
+ */
+export type CalculationParam =
+/** One of the user's own measurement categories, filtered by its unit */
+ | { key: 'category_id', kind: 'category', unitFilter: string[] }
+ /** A single exercise, stored as its id */
+ | { key: 'exercise_id', kind: 'exercise' }
+ /** Two to five exercises, stored as a list of ids */
+ | { key: 'exercise_ids', kind: 'exercises', minItems: number, maxItems: number }
+ /** A bounded number with a value the server falls back to */
+ | { key: 'max_reps' | 'window_days', kind: 'int', min: number, max: number, fallback: number };
+
+/**
+ * One row of the table below. The slug stays a plain string, the union is
+ * derived from the table itself (see CalculationSlug).
+ */
+export interface CalculationType {
+ slug: string;
+ /** Prefill for the category unit; the user can still change it */
+ unit: string;
+ params: readonly CalculationParam[];
+ /** Needs the height in the user profile, and computes nothing without it */
+ needsHeight: boolean;
+}
+
+/**
+ * The units a length may be written in, as the server reads them. Translated
+ * spellings are deliberately not in here, that list has no end.
+ */
+export const LENGTH_UNITS = [
+ 'mm',
+ 'millimeter',
+ 'millimeters',
+ 'cm',
+ 'centimeter',
+ 'centimeters',
+ 'm',
+ 'meter',
+ 'meters',
+ 'in',
+ 'inch',
+ 'inches',
+ '"',
+ '\u2033',
+];
+
+/** The bounds repeat what the server validates, they are not read from its schema */
+export const CALCULATION_TYPES = [
+ {
+ slug: 'BMI',
+ unit: 'kg/m²',
+ params: [],
+ needsHeight: true,
+ },
+ {
+ slug: 'WHTR',
+ unit: '',
+ params: [
+ { key: 'category_id', kind: 'category', unitFilter: LENGTH_UNITS },
+ ],
+ needsHeight: true,
+ },
+ {
+ slug: 'ONE_REP_MAX',
+ unit: 'kg',
+ params: [
+ { key: 'exercise_id', kind: 'exercise' },
+ { key: 'max_reps', kind: 'int', min: 1, max: 10, fallback: 5 },
+ ],
+ needsHeight: false,
+ },
+ {
+ slug: 'ONE_RM_TOTAL',
+ unit: 'kg',
+ params: [
+ { key: 'exercise_ids', kind: 'exercises', minItems: 2, maxItems: 5 },
+ { key: 'max_reps', kind: 'int', min: 1, max: 10, fallback: 5 },
+ { key: 'window_days', kind: 'int', min: 7, max: 120, fallback: 30 },
+ ],
+ needsHeight: false,
+ },
+] as const satisfies readonly CalculationType[];
+
+/** What this release knows, derived from the table so the two cannot drift */
+export type CalculationSlug = typeof CALCULATION_TYPES[number]['slug'];
+
+/**
+ * Bench press, squat and deadlift. By uuid, since the numeric id is local to
+ * each instance; one that never synced them resolves nothing.
+ */
+export const BIG_THREE_UUIDS = [
+ '3717d144-7815-4a97-9a56-956fb889c996',
+ 'a2f5b6ef-b780-49c0-8d96-fdaff23e27ce',
+ 'ee8e8db4-2d82-49e1-ab7f-891e9a354934',
+];
+
+export function calculationType(slug: string): CalculationType | undefined {
+ return CALCULATION_TYPES.find(type => type.slug === slug);
+}
+
+/** Whether this release can render a stored calculation; an unknown one keeps its parameters */
+export function isKnownCalculation(slug: string): boolean {
+ return slug === CALCULATION_NONE || calculationType(slug) !== undefined;
+}
+
+/**
+ * Whether a category's unit fits a parameter that asks for one. A trailing
+ * dot is an abbreviation, not a different unit ("cm.")
+ */
+export function unitMatches(param: CalculationParam, unit: string): boolean {
+ return param.kind === 'category'
+ && param.unitFilter.includes(unit.trim().toLowerCase().replace(/\.$/, ''));
+}
+
+/**
+ * The parameters a freshly picked calculation starts with. The numbers start
+ * at the server's default so the user sees it, an empty field means it again.
+ */
+export function defaultParams(type: CalculationType): Record {
+ const params: Record = {};
+ for (const param of type.params) {
+ if (param.kind === 'exercises') {
+ params[param.key] = [];
+ } else if (param.kind === 'int') {
+ params[param.key] = param.fallback;
+ } else {
+ params[param.key] = null;
+ }
+ }
+ return params;
+}
+
+/**
+ * The parameter keys the server would refuse. A number may be absent, it then
+ * falls back to the server's default.
+ */
+export function missingParams(type: CalculationType, params: Record): string[] {
+ return type.params.filter(param => {
+ const value = params[param.key];
+ switch (param.kind) {
+ case 'exercises':
+ return !Array.isArray(value)
+ || value.length < param.minItems
+ || value.length > param.maxItems;
+ case 'int':
+ if (value === undefined || value === null || value === '') {
+ return false;
+ }
+ return typeof value !== 'number'
+ || !Number.isInteger(value)
+ || value < param.min
+ || value > param.max;
+ default:
+ return value === null || value === undefined || value === '';
+ }
+ }).map(param => param.key);
+}
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index 7b02fa6c4..d323b1684 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -1,6 +1,9 @@
import { Adapter } from "@/core/lib/Adapter";
import { convertStoredValue } from "@/core/lib/weightUnit";
+/** The server writes this source for the entries of a calculated category */
+export const MEASUREMENT_SOURCE_CALCULATED = 'calculated';
+
export class MeasurementEntry {
constructor(
@@ -14,7 +17,10 @@ export class MeasurementEntry {
) {
}
- /** Entries synced from a health app are managed by the source app */
+ /**
+ * Only what the user wrote themselves is theirs to change: an imported
+ * entry belongs to the app it came from, a calculated one to the server
+ */
get isEditable(): boolean {
return this.source === 'user';
}
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 70a578986..04482cb90 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -1,5 +1,10 @@
import { Stack } from "@mui/material";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
+import {
+ CalculationBadge,
+ calculationSourceId,
+ CalculationSource
+} from "@/components/Measurements/widgets/CalculationMark";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import {
categoryDisplayName,
@@ -83,6 +88,10 @@ export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] })
const range = useChartRange();
// eslint-disable-next-line react-hooks/rules-of-hooks
const categoryQuery = useMeasurementsQuery(categoryId);
+ // Only a ratio names another category, so only then is one looked up
+ const sourceId = categoryQuery.data ? calculationSourceId(categoryQuery.data) : undefined;
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const sourceQuery = useMeasurementsQuery(sourceId ?? '', sourceId !== undefined);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [t, i18n] = useTranslation();
@@ -105,6 +114,18 @@ export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] })
: }
mainContent={
+ {/* The mark of a calculated category, plus what it reads */}
+ {categoryQuery.data!.isCalculated &&
+
+
+ }
{
+export const CategoryList = (props: {
+ category: MeasurementCategory,
+ range: ChartRange,
+ /** Name of the category a calculated one reads, see CalculationSource */
+ sourceName?: string,
+}) => {
const [t, i18n] = useTranslation();
const [openModal, setOpenModal] = React.useState(false);
@@ -48,7 +58,15 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
{/* The unit rides on the value; a category still without one
* shows it on its chart axis instead */}
+ {categoryDisplayName(props.category, t)}
+ {' '}
+
+ >}
+ subheader={}
action={}
/>
@@ -107,7 +125,14 @@ export const MeasurementCategoryOverview = () => {
gridTemplateColumns: 'repeat(auto-fill, minmax(min(380px, 100%), 1fr))',
}}>
{categoryQuery.data!.map(c =>
- )}
+ candidate.id === calculationSourceId(c)
+ )?.name}
+ />)}
diff --git a/src/components/Measurements/widgets/CalculationMark.tsx b/src/components/Measurements/widgets/CalculationMark.tsx
new file mode 100644
index 000000000..47d6c9899
--- /dev/null
+++ b/src/components/Measurements/widgets/CalculationMark.tsx
@@ -0,0 +1,57 @@
+import { CalculationSlug } from "@/components/Measurements/models/Calculation";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { Chip, Typography } from "@mui/material";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Marks a category whose entries the server computes. The mark sits on the
+ * category, not on every entry: all of them are calculated.
+ */
+export const CalculationBadge = ({ category }: { category: MeasurementCategory }) => {
+ const [t] = useTranslation();
+
+ if (!category.isCalculated) {
+ return null;
+ }
+
+ return ;
+};
+
+/**
+ * What the values are computed from, in words. Doubles as the explanation for
+ * an empty category; a calculation without such a sentence renders nothing.
+ * The name of a source category is passed in.
+ */
+export const CalculationSource = ({
+ category,
+ sourceName,
+ }: {
+ category: MeasurementCategory,
+ sourceName?: string,
+}) => {
+ const [t] = useTranslation();
+
+ // Absent for a calculation of a newer server as well
+ const description = t(
+ `measurements.calculations.descriptions.${category.dynamicType as CalculationSlug}`,
+ { category: sourceName ?? '', defaultValue: '' },
+ );
+
+ if (!category.isCalculated || description === '') {
+ return null;
+ }
+
+ return
+ {description}
+ ;
+};
+
+/** The id of the category a calculation reads, if it reads one */
+export const calculationSourceId = (category: MeasurementCategory): string | undefined =>
+ (category.dynamicParams as { category_id?: string }).category_id;
diff --git a/src/components/Measurements/widgets/CalculationParams.test.tsx b/src/components/Measurements/widgets/CalculationParams.test.tsx
new file mode 100644
index 000000000..8d4886040
--- /dev/null
+++ b/src/components/Measurements/widgets/CalculationParams.test.tsx
@@ -0,0 +1,118 @@
+import { calculationType } from "@/components/Measurements/models/Calculation";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { CalculationParams } from "@/components/Measurements/widgets/CalculationParams";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from '@testing-library/react';
+import userEvent from "@testing-library/user-event";
+import React from 'react';
+
+vi.mock("@/components/Exercises", async (importOriginal) => ({
+ ...await importOriginal(),
+ useLanguageQuery: vi.fn(() => ({ isSuccess: true, data: [] })),
+ getExercise: vi.fn(async (id: number) => ({
+ id: id,
+ getTranslation: () => ({ name: `Exercise ${id}` }),
+ })),
+}));
+// The autocompleter reads the language list from the module directly
+vi.mock("@/components/Exercises/queries", async (importOriginal) => ({
+ ...await importOriginal(),
+ useLanguageQuery: vi.fn(() => ({ isSuccess: true, data: [] })),
+}));
+
+const WHTR = calculationType('WHTR')!;
+const ONE_REP_MAX = calculationType('ONE_REP_MAX')!;
+
+const category = (id: string, name: string, unit: string) =>
+ new MeasurementCategory(id, name, unit);
+
+const renderParams = (props: {
+ type?: typeof WHTR,
+ categories?: MeasurementCategory[],
+ params?: Record,
+ onChange?: (params: Record) => void,
+}) => render(
+
+
+
+);
+
+describe('The parameters of a calculation', () => {
+
+ test('says so when there is no other category to read from', () => {
+ renderParams({ categories: [] });
+
+ expect(screen.getByText('measurements.calculations.noSourceCategory')).toBeInTheDocument();
+ expect(screen.queryByLabelText('measurements.calculations.params.category_id'))
+ .not.toBeInTheDocument();
+ });
+
+ test('a category whose unit it cannot read is shown, but cannot be picked', async () => {
+ const user = userEvent.setup();
+ const calculated = category('c-4', 'Waist to height', '');
+ calculated.dynamicType = 'WHTR';
+
+ renderParams({
+ categories: [
+ category('c-1', 'Waist', 'cm'),
+ category('c-2', 'Chest', 'inches'),
+ category('c-3', 'Bauch', 'Zentimeter'),
+ calculated,
+ // the category being configured cannot read itself
+ category('c-self', 'Self', 'cm'),
+ ],
+ });
+ await user.click(screen.getByLabelText('measurements.calculations.params.category_id'));
+
+ // The unit next to the name is what the user has to change, so the
+ // category is offered rather than hidden
+ expect(screen.getByRole('option', { name: 'Waist (cm)' })).not.toHaveAttribute('aria-disabled');
+ expect(screen.getByRole('option', { name: 'Chest (inches)' })).not.toHaveAttribute('aria-disabled');
+ expect(screen.getByRole('option', { name: 'Bauch (Zentimeter)' }))
+ .toHaveAttribute('aria-disabled', 'true');
+
+ // A calculated category and the one being configured are not sources
+ expect(screen.queryByRole('option', { name: /Waist to height/ })).not.toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: /Self/ })).not.toBeInTheDocument();
+ });
+
+ test('the usual spellings of a length count as the same unit', async () => {
+ const user = userEvent.setup();
+ renderParams({
+ categories: [
+ category('c-1', 'Waist', ' CM. '),
+ category('c-2', 'Chest', 'Centimeters'),
+ category('c-3', 'Arm', '"'),
+ category('c-4', 'Hip', 'mm'),
+ category('c-5', 'Leg', 'm'),
+ ],
+ });
+ await user.click(screen.getByLabelText('measurements.calculations.params.category_id'));
+
+ for (const name of [
+ 'Waist ( CM. )', 'Chest (Centimeters)', 'Arm (")', 'Hip (mm)', 'Leg (m)',
+ ]) {
+ expect(screen.getByRole('option', { name })).not.toHaveAttribute('aria-disabled');
+ }
+ });
+
+ test('a number that is cleared stays empty, which means the server default', async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+ renderParams({
+ type: ONE_REP_MAX,
+ params: { exercise_id: 1, max_reps: 5 },
+ onChange: onChange,
+ });
+
+ await user.clear(screen.getByLabelText('measurements.calculations.params.max_reps'));
+
+ expect(onChange).toHaveBeenCalledWith({ exercise_id: 1 });
+ });
+});
diff --git a/src/components/Measurements/widgets/CalculationParams.tsx b/src/components/Measurements/widgets/CalculationParams.tsx
new file mode 100644
index 000000000..6e2cd634c
--- /dev/null
+++ b/src/components/Measurements/widgets/CalculationParams.tsx
@@ -0,0 +1,204 @@
+import {
+ Exercise,
+ getExercise,
+ getLanguageByShortName,
+ NameAutocompleter,
+ useLanguageQuery
+} from "@/components/Exercises";
+import { CalculationParam, CalculationType, unitMatches } from "@/components/Measurements/models/Calculation";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { QueryKey } from "@/core/lib/consts";
+import { Alert, Box, Chip, MenuItem, Stack, TextField, Typography } from "@mui/material";
+import { useQueries, useQueryClient } from "@tanstack/react-query";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+type Params = Record;
+
+interface CalculationParamsProps {
+ type: CalculationType;
+ params: Params;
+ onChange: (params: Params) => void;
+ /** The user's categories, for the pickers that read one */
+ categories: MeasurementCategory[];
+ /** The category being edited, which cannot be its own source */
+ categoryId?: string | null;
+}
+
+/** The ids a parameter holds, as a list, whether it takes one or several */
+const idsOf = (param: CalculationParam, params: Params): number[] => {
+ const value = params[param.key];
+ if (Array.isArray(value)) {
+ return value as number[];
+ }
+ return typeof value === 'number' ? [value] : [];
+};
+
+/**
+ * The parameter block of a calculation, between the type select and the rest
+ * of the form. Which fields appear follows from the type.
+ */
+export const CalculationParams = ({
+ type,
+ params,
+ onChange,
+ categories,
+ categoryId,
+ }: CalculationParamsProps) => {
+
+ const [t, i18n] = useTranslation();
+ const languageQuery = useLanguageQuery();
+ const language = languageQuery.isSuccess
+ ? getLanguageByShortName(i18n.language, languageQuery.data)
+ : undefined;
+
+ // Every exercise the parameters point at, so an existing configuration
+ // shows names rather than the ids it is stored as
+ const exerciseIds = type.params.flatMap(param =>
+ param.kind === 'exercise' || param.kind === 'exercises' ? idsOf(param, params) : []
+ );
+ // The same key the rest of the app reads an exercise under, so a detail
+ // page that already loaded one answers for the chip as well
+ const exerciseQueries = useQueries({
+ queries: exerciseIds.map(id => ({
+ queryKey: [QueryKey.EXERCISE_DETAIL, id],
+ queryFn: () => getExercise(id),
+ // An exercise record does not change while a form is open, and
+ // without this a seeded one would be refetched right away
+ staleTime: Infinity,
+ })),
+ });
+ const queryClient = useQueryClient();
+ const exerciseName = (id: number): string => {
+ const exercise = exerciseQueries
+ .map(query => query.data)
+ .find(data => data?.id === id);
+ return exercise ? exercise.getTranslation(language).name : `#${id}`;
+ };
+
+ const set = (key: string, value: unknown) => onChange({ ...params, [key]: value });
+
+ const renderCategoryPicker = (param: CalculationParam & { kind: 'category' }) => {
+ // A calculated category cannot feed another one, and nothing can feed
+ // itself; the server refuses both
+ const candidates = categories.filter(candidate =>
+ candidate.id !== categoryId && !candidate.isCalculated
+ );
+
+ if (candidates.length === 0) {
+ return
+ {t('measurements.calculations.noSourceCategory')}
+ ;
+ }
+
+ // A category whose unit this calculation cannot read is shown rather
+ // than hidden: the unit next to it is what the user has to change, and
+ // a silently short list explains nothing
+ return set(param.key, event.target.value)}
+ >
+ {candidates.map(candidate =>
+
+ )}
+ ;
+ };
+
+ const renderExercisePicker = (param: CalculationParam & { kind: 'exercise' | 'exercises' }) => {
+ const selected = idsOf(param, params);
+ const isMulti = param.kind === 'exercises';
+ const full = isMulti && selected.length >= param.maxItems;
+
+ const add = (exercise: Exercise | null) => {
+ if (exercise === null || exercise.id === null) {
+ return;
+ }
+ // The autocompleter hands over the full record, so the chip does
+ // not have to fetch what is already here
+ queryClient.setQueryData([QueryKey.EXERCISE_DETAIL, exercise.id], exercise);
+ if (!isMulti) {
+ set(param.key, exercise.id);
+ return;
+ }
+ if (selected.includes(exercise.id) || full) {
+ return;
+ }
+ set(param.key, [...selected, exercise.id]);
+ };
+
+ const remove = (id: number) => set(
+ param.key,
+ isMulti ? selected.filter(current => current !== id) : null,
+ );
+
+ return
+ {selected.length > 0 &&
+ {selected.map(id =>
+ remove(id)} />
+ )}
+ }
+ {!full && }
+ {isMulti &&
+ {t('measurements.calculations.paramsHelp.exercise_ids', {
+ min: param.minItems,
+ max: param.maxItems,
+ count: selected.length,
+ })}
+ }
+ ;
+ };
+
+ /** A bounded number. Kept as typed: an emptied field means the default */
+ const renderNumber = (param: CalculationParam & { kind: 'int' }) => {
+ const raw = event.target.value;
+ if (raw === '') {
+ const rest = { ...params };
+ delete rest[param.key];
+ onChange(rest);
+ return;
+ }
+ // Number() also swallows '5.5', which the server refuses as an
+ // integer, so what is not whole is kept as typed and rejected
+ const parsed = Number(raw);
+ set(param.key, Number.isInteger(parsed) ? parsed : raw);
+ }}
+ />;
+
+ return
+ {type.params.map(param => {
+ switch (param.kind) {
+ case 'category':
+ return renderCategoryPicker(param);
+ case 'exercise':
+ case 'exercises':
+ return renderExercisePicker(param);
+ case 'int':
+ return renderNumber(param);
+ }
+ })}
+ ;
+};
diff --git a/src/components/Measurements/widgets/CalculationSection.tsx b/src/components/Measurements/widgets/CalculationSection.tsx
new file mode 100644
index 000000000..69a3401b2
--- /dev/null
+++ b/src/components/Measurements/widgets/CalculationSection.tsx
@@ -0,0 +1,132 @@
+import {
+ CALCULATION_NONE,
+ CALCULATION_TYPES,
+ CalculationSlug,
+ calculationType,
+ CalculationType
+} from "@/components/Measurements/models/Calculation";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { CalculationParams } from "@/components/Measurements/widgets/CalculationParams";
+import { useProfileQuery } from "@/components/User";
+import { Alert, MenuItem, TextField, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material";
+import { useFormikContext } from "formik";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+interface CalculationValues {
+ calculation: string;
+ params: Record;
+}
+
+interface CalculationSectionProps {
+ /** The category being edited, absent while one is created */
+ category?: MeasurementCategory;
+ /** Every category of the user, group children included */
+ categories: MeasurementCategory[];
+ /** Switches to a calculation, which also prefills the name and the unit */
+ onPick: (type?: CalculationType) => void;
+}
+
+/**
+ * Who fills a category in: the user by hand, or the server by computing it.
+ * Only free-form categories can be calculated, the API refuses the rest.
+ */
+export const CalculationSection = ({
+ category,
+ categories,
+ onPick,
+ }: CalculationSectionProps) => {
+
+ const [t] = useTranslation();
+ const { values, errors, submitCount, setFieldValue } = useFormikContext();
+ const profileQuery = useProfileQuery();
+
+ // What a category computes is what it is, like its metric type, so it is
+ // set once: the server refuses a change, and stopping means deleting
+ const isLocked = category?.isCalculated ?? false;
+ const picked = calculationType(values.calculation);
+
+ /** Whether the user has this calculation; only decidable without parameters */
+ const isTaken = (type: CalculationType): boolean => type.params.length === 0
+ && categories.some(candidate =>
+ candidate.dynamicType === type.slug && candidate.id !== category?.id
+ );
+
+ /** What the switch starts at; a taken one would be refused when saved */
+ const firstAvailable = (): CalculationType =>
+ CALCULATION_TYPES.find(type => !isTaken(type)) ?? CALCULATION_TYPES[0];
+
+ // The ratio names the category it reads, so the description needs it too
+ const sourceId = (values.params as { category_id?: string }).category_id;
+ const sourceName = categories.find(candidate => candidate.id === sourceId)?.name ?? '';
+
+ // Not every calculation brings a description, see the translation file
+ const description = t(
+ `measurements.calculations.descriptions.${values.calculation as CalculationSlug}`,
+ { category: sourceName, defaultValue: '' },
+ );
+
+ return <>
+ {!isLocked && <>
+ {
+ if (mode === null) {
+ return;
+ }
+ if (mode === 'manual') {
+ setFieldValue('calculation', CALCULATION_NONE);
+ return;
+ }
+ onPick(firstAvailable());
+ }}
+ >
+
+ {t('measurements.calculations.sourceManual')}
+
+
+ {t('measurements.calculations.sourceCalculated')}
+
+
+ >}
+
+ {picked !== undefined && <>
+ onPick(calculationType(event.target.value))}
+ >
+ {CALCULATION_TYPES.map(type =>
+
+ )}
+
+ {description !== '' &&
+ {description}
+ }
+ {picked.needsHeight && !profileQuery.data?.height &&
+ {t('measurements.calculations.missingHeight')}
+ }
+ setFieldValue('params', params)}
+ categories={categories}
+ categoryId={category?.id}
+ />
+ {/* Only once sent: incomplete is the normal state while typing */}
+ {submitCount > 0 && typeof errors.params === 'string' &&
+ {errors.params}}
+ >}
+ >;
+};
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 624733a27..1c8119760 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -56,6 +56,30 @@ describe('CategoryDetailDataGrid', () => {
expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument();
});
+ test('a calculated entry says who keeps it, not that it was synced', async () => {
+ const category = new MeasurementCategory(CATEGORY_UUID, 'BMI', '');
+ const entries = [
+ new MeasurementEntry(
+ SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 24, '', 'calculated'),
+ ];
+
+ render(
+
+
+
+ );
+ await screen.findByText('24');
+
+ const row = document.querySelector(`[data-id="${SYNCED_ENTRY_UUID}"]`) as HTMLElement;
+ expect(within(row).queryByRole('menuitem', { name: /edit/i })).not.toBeInTheDocument();
+ expect(within(row).getByRole(
+ 'menuitem',
+ { name: 'measurements.calculations.entryInfo' },
+ )).toBeInTheDocument();
+ expect(within(row).queryByRole('menuitem', { name: 'syncedEntryInfo' }))
+ .not.toBeInTheDocument();
+ });
+
test('a page measures its difference columns against the entries outside it', async () => {
const category = new MeasurementCategory(CATEGORY_UUID, 'Biceps', 'cm');
const page = [
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index a7ae77e3b..65e08e7d9 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -2,11 +2,15 @@ import { processTimeSeries } from "@/core/lib/timeSeries";
import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format";
import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category";
import { collectValidationErrors } from "@/core/lib/forms";
-import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import {
+ MEASUREMENT_SOURCE_CALCULATED,
+ MeasurementEntry
+} from "@/components/Measurements/models/Entry";
import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { PAGINATION_OPTIONS } from "@/core/lib/consts";
import { luxonDateTimeToLocale } from "@/core/lib/date";
import CancelIcon from "@mui/icons-material/Close";
+import CalculateIcon from "@mui/icons-material/Calculate";
import CloudSyncIcon from "@mui/icons-material/CloudSync";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/Edit";
@@ -50,6 +54,7 @@ const buildRows = (
value: row.entry.valueIn(unit, categoryUnit),
notes: row.entry.notes,
isEditable: row.entry.isEditable,
+ source: row.entry.source,
change: +row.change.toFixed(2),
totalChange: +row.totalChange.toFixed(2),
days: +row.days.toFixed(1),
@@ -254,13 +259,22 @@ export const CategoryDetailDataGrid = (props: {
width: 100,
cellClassName: 'actions',
getActions: ({ id, row }) => {
- // synced entries are managed by the source app, offer no actions
+ // Entries the user did not write offer no actions. Who keeps
+ // them differs: an import is changed in the app it came from,
+ // a calculated value is the server's and changes with what it
+ // is computed from
if (!row.isEditable) {
+ const isCalculated = row.source === MEASUREMENT_SOURCE_CALCULATED;
+ const info = isCalculated
+ ? t('measurements.calculations.entryInfo')
+ : t('syncedEntryInfo');
return [
}
- label={t('syncedEntryInfo')}
+ icon={
+ {isCalculated ? : }
+ }
+ label={info}
color="inherit"
// a badge, not a button: disabled drops the click
// affordance, the style keeps hover events flowing
diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
index 180c2b7d2..3e5daecb8 100644
--- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
@@ -71,7 +71,13 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory })
-
+ {/* Fixed height: the form grows with the calculation picked in it */}
+
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index bdc5eef93..4ab401cf9 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -6,6 +6,7 @@ import {
useEditMeasurementCategoryQuery,
useCategoryEntryFlagsQuery
} from "@/components/Measurements/queries";
+import { useProfileQuery } from "@/components/User";
import { MeasurementCategory, TrendCharacter } from "@/components/Measurements/models/Category";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
import React from 'react';
@@ -15,6 +16,27 @@ import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock("@/components/Measurements/queries");
+vi.mock("@/components/User");
+// The parameter block reads exercises; without this the suite would hit the
+// network for the language list and for every exercise a chip names
+vi.mock("@/components/Exercises", async (importOriginal) => ({
+ ...await importOriginal(),
+ useLanguageQuery: vi.fn(() => ({ isSuccess: true, data: [] })),
+ getExercise: vi.fn(async (id: number) => ({
+ id: id,
+ getTranslation: () => ({ name: `Exercise ${id}` }),
+ })),
+ getExercisesByUuids: vi.fn(async () => [
+ { id: 73, getTranslation: () => ({ name: 'Bench press' }) },
+ { id: 615, getTranslation: () => ({ name: 'Squats' }) },
+ { id: 184, getTranslation: () => ({ name: 'Deadlifts' }) },
+ ]),
+}));
+// The autocompleter reads the language list from the module directly
+vi.mock("@/components/Exercises/queries", async (importOriginal) => ({
+ ...await importOriginal(),
+ useLanguageQuery: vi.fn(() => ({ isSuccess: true, data: [] })),
+}));
// an entry-free category, eligible as a group parent
const TEST_GROUP_CATEGORY = new MeasurementCategory(
@@ -63,6 +85,9 @@ describe("Test the CategoryForm component", () => {
{ category: TEST_GROUP_CATEGORY, hasEntries: false },
]
}));
+ (useProfileQuery as Mock).mockImplementation(() => ({
+ data: { height: 180 }
+ }));
});
test('Passing an existing entry renders its values in the form', () => {
@@ -475,4 +500,265 @@ describe("Test the CategoryForm component", () => {
await user.type(nameInput, 'Biceps');
await waitFor(() => expect(nameInput).not.toHaveAttribute('aria-invalid', 'true'));
});
+
+ test('Switching a new category to calculated prefills it and offers the types', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'measurements.calculations.sourceCalculated' }));
+
+ // Assert: the first calculation is picked, with its name and unit
+ expect(await screen.findByLabelText('measurements.calculations.type')).toBeInTheDocument();
+ await waitFor(() =>
+ expect(screen.getByLabelText('name')).toHaveValue('measurements.calculations.names.BMI'));
+ expect(screen.getByLabelText('unit')).toHaveValue('kg/m²');
+ });
+
+ test('A calculated category is saved with its type and parameters', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+
+ // Act
+ await user.click(screen.getByRole('button', { name: 'measurements.calculations.sourceCalculated' }));
+ await waitFor(() => expect(screen.getByLabelText('unit')).toHaveValue('kg/m²'));
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
+ expect(mutate.mock.calls[0][0].dynamicType).toBe('BMI');
+ expect(mutate.mock.calls[0][0].dynamicParams).toStrictEqual({});
+ });
+
+ test('A category the user fills in themselves is not offered a calculation', async () => {
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert: what a category computes is set when it is created
+ expect(screen.queryByRole('button', { name: 'measurements.calculations.sourceCalculated' }))
+ .not.toBeInTheDocument();
+ expect(screen.queryByRole('combobox', { name: 'measurements.calculations.type' }))
+ .not.toBeInTheDocument();
+ });
+
+ test('A calculation this release does not know is left alone', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ const newer = MeasurementCategory.clone(TEST_GROUP_CATEGORY);
+ newer.dynamicType = 'FFMI';
+ newer.dynamicParams = { category_id: 'c-body-fat' };
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert: nothing to switch or configure, and saving keeps the
+ // stored configuration as it is
+ expect(screen.queryByRole('button', { name: 'measurements.calculations.sourceCalculated' }))
+ .not.toBeInTheDocument();
+ expect(screen.queryByRole('combobox', { name: 'measurements.calculations.type' }))
+ .not.toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
+ expect(mutate.mock.calls[0][0].dynamicType).toBe('FFMI');
+ expect(mutate.mock.calls[0][0].dynamicParams).toStrictEqual({ category_id: 'c-body-fat' });
+ });
+
+ test('A calculation without a unit is accepted, a hand-kept category still needs one', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+ await user.type(screen.getByLabelText('name'), 'Ratio');
+
+ // Act + Assert: without a calculation the unit is required
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+ await waitFor(() =>
+ expect(screen.getByLabelText('unit')).toHaveAttribute('aria-invalid', 'true'));
+ expect(mutate).not.toHaveBeenCalled();
+
+ // Act: the ratio is a bare number, so it may go without one
+ await user.click(screen.getByRole('button', { name: 'measurements.calculations.sourceCalculated' }));
+ await user.clear(screen.getByLabelText('unit'));
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
+ expect(mutate.mock.calls[0][0].unit).toBe('');
+ });
+
+ test('An existing calculated category keeps its calculation', async () => {
+
+ // Arrange
+ const calculated = MeasurementCategory.clone(TEST_GROUP_CATEGORY);
+ calculated.dynamicType = 'ONE_REP_MAX';
+ calculated.dynamicParams = { exercise_id: 1, max_reps: 5 };
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert: no way back to hand entry and no way to another calculation
+ expect(screen.queryByRole('button', { name: 'measurements.calculations.sourceManual' }))
+ .not.toBeInTheDocument();
+ expect(screen.getByLabelText('measurements.calculations.type')).toHaveAttribute(
+ 'aria-disabled',
+ 'true',
+ );
+ expect(screen.getByText('measurements.calculations.locked')).toBeInTheDocument();
+ });
+
+ test('An existing calculation shows the exercise it reads, by name', async () => {
+
+ // Arrange
+ const calculated = MeasurementCategory.clone(TEST_GROUP_CATEGORY);
+ calculated.dynamicType = 'ONE_REP_MAX';
+ calculated.dynamicParams = { exercise_id: 42 };
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert: the chip resolves the stored id into the exercise name
+ expect(await screen.findByText('Exercise 42')).toBeInTheDocument();
+ });
+
+ test('A total starts with bench press, squat and deadlift', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ render(
+
+
+
+ );
+
+ // Act: switch to a calculation and pick the total
+ await user.click(screen.getByRole('button', { name: 'measurements.calculations.sourceCalculated' }));
+ await user.click(await screen.findByRole('combobox', {
+ name: 'measurements.calculations.type',
+ }));
+ await user.click(screen.getByRole('option', {
+ name: 'measurements.calculations.names.ONE_RM_TOTAL',
+ }));
+
+ // Assert: the three are in, and they are what gets saved
+ expect(await screen.findByText('Bench press')).toBeInTheDocument();
+ expect(screen.getByText('Squats')).toBeInTheDocument();
+ expect(screen.getByText('Deadlifts')).toBeInTheDocument();
+
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+ await waitFor(() => expect(mutate).toHaveBeenCalled());
+ expect(mutate.mock.calls[0][0].dynamicParams).toStrictEqual({
+ exercise_ids: [73, 615, 184],
+ max_reps: 5,
+ window_days: 30,
+ });
+ });
+
+ test('An incomplete calculation is complained about when the form is saved', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ data: [] }));
+
+ render(
+
+
+
+ );
+
+ const pick = async (name: RegExp) => {
+ await user.click(await screen.findByRole('combobox', {
+ name: 'measurements.calculations.type',
+ }));
+ await user.click(await screen.findByRole('option', { name: name }));
+ };
+
+ // Act & assert: an exercise that is still missing says nothing yet
+ await user.click(screen.getByRole('button', {
+ name: 'measurements.calculations.sourceCalculated',
+ }));
+ await pick(/names.ONE_REP_MAX/);
+ expect(screen.queryByText('measurements.calculations.paramsIncomplete')).toBeNull();
+
+ // Sending the form says it, and saves nothing
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+ expect(await screen.findByText('measurements.calculations.paramsIncomplete'))
+ .toBeInTheDocument();
+ expect(mutate).not.toHaveBeenCalled();
+
+ // The complaint belongs to what is picked now, not to what was before
+ await pick(/names.BMI/);
+ await waitFor(() =>
+ expect(screen.queryByText('measurements.calculations.paramsIncomplete')).toBeNull()
+ );
+ });
+
+ test('A calculation the user already has cannot be picked twice', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ const bmi = MeasurementCategory.clone(TEST_GROUP_CATEGORY, { id: 'c-bmi', name: 'BMI' });
+ bmi.dynamicType = 'BMI';
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({
+ data: [{ category: bmi, hasEntries: false }]
+ }));
+
+ render(
+
+
+
+ );
+
+ // Act: the switch skips BMI, and the list offers it disabled
+ await user.click(screen.getByRole('button', { name: 'measurements.calculations.sourceCalculated' }));
+ const select = await screen.findByRole('combobox', {
+ name: 'measurements.calculations.type',
+ });
+
+ // Assert: the switch lands on the first calculation still to be had
+ expect(select).toHaveTextContent('names.WHTR');
+ expect(screen.getByLabelText('name')).toHaveValue(
+ 'measurements.calculations.names.WHTR'
+ );
+
+ await user.click(select);
+ expect(screen.getByRole('option', { name: /names.BMI/ }))
+ .toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('option', { name: /names.WHTR/ }))
+ .not.toHaveAttribute('aria-disabled');
+ });
});
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index b556adb6a..5d1c7f314 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -11,13 +11,31 @@ import {
TrendCharacter,
trendOf
} from "@/components/Measurements/models/Category";
+import { getExercisesByUuids } from "@/components/Exercises";
+import {
+ BIG_THREE_UUIDS,
+ CALCULATION_NONE,
+ calculationType,
+ CalculationSlug,
+ CalculationType,
+ defaultParams,
+ missingParams
+} from "@/components/Measurements/models/Calculation";
import {
useAddMeasurementCategoryQuery,
useCategoryEntryFlagsQuery,
useEditMeasurementCategoryQuery
} from "@/components/Measurements/queries";
-import { Button, MenuItem, Stack, TextField } from "@mui/material";
+import { CalculationSection } from "@/components/Measurements/widgets/CalculationSection";
+import {
+ Button,
+ MenuItem,
+ Stack,
+ TextField
+} from "@mui/material";
import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
+import { QueryKey } from "@/core/lib/consts";
+import { useQueryClient } from "@tanstack/react-query";
import { Form, Formik } from "formik";
import React from 'react';
import { useTranslation } from "react-i18next";
@@ -82,10 +100,29 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
.string()
.required(t('forms.fieldRequired'))
.max(100, t('forms.maxLength', { chars: '100' })),
- unit: yup
- .string()
- .required(t('forms.fieldRequired'))
- .max(30, t('forms.maxLength', { chars: '30' }))
+ // Required only where the field is offered: a typed category takes
+ // its unit from the metric type (a step count has none at all), and a
+ // calculation defines what the number is, which may be a bare ratio
+ unit: isCustom
+ ? yup
+ .string()
+ .max(30, t('forms.maxLength', { chars: '30' }))
+ .when('calculation', {
+ is: CALCULATION_NONE,
+ then: schema => schema.required(t('forms.fieldRequired')),
+ })
+ : yup.string(),
+ // The parameters belong to the schema like every other field, so a
+ // fixed one clears its error by itself
+ params: yup.mixed().test(
+ 'calculation-params',
+ t('measurements.calculations.paramsIncomplete'),
+ function (value) {
+ const picked = calculationType(this.parent.calculation);
+ return picked === undefined
+ || missingParams(picked, (value ?? {}) as Record).length === 0;
+ },
+ ),
});
@@ -94,6 +131,105 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
const seededTrend = trendOf(category?.chartConfig ?? {});
const seededWindow = averageWindowOf(category?.chartConfig ?? {});
+ const storedCalculation = category?.dynamicType ?? CALCULATION_NONE;
+
+ /** What the form holds, named so that a whole-form update can be typed */
+ interface CategoryFormValues {
+ name: string;
+ unit: string;
+ metricType: MetricType;
+ chartType: ChartType;
+ trend: TrendCharacter;
+ averageWindow: number;
+ parentId: string;
+ calculation: string;
+ params: Record;
+ }
+
+ // Group children are in this list as well, and they are categories like
+ // any other here: one can hold the entries that block a calculation, and
+ // one can be the source of a ratio
+ const allCategories = (categoryQuery.data ?? []).map(flag => flag.category);
+
+ const queryClient = useQueryClient();
+ // Which calculation the last pick was for, see prefillBigThree
+ const pickedRef = React.useRef('');
+
+ const [nameEdited, setNameEdited] = React.useState(category !== undefined);
+ const [unitEdited, setUnitEdited] = React.useState(category !== undefined);
+
+
+ /**
+ * Switches the form to a calculation: its parameters start at their
+ * defaults, and name and unit are prefilled as long as the user has not
+ * written their own.
+ */
+ const pickCalculation = (
+ formik: {
+ values: CategoryFormValues,
+ setValues: (values: CategoryFormValues) => unknown,
+ setFieldValue: (field: string, value: unknown) => unknown,
+ },
+ type?: CalculationType,
+ ) => {
+ if (type === undefined) {
+ return;
+ }
+ pickedRef.current = type.slug;
+
+ // One update, not one per field: each validates on its own and would
+ // check the new parameters against the calculation before them
+ formik.setValues({
+ ...formik.values,
+ calculation: type.slug,
+ params: defaultParams(type),
+ ...(nameEdited
+ ? {}
+ : { name: t(`measurements.calculations.names.${type.slug as CalculationSlug}`) }),
+ ...(unitEdited ? {} : { unit: type.unit }),
+ });
+ prefillBigThree(formik, type);
+ };
+
+ /**
+ * A total of several exercises means bench press, squat and deadlift for
+ * most people, so that is what a fresh one starts with. The chips stay
+ * removable, and an instance that never synced them prefills nothing.
+ */
+ const prefillBigThree = async (
+ formik: { setFieldValue: (field: string, value: unknown) => unknown },
+ type: CalculationType,
+ ) => {
+ const param = type.params.find(candidate => candidate.kind === 'exercises');
+ if (param === undefined) {
+ return;
+ }
+
+ try {
+ const exercises = await queryClient.ensureQueryData({
+ queryKey: [QueryKey.EXERCISES, 'big-three'],
+ queryFn: () => getExercisesByUuids(BIG_THREE_UUIDS),
+ staleTime: Infinity,
+ });
+ const ids = exercises
+ .map(exercise => exercise.id)
+ .filter((id): id is number => id !== null);
+
+ // The chips read an exercise under this key, and the whole record
+ // is already here, so they do not have to fetch it again
+ for (const exercise of exercises) {
+ queryClient.setQueryData([QueryKey.EXERCISE_DETAIL, exercise.id], exercise);
+ }
+
+ // The user may have picked something else while this was loading
+ if (ids.length === BIG_THREE_UUIDS.length && pickedRef.current === type.slug) {
+ formik.setFieldValue('params', { ...defaultParams(type), [param.key]: ids });
+ }
+ } catch {
+ // Nothing to prefill, the user picks the exercises themselves
+ }
+ };
+
return (
{
// the empty string stands in for "no group", MUI selects
// don't accept null values
parentId: category?.parentId ?? "",
- }}
+ calculation: storedCalculation,
+ params: (category?.dynamicParams ?? {}) as Record,
+ } as CategoryFormValues}
validationSchema={validationSchema}
onSubmit={async (values) => {
const parentId = values.parentId === "" ? null : values.parentId;
@@ -137,13 +275,18 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
// Edit existing category
if (category) {
- useEditCategoryQuery.mutate(withSettings(MeasurementCategory.clone(category, {
+ const edited = MeasurementCategory.clone(category, {
name: values.name,
unit: values.unit,
metricType: values.metricType,
chartType: values.chartType,
parentId: parentId,
- })), options);
+ });
+ edited.dynamicType = values.calculation;
+ edited.dynamicParams = values.calculation === CALCULATION_NONE
+ ? {}
+ : values.params;
+ useEditCategoryQuery.mutate(withSettings(edited), options);
} else {
useAddCategoryQuery.mutate(withSettings(new MeasurementCategory(
null,
@@ -154,6 +297,9 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
parentId,
0,
values.chartType,
+ {},
+ values.calculation,
+ values.calculation === CALCULATION_NONE ? {} : values.params,
)), options);
}
}}
@@ -168,6 +314,10 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
error={formik.touched.name && Boolean(formik.errors.name)}
helperText={formik.touched.name && formik.errors.name}
{...formik.getFieldProps('name')}
+ onChange={event => {
+ setNameEdited(true);
+ formik.handleChange(event);
+ }}
/>}
{isCustom && {
: t('measurements.unitFormHelpText')
}
{...formik.getFieldProps('unit')}
+ onChange={event => {
+ setUnitEdited(true);
+ formik.handleChange(event);
+ }}
/>}
+ {/* What a category computes is set when it is created:
+ * the server refuses a change afterwards, so an
+ * existing one only shows what it already does */}
+ {isCustom && !hasChildren
+ && (category === undefined || category.isCalculated)
+ && pickCalculation(formik, type)}
+ />}
{/* The metric type is picked when the category is
* created (see NewCategoryPicker) and fixed from then
* on: the key of a typed category is derived from it,
@@ -215,36 +379,40 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
* currently drawn as something else keeps its
* settings but cannot change them
*/}
- {canDrawLine(formik.values.metricType, hasChildren) && <>
-
- {TREND_CHARACTERS.map((trend: TrendCharacter) =>
-
- )}
-
-
- {AVERAGE_WINDOWS.map(days =>
-
- )}
-
- >}
+ {canDrawLine(formik.values.metricType, hasChildren) &&
+ /* The two settings of the line share a row: they
+ * belong together and the form is long enough
+ */
+
+
+ {TREND_CHARACTERS.map((trend: TrendCharacter) =>
+
+ )}
+
+
+ {AVERAGE_WINDOWS.map(days =>
+
+ )}
+
+ }
{!hasChildren && formik.values.metricType === 'custom'
&& parentCandidates.length > 0 &&
{useLocation().pathname}
;
@@ -30,6 +32,8 @@ describe("Test the NewCategoryPicker component", () => {
(useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({ mutate: mutate }));
// read by the form the custom entry leads into
(useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ data: [] }));
+ // the form the picker leads into reads it
+ (useProfileQuery as Mock).mockImplementation(() => ({ data: { height: 180 } }));
(useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
data: [TEST_MEASUREMENT_CATEGORY_1]
}));
@@ -99,6 +103,19 @@ describe("Test the NewCategoryPicker component", () => {
);
});
+ test('The metrics are listed by name, not in the order the types are declared', () => {
+
+ // Act
+ renderPicker();
+
+ // Assert
+ const names = screen
+ .getAllByText(/^measurements\.metricTypes\./)
+ .map(node => node.textContent ?? '');
+ expect(names.length).toBeGreaterThan(3);
+ expect(names).toStrictEqual([...names].sort((a, b) => a.localeCompare(b)));
+ });
+
test('A metric that already has a category cannot be picked again', () => {
// Arrange
(useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
diff --git a/src/components/Measurements/widgets/MetricPicker.tsx b/src/components/Measurements/widgets/MetricPicker.tsx
index 4fde6403e..3528f9134 100644
--- a/src/components/Measurements/widgets/MetricPicker.tsx
+++ b/src/components/Measurements/widgets/MetricPicker.tsx
@@ -45,8 +45,30 @@ export const NewCategoryPicker = ({ closeFn }: { closeFn?: () => void }) => {
const taken = new Set((categoryQuery.data ?? []).map(c => c.metricType));
+ // By the name the user reads, not by the order the types are declared in:
+ // that one comes from the server's enum and means nothing here. The
+ // comparison is the language's own, so ä sorts where the language puts it
+ const pickable = METRIC_TYPES.filter(isPickableMetricType).sort((a, b) =>
+ t(`measurements.metricTypes.${a}`).localeCompare(
+ t(`measurements.metricTypes.${b}`),
+ i18n.language,
+ )
+ );
+
return
- {METRIC_TYPES.filter(isPickableMetricType).map((metricType: MetricType) => {
+ {/* The free-form category first: it is the one every user can add,
+ * the typed ones below are each available only once */}
+ setIsCustom(true)}>
+
+
+
+
+
+
+ {pickable.map((metricType: MetricType) => {
const defaults = defaultsForMetricType(metricType);
return void }) => {
/>
;
})}
-
- setIsCustom(true)}>
-
-
-
-
- ;
};
diff --git a/src/components/Measurements/widgets/fab.tsx b/src/components/Measurements/widgets/fab.tsx
index 45d643cba..32f0bc513 100644
--- a/src/components/Measurements/widgets/fab.tsx
+++ b/src/components/Measurements/widgets/fab.tsx
@@ -26,7 +26,9 @@ export const AddMeasurementCategoryFab = ({ isLoading = false }: { isLoading?: b
{isLoading ? : }
-
+ {/* The picker turns into the category form, and that form grows
+ * with what is picked in it */}
+
diff --git a/src/components/Routines/screens/Detail/SlotProgressionEdit.tsx b/src/components/Routines/screens/Detail/SlotProgressionEdit.tsx
index aab46a635..7051ea0bf 100644
--- a/src/components/Routines/screens/Detail/SlotProgressionEdit.tsx
+++ b/src/components/Routines/screens/Detail/SlotProgressionEdit.tsx
@@ -1,11 +1,11 @@
-import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { WgerContainerFullWidth } from "@/core/ui/Widgets/Container";
import { getLanguageByShortName, Language, useLanguageQuery } from "@/components/Exercises";
import { Slot } from "@/components/Routines/models/Slot";
import { useRoutineDetailQuery } from "@/components/Routines/queries";
import { ProgressionForm } from "@/components/Routines/widgets/forms/ProgressionForm";
import { SlotEntryRoundingField } from "@/components/Routines/widgets/forms/SlotEntryForm";
import { makeLink, WgerLink } from "@/core/lib/url";
+import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
+import { WgerContainerFullWidth } from "@/core/ui/Widgets/Container";
import { Typography } from "@mui/material";
import Grid from '@mui/material/Grid';
import React from "react";
diff --git a/src/core/ui/Modals/WgerModal.tsx b/src/core/ui/Modals/WgerModal.tsx
index c8a318292..c45d4ab38 100644
--- a/src/core/ui/Modals/WgerModal.tsx
+++ b/src/core/ui/Modals/WgerModal.tsx
@@ -1,5 +1,5 @@
import CloseIcon from '@mui/icons-material/Close';
-import { Card, CardActions, CardContent, CardHeader, Modal } from "@mui/material";
+import { Card, CardContent, CardHeader, Modal } from "@mui/material";
import React, { FunctionComponent } from 'react';
export interface WgerModalProps {
@@ -7,18 +7,58 @@ export interface WgerModalProps {
subtitle?: string,
isOpen: boolean,
closeFn: () => void,
+ /**
+ * Keeps the card at one height instead of following its content. For a
+ * form whose fields depend on what the user picks: growing and shrinking
+ * moves the whole dialog, since it is centered on the screen.
+ */
+ stableHeight?: boolean,
children: React.ReactNode
}
-export const WgerModal: FunctionComponent = ({ title, subtitle, isOpen, closeFn, children }) => {
+export const WgerModal: FunctionComponent = ({
+ title,
+ subtitle,
+ isOpen,
+ closeFn,
+ stableHeight,
+ children
+ }) => {
+ // The card is positioned out of the flow, so without a bound it grows past
+ // the viewport in both directions and takes its own header with it. The
+ // margin it keeps is smaller on a phone, where 64px is a good part of the
+ // screen. Bounded here rather than in each modal: a long list is the
+ // normal case (the metric picker, the category order, a form).
+ const margin = { xs: '32px', sm: '64px' };
+ // Dynamic viewport units: with vh the browser's collapsing toolbar counts
+ // towards the height, so the card can reach past what is visible
+ const available = {
+ xs: `calc(100dvh - ${margin.xs})`,
+ sm: `calc(100dvh - ${margin.sm})`,
+ };
+
+ // No padding on the card itself, the header and the content have their own
const style = {
position: 'absolute' as const,
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
- p: 2,
- minWidth: '400px'
+ // A phone is narrower than the 400px a dialog reads well at, and a
+ // centered card wider than the screen is cut off on both sides
+ width: { xs: 'calc(100vw - 32px)', sm: 'auto' },
+ minWidth: { xs: 0, sm: '400px' },
+ maxHeight: available,
+ display: 'flex',
+ flexDirection: 'column',
+ ...(stableHeight
+ ? {
+ height: {
+ xs: `min(600px, ${available.xs})`,
+ sm: `min(600px, ${available.sm})`,
+ },
+ }
+ : {}),
};
return (
@@ -34,10 +74,11 @@ export const WgerModal: FunctionComponent = ({ title, subtitle,
subheader={subtitle}
action={}
/>
-
+ {/* Only the content scrolls, so the title and the close
+ * button stay reachable */}
+
{children}
-
From 03c54969f9e031d42a4cc1c8831b831abdf3c0db Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 20 Aug 2026 18:55:30 +0200
Subject: [PATCH 090/102] Allow disabling the average and the trend lines
---
public/locales/cs/translation.json | 2 +-
public/locales/de/translation.json | 2 +-
public/locales/en/translation.json | 2 +-
public/locales/es/translation.json | 12 +++----
public/locales/fr/translation.json | 6 ++--
public/locales/hr/translation.json | 2 +-
public/locales/hu/translation.json | 2 +-
public/locales/it/translation.json | 2 +-
public/locales/pt/translation.json | 2 +-
public/locales/pt_BR/translation.json | 2 +-
public/locales/pt_PT/translation.json | 2 +-
public/locales/sl/translation.json | 2 +-
public/locales/tr/translation.json | 2 +-
public/locales/zh_Hant/translation.json | 2 +-
.../Measurements/charts/data.test.ts | 20 ++++++++++++
src/components/Measurements/charts/data.ts | 16 ++++++++--
.../Measurements/models/Category.test.ts | 18 ++++++++---
.../Measurements/models/Category.ts | 32 +++++++++++++------
.../widgets/CategoryForm.test.tsx | 22 +++++++++++++
.../Measurements/widgets/CategoryForm.tsx | 12 +++++--
.../Measurements/widgets/MeasurementChart.tsx | 8 ++++-
.../widgets/IngredientAutocompleter.tsx | 2 +-
22 files changed, 129 insertions(+), 43 deletions(-)
diff --git a/public/locales/cs/translation.json b/public/locales/cs/translation.json
index 0dbf1ce8a..72ef55ddb 100644
--- a/public/locales/cs/translation.json
+++ b/public/locales/cs/translation.json
@@ -108,7 +108,6 @@
"filterVegan": "Vegan",
"filterVegetarian": "Vegetarián",
"filterNutriscore": "Nutri-Score filter",
- "filterNutriscoreOff": "Vypnout",
"filterNutriscoreNoFilter": "Žádný filtr",
"filterNutriscoreOrBetter": "{{grade}} nebo lepší"
},
@@ -129,6 +128,7 @@
"weight": "Váha",
"date": "Datum",
"timeOfDay": "Čas",
+ "off": "Vypnout",
"submit": "Potvrdit",
"edit": "Upravit",
"delete": "Odstranit",
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 8a6cd51e2..8d51fe1c5 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -117,6 +117,7 @@
"max_reps": "Max. Wdh.",
"plates": "Gewichtsscheiben"
},
+ "off": "Aus",
"submit": "Abschicken",
"weight": "Gewicht",
"syncedEntryInfo": "Dieser Eintrag wurde aus einer Health-App synchronisiert und kann nur dort geändert werden",
@@ -423,7 +424,6 @@
"filterVegan": "Vegan",
"filterVegetarian": "Vegetarisch",
"filterNutriscore": "Nährwertfilter",
- "filterNutriscoreOff": "Aus",
"filterNutriscoreNoFilter": "Kein Filter",
"filterNutriscoreOrBetter": "{{grade}} oder besser"
},
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 3573c32b7..5950a5536 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -14,6 +14,7 @@
"cm": "cm",
"date": "Date",
"timeOfDay": "Time of day",
+ "off": "Off",
"submit": "Submit",
"edit": "Edit",
"preview": "Preview",
@@ -153,7 +154,6 @@
"filterVegan": "Vegan",
"filterVegetarian": "Vegetarian",
"filterNutriscore": "Nutri-Score filter",
- "filterNutriscoreOff": "Off",
"filterNutriscoreNoFilter": "No filter",
"filterNutriscoreOrBetter": "{{grade}} or better",
"macronutrient": "Macronutrient",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 3bc76c263..c203f498e 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -9,6 +9,7 @@
"difference": "Diferencia",
"edit": "Editar",
"nutritionalPlan": "Plan nutricional",
+ "off": "Apagado",
"submit": "Enviar",
"weight": "Peso",
"syncedEntryInfo": "Esta entrada se sincronizó desde una aplicación de salud y solo se puede cambiar allí",
@@ -260,10 +261,10 @@
"sourceManual": "Introducido a mano",
"sourceCalculated": "Calculado por wger",
"type": "Cálculo",
- "locked": "El cálculo no se puede cambiar. Borre la categoría para dejar de calcularla.",
+ "locked": "El cálculo no se puede cambiar. Borra la categoría para dejar de calcularla.",
"paramsIncomplete": "El cálculo todavía no está configurado por completo",
- "missingHeight": "Falta la altura en su perfil, por lo que todavía no se pueden calcular valores.",
- "noSourceCategory": "Todavía no tiene otra categoría de la que leer.",
+ "missingHeight": "Falta la altura en tu perfil, por lo que todavía no se pueden calcular valores.",
+ "noSourceCategory": "Todavía no tienes otra categoría de la que leer.",
"badge": "Calculado",
"entryInfo": "Este valor lo calcula wger y no se puede editar",
"names": {
@@ -273,8 +274,8 @@
"ONE_RM_TOTAL": "Suma de 1RM"
},
"descriptions": {
- "BMI": "De su peso corporal y la altura de su perfil",
- "WHTR": "De {{category}} y la altura de su perfil"
+ "BMI": "De tu peso corporal y la altura de tu perfil",
+ "WHTR": "De {{category}} y la altura de tu perfil"
},
"params": {
"category_id": "Categoría de origen",
@@ -421,7 +422,6 @@
"filterVegan": "Vegana",
"filterVegetarian": "Vegetariana",
"filterNutriscore": "Filtro Nutri-Score",
- "filterNutriscoreOff": "Apagado",
"filterNutriscoreNoFilter": "Sin filtrado",
"filterNutriscoreOrBetter": "{{grade}} o mejor"
},
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 66ab570af..9376f7bcc 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -6,6 +6,7 @@
"edit": "Modifier",
"weight": "Poids",
"syncedEntryInfo": "Cette entrée a été synchronisée depuis une app de santé et ne peut être modifiée que là-bas",
+ "off": "Off",
"submit": "Envoyer",
"add": "Ajouter",
"close": "Fermer",
@@ -319,7 +320,6 @@
"languageFilterCurrentAndEnglish": "Langue courante ({{lang}}) & Anglais",
"filterVegan": "Végan",
"filterNutriscore": "Filtre Nutri-Score",
- "filterNutriscoreOff": "Off",
"filterNutriscoreNoFilter": "Pas de filtre",
"filterNutriscoreOrBetter": "{{grade}} ou mieux"
},
@@ -346,12 +346,12 @@
"locked": "Le calcul ne peut pas être modifié. Supprimez la catégorie pour arrêter de la calculer.",
"paramsIncomplete": "Le calcul n'est pas encore entièrement configuré",
"missingHeight": "La taille manque dans votre profil, aucune valeur ne peut donc encore être calculée.",
- "noSourceCategory": "Vous n'avez pas encore d'autre catégorie à lire.",
+ "noSourceCategory": "Vous n'avez pas encore d'autre catégorie dans laquelle puiser.",
"badge": "Calculé",
"entryInfo": "Cette valeur est calculée par wger et ne peut pas être modifiée",
"names": {
"BMI": "IMC",
- "WHTR": "Rapport tour de taille sur taille",
+ "WHTR": "Rapport taille-hauteur",
"ONE_REP_MAX": "1RM",
"ONE_RM_TOTAL": "Total 1RM"
},
diff --git a/public/locales/hr/translation.json b/public/locales/hr/translation.json
index fd7a350d3..2d49e94c4 100644
--- a/public/locales/hr/translation.json
+++ b/public/locales/hr/translation.json
@@ -113,6 +113,7 @@
"plates": "Ploče",
"max_reps": "Maks. broj ponavljanja"
},
+ "off": "Isključeno",
"submit": "Pošalji",
"weight": "Težina",
"workout": "Trening",
@@ -317,7 +318,6 @@
"filterVegan": "Veganski",
"filterVegetarian": "Vegetarijanski",
"filterNutriscore": "Nutri-Score filtar",
- "filterNutriscoreOff": "Isključeno",
"filterNutriscoreNoFilter": "Bez filtra",
"filterNutriscoreOrBetter": "{{grade}} ili bolje"
},
diff --git a/public/locales/hu/translation.json b/public/locales/hu/translation.json
index e27b3fa08..e232b9bf2 100644
--- a/public/locales/hu/translation.json
+++ b/public/locales/hu/translation.json
@@ -13,6 +13,7 @@
"cm": "cm",
"date": "Dátum",
"timeOfDay": "Napszak",
+ "off": "Ki",
"submit": "Beküldés",
"edit": "Módosítás",
"preview": "Előnézet",
@@ -152,7 +153,6 @@
"filterVegan": "Vegán",
"filterVegetarian": "Vegetáriánus",
"filterNutriscore": "Nutri-Score-szűrő",
- "filterNutriscoreOff": "Ki",
"filterNutriscoreNoFilter": "Nincs szűrés",
"filterNutriscoreOrBetter": "{{grade}} vagy jobb",
"macronutrient": "Makrotápanyag",
diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json
index 2a3926e41..47f19dd9b 100644
--- a/public/locales/it/translation.json
+++ b/public/locales/it/translation.json
@@ -111,6 +111,7 @@
"triceps": "Tricipiti",
"kilometers_per_hour": "Chilometri all'ora"
},
+ "off": "Disattivato",
"submit": "Invia",
"weight": "Peso",
"workout": "Allenamento",
@@ -188,7 +189,6 @@
"filterVegan": "Vegano",
"filterVegetarian": "Vegetariano",
"filterNutriscore": "Filtro Nutri-Score",
- "filterNutriscoreOff": "Disattivato",
"filterNutriscoreNoFilter": "Nessun filtro",
"filterNutriscoreOrBetter": "{{grade}} o meglio"
},
diff --git a/public/locales/pt/translation.json b/public/locales/pt/translation.json
index dcbeea81b..e35e97e9c 100644
--- a/public/locales/pt/translation.json
+++ b/public/locales/pt/translation.json
@@ -5,6 +5,7 @@
"delete": "Deletar",
"add": "Adicionar",
"weight": "Peso",
+ "off": "Desligado",
"submit": "Enviar",
"difference": "Diferença",
"close": "Fechar",
@@ -336,7 +337,6 @@
"languageFilterAll": "Todos os idiomas",
"filterVegan": "Vegano",
"filterNutriscore": "Filtro Nutri-Score",
- "filterNutriscoreOff": "Desligado",
"filterNutriscoreNoFilter": "Sem filtro",
"filterNutriscoreOrBetter": "{{grade}} ou melhor"
},
diff --git a/public/locales/pt_BR/translation.json b/public/locales/pt_BR/translation.json
index 0384e7569..faf3ef7db 100644
--- a/public/locales/pt_BR/translation.json
+++ b/public/locales/pt_BR/translation.json
@@ -250,6 +250,7 @@
"delete": "Excluir",
"weight": "Peso",
"date": "Data",
+ "off": "Desligado",
"submit": "Enviar",
"edit": "Editar",
"add": "Adicionar",
@@ -327,7 +328,6 @@
"filterVegan": "Vegano",
"filterVegetarian": "Vegetariano",
"filterNutriscore": "Filtro Nutri-Score",
- "filterNutriscoreOff": "Desligado",
"filterNutriscoreNoFilter": "Sem filtro",
"filterNutriscoreOrBetter": "{{grade}} ou melhor"
},
diff --git a/public/locales/pt_PT/translation.json b/public/locales/pt_PT/translation.json
index e5c953d1b..5f1470292 100644
--- a/public/locales/pt_PT/translation.json
+++ b/public/locales/pt_PT/translation.json
@@ -4,6 +4,7 @@
"cm": "cm",
"date": "Data",
"timeOfDay": "Hora do dia",
+ "off": "Desligado",
"submit": "Submeter",
"edit": "Editar",
"editName": "Editar {{name}}",
@@ -159,7 +160,6 @@
"filterVegan": "Vegano",
"filterVegetarian": "Vegetariano",
"filterNutriscore": "Filtro Nutri-Score",
- "filterNutriscoreOff": "Desligado",
"filterNutriscoreNoFilter": "Sem filtro",
"filterNutriscoreOrBetter": "{{grade}} ou melhor"
},
diff --git a/public/locales/sl/translation.json b/public/locales/sl/translation.json
index a896f855c..e62ec0ca0 100644
--- a/public/locales/sl/translation.json
+++ b/public/locales/sl/translation.json
@@ -5,6 +5,7 @@
"cm": "cm",
"date": "Datum",
"timeOfDay": "Čas dneva",
+ "off": "Izklopljeno",
"submit": "Pošlji",
"edit": "Uredi",
"editName": "Uredi {{name}}",
@@ -159,7 +160,6 @@
"filterVegan": "Vegansko",
"filterVegetarian": "Vegetarijansko",
"filterNutriscore": "Filter Nutri-Score",
- "filterNutriscoreOff": "Izklopljeno",
"filterNutriscoreNoFilter": "Brez filtra",
"filterNutriscoreOrBetter": "{{grade}} ali bolje"
},
diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json
index 1cd0a45b8..15147d7fe 100644
--- a/public/locales/tr/translation.json
+++ b/public/locales/tr/translation.json
@@ -114,6 +114,7 @@
"body_weight": "Vücut ağırlığı",
"lb": "paunt"
},
+ "off": "Kapalı",
"submit": "Gönder",
"weight": "Ağırlık",
"workout": "Antrenman",
@@ -205,7 +206,6 @@
"filterVegan": "Vegan",
"filterVegetarian": "Vejeteryan",
"filterNutriscore": "Nutri-Score filtresi",
- "filterNutriscoreOff": "Kapalı",
"filterNutriscoreNoFilter": "Filtresiz",
"filterNutriscoreOrBetter": "{{grade}} veya daha yüksek"
},
diff --git a/public/locales/zh_Hant/translation.json b/public/locales/zh_Hant/translation.json
index 99b2c8163..43fae24f0 100644
--- a/public/locales/zh_Hant/translation.json
+++ b/public/locales/zh_Hant/translation.json
@@ -2,6 +2,7 @@
"difference": "差異",
"close": "關閉",
"timeOfDay": "時間",
+ "off": "關閉",
"submit": "提交",
"deleteConfirmation": "你確定要刪除“{{name}}”嗎?",
"licenses": {
@@ -127,7 +128,6 @@
"languageFilterCurrentAndEnglish": "當前語言({{lang}}) 與英文",
"filterVegan": "純素主義",
"filterNutriscore": "依營養分數(Nutri-Score )篩選",
- "filterNutriscoreOff": "關閉",
"filterNutriscoreNoFilter": "不套用篩選"
},
"images": "圖片",
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 789c5d08a..cfdcb6115 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -17,6 +17,7 @@ import {
groupReadingPage,
groupReadings,
groupStackedEntries,
+ measurementSeries,
movingAverage,
niceBinWidth,
overallChange,
@@ -124,6 +125,25 @@ describe('movingAverage', () => {
});
});
+describe('measurementSeries', () => {
+ const points = [point(day(1), 10), point(day(2), 20), point(day(3), 30)];
+
+ test('draws the values, the average and the trend', () => {
+ const roles = measurementSeries(points).map(series => series.role);
+
+ expect(roles).toEqual(['raw', 'average', 'trend']);
+ });
+
+ test('leaves out the line the user turned off', () => {
+ expect(measurementSeries(points, null, { average_window: 'none' }).map(s => s.role))
+ .toEqual(['raw', 'trend']);
+ expect(measurementSeries(points, null, { trend: 'none' }).map(s => s.role))
+ .toEqual(['raw', 'average']);
+ expect(measurementSeries(points, null, { trend: 'none', average_window: 'none' })
+ .map(s => s.role)).toEqual(['raw']);
+ });
+});
+
describe('smoothedTrendline', () => {
test('returns an empty series unchanged', () => {
expect(smoothedTrendline([])).toEqual([]);
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index be802bdf0..712e6de7b 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -730,10 +730,16 @@ export const measurementSeries = (
cutoff: Date | null = null,
config: ChartConfig = {},
): ChartSeries[] => {
+ // Both lines can be turned off, then only the values are drawn
+ const window = averageWindowOf(config);
+ const period = trendPeriodOf(config);
+
// The average is computed over the full history and only then cut, so the
// first points of the range average the days before it instead of
// starting over at the cutoff
- const average = pointsSince(movingAverage(all, averageWindowOf(config)), cutoff);
+ const average = window === null
+ ? []
+ : pointsSince(movingAverage(all, window), cutoff);
const points = pointsSince(all, cutoff);
const condensed = downsample(points);
@@ -747,8 +753,12 @@ export const measurementSeries = (
return [
raw,
- { points: downsample(average), role: 'average' },
- { points: smoothedTrendline(condensed, trendPeriodOf(config)), role: 'trend' },
+ ...(window === null
+ ? []
+ : [{ points: downsample(average), role: 'average' } as ChartSeries]),
+ ...(period === null
+ ? []
+ : [{ points: smoothedTrendline(condensed, period), role: 'trend' } as ChartSeries]),
];
};
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 77226b1ff..883f9f97b 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -251,10 +251,20 @@ describe('MeasurementCategory', () => {
});
test('the trend character maps to the EMA period the chart uses', () => {
- expect(trendPeriodOf({ trend: 'reactive' }))
- .toBeLessThan(trendPeriodOf({ trend: 'balanced' }));
- expect(trendPeriodOf({ trend: 'sluggish' }))
- .toBeGreaterThan(trendPeriodOf({ trend: 'balanced' }));
+ expect(trendPeriodOf({ trend: 'reactive' })!)
+ .toBeLessThan(trendPeriodOf({ trend: 'balanced' })!);
+ expect(trendPeriodOf({ trend: 'sluggish' })!)
+ .toBeGreaterThan(trendPeriodOf({ trend: 'balanced' })!);
+ });
+
+ test('a line the user turned off has no period and no window', () => {
+ expect(trendPeriodOf({ trend: 'none' })).toBeNull();
+ expect(averageWindowOf({ average_window: 'none' })).toBeNull();
+ });
+
+ test('turning one line off leaves the other alone', () => {
+ expect(averageWindowOf({ trend: 'none', average_window: 14 })).toBe(14);
+ expect(trendPeriodOf({ trend: 'reactive', average_window: 'none' })).not.toBeNull();
});
test('a setting is changed without dropping the keys of another client', () => {
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index ed5718232..260b8524c 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -43,16 +43,19 @@ export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight';
export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap', 'delta', 'distribution'] as const;
export type ChartType = typeof CHART_TYPES[number];
+/** What both line settings take when the user turns their line off */
+export const CHART_LINE_OFF = 'none';
+
/**
* How closely the trend line follows the values, as the EMA period it maps to.
*
* Stored as the character rather than the number, so the periods stay tunable
* without touching what users configured.
*/
-export const TREND_CHARACTERS = ['reactive', 'balanced', 'sluggish'] as const;
+export const TREND_CHARACTERS = [CHART_LINE_OFF, 'reactive', 'balanced', 'sluggish'] as const;
export type TrendCharacter = typeof TREND_CHARACTERS[number];
-const TREND_EMA_PERIODS: Record = {
+const TREND_EMA_PERIODS: Record, number> = {
reactive: 5,
balanced: 10,
sluggish: 20,
@@ -64,7 +67,7 @@ export const AVERAGE_WINDOWS = [7, 14, 30];
/** Taste-level chart settings, see chart_config on the server */
export interface ChartConfig {
trend?: TrendCharacter;
- average_window?: number;
+ average_window?: number | typeof CHART_LINE_OFF;
/** Keys another client wrote, kept so a write from here does not drop them */
[key: string]: unknown;
@@ -77,19 +80,28 @@ export function trendOf(config: ChartConfig): TrendCharacter {
: 'balanced';
}
-/** The EMA period the trend line of this configuration is smoothed with */
-export function trendPeriodOf(config: ChartConfig): number {
- return TREND_EMA_PERIODS[trendOf(config)];
+/**
+ * The EMA period the trend line of this configuration is smoothed with, null
+ * for a chart that draws no trend line
+ */
+export function trendPeriodOf(config: ChartConfig): number | null {
+ const trend = trendOf(config);
+
+ return trend === CHART_LINE_OFF ? null : TREND_EMA_PERIODS[trend];
}
/**
- * Window the moving average covers, in days. Anything the picker does not
- * offer falls back to the first window, the same rule an unfitting chart type
- * follows.
+ * Window the moving average covers, in days, null for a chart that draws no
+ * average. Anything the picker does not offer falls back to the first window,
+ * the same rule an unfitting chart type follows.
*/
-export function averageWindowOf(config: ChartConfig): number {
+export function averageWindowOf(config: ChartConfig): number | null {
const window = config.average_window;
+ if (window === CHART_LINE_OFF) {
+ return null;
+ }
+
return typeof window === 'number' && AVERAGE_WINDOWS.includes(window)
? window
: AVERAGE_WINDOWS[0];
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 4ab401cf9..f3579e0f5 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -437,6 +437,28 @@ describe("Test the CategoryForm component", () => {
.toEqual({ goal_line: 75, trend: 'reactive' });
});
+ test('Both lines of the chart can be turned off', async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1);
+
+ // Act
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('combobox', { name: 'measurements.chartTrend' }));
+ await user.click(screen.getByRole('option', { name: 'off' }));
+ await user.click(screen.getByRole('combobox', { name: 'measurements.chartAverageWindow' }));
+ await user.click(screen.getByRole('option', { name: 'off' }));
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ expect(mutate.mock.calls[0][0].chartConfig)
+ .toEqual({ trend: 'none', average_window: 'none' });
+ });
+
test('A rename keeps a setting this release does not know', async () => {
// 'glacial' reads as the default here, and writing that default back
// would drop it. Only a setting the user changed is written.
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index 5d1c7f314..ed519fa1d 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -2,6 +2,7 @@ import {
availableChartTypes,
AVERAGE_WINDOWS,
averageWindowOf,
+ CHART_LINE_OFF,
ChartType,
isGroupMetricType,
MeasurementCategory,
@@ -129,7 +130,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
// What the two chart settings were seeded with, which is also what decides
// whether the user changed them
const seededTrend = trendOf(category?.chartConfig ?? {});
- const seededWindow = averageWindowOf(category?.chartConfig ?? {});
+ const seededWindow = averageWindowOf(category?.chartConfig ?? {}) ?? CHART_LINE_OFF;
const storedCalculation = category?.dynamicType ?? CALCULATION_NONE;
@@ -140,7 +141,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
metricType: MetricType;
chartType: ChartType;
trend: TrendCharacter;
- averageWindow: number;
+ averageWindow: number | typeof CHART_LINE_OFF;
parentId: string;
calculation: string;
params: Record;
@@ -394,7 +395,9 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
>
{TREND_CHARACTERS.map((trend: TrendCharacter) =>
)}
@@ -406,6 +409,9 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
disabled={!drawsLine(formik.values)}
{...formik.getFieldProps('averageWindow')}
>
+
{AVERAGE_WINDOWS.map(days =>