From f3116e6841c37702a3c1bf77336a46ac96e0f527 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 30 Jul 2026 17:23:44 +0200
Subject: [PATCH 01/71] Allow setting the meal when editing logs from the
overview
---
.../Components/CalendarComponent.test.tsx | 4 +-
.../Measurements/api/measurements.test.ts | 33 +++++++
.../Measurements/api/measurements.ts | 8 +-
src/components/Measurements/index.ts | 5 +-
.../Measurements/models/Category.ts | 16 +++-
.../screens/MeasurementCategoryDetail.tsx | 5 +-
src/components/Weight/api/weight.test.ts | 91 +++++++++++++++----
src/components/Weight/api/weight.ts | 61 +++++++++----
.../Weight/forms/WeightForm.test.tsx | 25 +++--
src/components/Weight/forms/WeightForm.tsx | 10 +-
src/components/Weight/models/WeightEntry.ts | 18 +++-
src/components/Weight/queries/index.ts | 36 +++++++-
.../Weight/screens/BodyWeight.test.tsx | 26 ++++--
.../Weight/widgets/Table/index.test.tsx | 19 ++--
src/components/Weight/widgets/Table/index.tsx | 5 +-
.../TableDashboard/TableDashboard.test.tsx | 4 +-
.../Weight/widgets/WeightChart/index.test.tsx | 16 ++--
src/core/lib/consts.ts | 1 +
src/tests/weight/testData.ts | 20 +++-
src/types.ts | 10 +-
20 files changed, 312 insertions(+), 101 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index 48a925082..25cc6f37e 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -3,9 +3,10 @@ import { WeightEntry } from "@/components/Weight";
import { getMeasurementCategories } from "@/components/Measurements/api/measurements";
import { getNutritionalDiaryEntries } from "@/components/Nutrition/api/nutritionalDiary";
import { getSessions } from "@/components/Routines/api/session";
-import { getWeights } from "@/components/Weight/api/weight";
+import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata";
import { testQueryClient } from "@/tests/queryClient";
+import { testBodyWeightCategory } from "@/tests/weight/testData";
import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { dateToYYYYMMDD } from "@/core/lib/date";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -38,6 +39,7 @@ describe('CalendarComponent', () => {
beforeEach(() => {
+ (getBodyWeightCategory as Mock).mockImplementation(() => Promise.resolve(testBodyWeightCategory));
(getWeights as Mock).mockImplementation(() => Promise.resolve([
new WeightEntry(
new Date(currentYear, currentMonth, 2, 12, 0),
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 9b13395c4..a49e3ccc0 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -100,6 +100,39 @@ describe('measurement service tests', () => {
]);
});
+ test('GET measurement categories hides the official body weight category', async () => {
+
+ (axios.get as Mock).mockImplementation((url: string) => {
+ if (url.includes("measurement-category")) {
+ return Promise.resolve({
+ data: {
+ count: 2,
+ next: null,
+ previous: null,
+ results: [
+ { id: CATEGORY_UUID, name: "Weight", unit: "kg" },
+ {
+ id: CATEGORY_UUID_2,
+ name: "Body weight",
+ unit: "kg",
+ metric_type: "body_weight",
+ is_official: true
+ },
+ ]
+ }
+ });
+ } else if (url.includes(`measurement/?category=${CATEGORY_UUID}`)) {
+ return Promise.resolve({ data: measurementEntryResponse });
+ }
+ });
+
+ const result = await getMeasurementCategories();
+
+ expect(result.map(c => c.id)).toStrictEqual([CATEGORY_UUID]);
+ // no entries are loaded for the hidden category
+ expect(axios.get).toHaveBeenCalledTimes(2);
+ });
+
test('GET measurement 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 f6346f0b2..8c2cfc16f 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { ApiMeasurementCategoryType } from '@/types';
import { API_MAX_PAGE_SIZE } from "@/core/lib/consts";
@@ -17,7 +17,7 @@ export type MeasurementQueryOptions = {
export const getMeasurementCategories = async (options?: MeasurementQueryOptions): Promise => {
const { filtersetQueryCategories = {}, filtersetQueryEntries = {} } = options || {};
- const categories: MeasurementCategory[] = [];
+ let categories: MeasurementCategory[] = [];
const categoryUrl = makeUrl(API_MEASUREMENTS_CATEGORY_PATH, {
query: {
limit: API_MAX_PAGE_SIZE,
@@ -31,6 +31,10 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
}
}
+ // The official body weight category is managed via the body weight screens,
+ // don't surface it between the regular measurement categories
+ categories = categories.filter(c => !(c.isOfficial && c.metricType === METRIC_TYPE_BODY_WEIGHT));
+
// Load entries for each category
const entryResponses = categories.map(async (category) => {
const out: MeasurementEntry[] = [];
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 20faaaba2..1eb4d8318 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -8,9 +8,12 @@ export { MeasurementCategoryDetail } from "./screens/MeasurementCategoryDetail";
export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOverview";
// Models
-export { MeasurementCategory } from "./models/Category";
+export { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "./models/Category";
export { MeasurementEntry } from "./models/Entry";
+// API endpoints
+export { API_MEASUREMENTS_CATEGORY_PATH, API_MEASUREMENTS_ENTRY_PATH } from "./api/measurements";
+
// Query hooks
export { useMeasurementsCategoryQuery } from "./queries";
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index ff92dd2d4..0f77ad943 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -1,6 +1,9 @@
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { Adapter } from "@/core/lib/Adapter";
+/** Server-side MetricType value marking a category as holding body weight data */
+export const METRIC_TYPE_BODY_WEIGHT = 'body_weight';
+
export class MeasurementCategory {
entries: MeasurementEntry[] = [];
@@ -9,7 +12,9 @@ export class MeasurementCategory {
public id: string | null,
public name: string,
public unit: string,
- entries?: MeasurementEntry[]
+ entries?: MeasurementEntry[],
+ public metricType: string = 'custom',
+ public isOfficial: boolean = false,
) {
if (entries) {
this.entries = entries;
@@ -22,6 +27,8 @@ export class MeasurementCategory {
overrides?.name ?? other.name,
overrides?.unit ?? other.unit,
other.entries,
+ other.metricType,
+ other.isOfficial,
);
}
@@ -42,7 +49,10 @@ class MeasurementCategoryAdapter implements Adapter {
return new MeasurementCategory(
item.id,
item.name,
- item.unit
+ item.unit,
+ undefined,
+ item.metric_type,
+ item.is_official,
);
}
@@ -55,4 +65,4 @@ class MeasurementCategoryAdapter implements Adapter {
}
}
-const adapter = new MeasurementCategoryAdapter();
\ No newline at end of file
+const adapter = new MeasurementCategoryAdapter();
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 14d32509f..5e35f0d1f 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -25,7 +25,10 @@ export const MeasurementCategoryDetail = () => {
return }
+ // official categories may neither be renamed nor deleted
+ optionsMenu={categoryQuery.data!.isOfficial
+ ? undefined
+ : }
mainContent={
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts
index 659109a07..4dc3c436d 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Weight/api/weight.test.ts
@@ -1,12 +1,43 @@
import axios from "axios";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { createWeight, deleteWeight, getWeights, updateWeight } from "./weight";
+import { createWeight, deleteWeight, getBodyWeightCategory, getWeights, updateWeight } from "./weight";
import type { Mock } from 'vitest';
vi.mock("axios");
+const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000042';
+const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001';
+const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000002';
+
describe("weight service tests", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test('GET the official body weight category', async () => {
+
+ const categoryResponse = {
+ count: 1,
+ next: null,
+ previous: null,
+ results: [
+ { id: CATEGORY_UUID, name: 'Body weight', unit: 'kg', metric_type: 'body_weight', is_official: true },
+ ]
+ };
+ (axios.get as Mock).mockImplementation(() => Promise.resolve({ data: categoryResponse }));
+
+ const result = await getBodyWeightCategory();
+
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining('metric_type=body_weight'),
+ expect.anything()
+ );
+ expect(result!.id).toBe(CATEGORY_UUID);
+ expect(result!.metricType).toBe('body_weight');
+ expect(result!.isOfficial).toBe(true);
+ });
+
test('GET weight entries', async () => {
const weightResponse = {
@@ -14,19 +45,23 @@ describe("weight service tests", () => {
next: null,
previous: null,
results: [
- { id: 1, weight: 80, date: '2021-12-10' },
- { id: 2, weight: 90, date: '2021-12-20' },
+ { id: ENTRY_UUID, category: CATEGORY_UUID, value: 80, date: '2021-12-10', notes: '' },
+ { id: ENTRY_UUID_2, category: CATEGORY_UUID, value: 90, date: '2021-12-20', notes: '' },
]
};
(axios.get as Mock).mockImplementation(() => Promise.resolve({ data: weightResponse }));
- const result = await getWeights();
- expect(axios.get).toHaveBeenCalledTimes(1);
+ const result = await getWeights(CATEGORY_UUID);
+ expect(axios.get).toHaveBeenCalledTimes(1);
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining(`category=${CATEGORY_UUID}`),
+ expect.anything()
+ );
expect(result).toStrictEqual([
- new WeightEntry(new Date('2021-12-10'), 80, 1),
- new WeightEntry(new Date('2021-12-20'), 90, 2),
+ new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID),
+ new WeightEntry(new Date('2021-12-20'), 90, ENTRY_UUID_2),
]);
});
@@ -36,18 +71,29 @@ describe("weight service tests", () => {
(axios.delete as Mock).mockImplementation(() => Promise.resolve({ status: 204 }));
// Act
- const result = await deleteWeight(1);
+ const result = await deleteWeight(ENTRY_UUID);
// Assert
- expect(axios.delete).toHaveBeenCalledTimes(1);
+ expect(axios.delete).toHaveBeenCalledWith(
+ expect.stringContaining(`measurement/${ENTRY_UUID}`),
+ expect.anything()
+ );
expect(result).toEqual(204);
});
test('PATCH weight entry', async () => {
// Arrange
- const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, 1);
- const weightResponse = { data: { id: 1, weight: 80, date: '2021-12-10' } };
+ const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID);
+ const weightResponse = {
+ data: {
+ id: ENTRY_UUID,
+ category: CATEGORY_UUID,
+ value: 80,
+ date: '2021-12-10',
+ notes: ''
+ }
+ };
// Act
(axios.patch as Mock).mockImplementation(() => Promise.resolve(weightResponse));
@@ -55,22 +101,35 @@ describe("weight service tests", () => {
// Assert
expect(axios.patch).toHaveBeenCalledTimes(1);
- expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, 1));
+ const [url, body] = (axios.patch as Mock).mock.calls[0];
+ expect(url).toContain(`measurement/${ENTRY_UUID}`);
+ expect(body).toMatchObject({ value: 80 });
+ expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID));
});
test('POST a new weight entry', async () => {
// Arrange
- const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, 1);
- const weightResponse = { data: { id: 1, weight: 80, date: '2021-12-10' } };
+ const weightEntry = new WeightEntry(new Date('2021-12-10'), 80);
+ const weightResponse = {
+ data: {
+ id: ENTRY_UUID,
+ category: CATEGORY_UUID,
+ value: 80,
+ date: '2021-12-10',
+ notes: ''
+ }
+ };
// Act
(axios.post as Mock).mockImplementation(() => Promise.resolve(weightResponse));
- const result = await createWeight(weightEntry);
+ const result = await createWeight(weightEntry, CATEGORY_UUID);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
- expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, 1));
+ const [, body] = (axios.post as Mock).mock.calls[0];
+ expect(body).toMatchObject({ category: CATEGORY_UUID, value: 80 });
+ expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID));
});
});
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index 9d4ca8056..4863fa7a7 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -1,34 +1,59 @@
+import {
+ API_MEASUREMENTS_CATEGORY_PATH,
+ API_MEASUREMENTS_ENTRY_PATH,
+ MeasurementCategory,
+ METRIC_TYPE_BODY_WEIGHT
+} from "@/components/Measurements";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import { ResponseType } from "@/core/api/responseType";
import { calculatePastDate } from '@/core/lib/date';
import { makeHeader, makeUrl } from "@/core/lib/url";
-import { ApiBodyWeightType } from '@/types';
+import { ApiMeasurementCategoryType, ApiMeasurementEntryType } from '@/types';
import axios from 'axios';
import { FilterType } from '../widgets/FilterButtons';
-export const WEIGHT_PATH = 'weightentry';
-
/*
- * Fetch weight entries based on filter value
+ * Fetch the user's official body weight category
+ *
+ * The server guarantees that every user has exactly one
*/
-export const getWeights = async (filter: FilterType = ''): Promise => {
+export const getBodyWeightCategory = async (): Promise => {
+ const url = makeUrl(API_MEASUREMENTS_CATEGORY_PATH, {
+ query: { metric_type: METRIC_TYPE_BODY_WEIGHT, is_official: 'true' }
+ });
+ const { data } = await axios.get>(url, {
+ headers: makeHeader(),
+ });
+ return MeasurementCategory.fromJson(data.results[0]);
+};
+/*
+ * Fetch weight entries based on filter value
+ */
+export const getWeights = async (categoryId: string, filter: FilterType = ''): Promise => {
const date__gte = calculatePastDate(filter);
-
- const url = makeUrl(WEIGHT_PATH, { query: { ordering: '-date', limit: 900, ...(date__gte && { date__gte }) } });
- const { data: receivedWeights } = await axios.get>(url, {
+ const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
+ query: {
+ category: categoryId,
+ ordering: '-date',
+ limit: 900,
+ ...(date__gte && { date__gte })
+ }
+ });
+ const { data } = await axios.get>(url, {
headers: makeHeader(),
});
- return receivedWeights.results.map(weight => WeightEntry.fromJson(weight));
+
+ return data.results.map(entry => WeightEntry.fromJson(entry));
};
/*
* Delete a weight entry
*/
-export const deleteWeight = async (id: number): Promise => {
- const response = await axios.delete(makeUrl(WEIGHT_PATH, { id: id }), {
+export const deleteWeight = async (id: string): Promise => {
+ const response = await axios.delete(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: id }), {
headers: makeHeader(),
});
@@ -39,7 +64,7 @@ export const deleteWeight = async (id: number): Promise => {
* Update a weight entry
*/
export const updateWeight = async (entry: WeightEntry): Promise => {
- const response = await axios.patch(makeUrl(WEIGHT_PATH, { id: entry.id }), entry.toJson(), {
+ const response = await axios.patch(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: entry.id }), entry.toJson(), {
headers: makeHeader(),
});
@@ -47,12 +72,14 @@ export const updateWeight = async (entry: WeightEntry): Promise =>
};
/*
- * Add a new weight entry
+ * Add a new weight entry to the official body weight category
*/
-export const createWeight = async (entry: WeightEntry): Promise => {
- const response = await axios.post(makeUrl(WEIGHT_PATH,), entry.toJson(), {
- headers: makeHeader(),
- });
+export const createWeight = async (entry: WeightEntry, categoryId: string): Promise => {
+ const response = await axios.post(
+ makeUrl(API_MEASUREMENTS_ENTRY_PATH),
+ { ...entry.toJson(), category: categoryId },
+ { headers: makeHeader() },
+ );
return WeightEntry.fromJson(response.data);
};
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx
index 26ec818dd..a848c5eab 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Weight/forms/WeightForm.test.tsx
@@ -3,19 +3,30 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import userEvent from "@testing-library/user-event";
import { WeightForm } from "@/components/Weight/forms/WeightForm";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { useAddWeightEntryQuery, useBodyWeightQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
+import {
+ useAddWeightEntryQuery,
+ useBodyWeightCategoryQuery,
+ useEditWeightEntryQuery
+} from "@/components/Weight/queries";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
-import { testWeightEntries } from "@/tests/weight/testData";
+import { testBodyWeightCategory } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
vi.mock("@/components/Weight/queries");
+const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001';
describe("Test WeightForm component", () => {
beforeEach(() => {
- (useBodyWeightQuery as Mock).mockImplementation(() => ({ isSuccess: true, data: testWeightEntries }));
+ (useBodyWeightCategoryQuery as Mock).mockImplementation(() => ({
+ isSuccess: true,
+ isLoading: false,
+ data: testBodyWeightCategory
+ }));
+ (useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
});
@@ -25,7 +36,7 @@ describe("Test WeightForm component", () => {
const weightEntry = new WeightEntry(
new Date('2021-12-10 17:00'),
80,
- 1,
+ ENTRY_UUID,
);
// Act
@@ -52,7 +63,7 @@ describe("Test WeightForm component", () => {
const weightEntry = new WeightEntry(
new Date('2022-02-28'),
80,
- 1
+ ENTRY_UUID
);
// Act
@@ -74,7 +85,7 @@ describe("Test WeightForm component", () => {
const submitted = mutateEditMock.mock.calls[0][0] as WeightEntry;
expect(submitted).not.toBe(weightEntry);
expect(Number(submitted.weight)).toBe(82);
- expect(submitted.id).toBe(1);
+ expect(submitted.id).toBe(ENTRY_UUID);
// ...and does not mutate the passed-in entry (it comes from the query cache)
expect(weightEntry.weight).toBe(80);
@@ -115,7 +126,7 @@ describe("Test WeightForm component", () => {
const user = userEvent.setup();
render(
-
+
);
const weightInput = await screen.findByLabelText('weight');
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index cda717563..5fcdcdb4d 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -2,7 +2,11 @@ import { Button, Stack, TextField } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { useAddWeightEntryQuery, useBodyWeightQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
+import {
+ useAddWeightEntryQuery,
+ useBodyWeightCategoryQuery,
+ useEditWeightEntryQuery
+} from "@/components/Weight/queries";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { Form, Formik } from "formik";
import { DateTime } from "luxon";
@@ -17,7 +21,7 @@ interface WeightFormProps {
export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
- const weightEntriesQuery = useBodyWeightQuery();
+ const categoryQuery = useBodyWeightCategoryQuery();
const addWeightQuery = useAddWeightEntryQuery();
const editWeightQuery = useEditWeightEntryQuery();
@@ -32,7 +36,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
.required('Weight field is required'),
});
- if (weightEntriesQuery.isLoading) {
+ if (categoryQuery.isLoading) {
return ;
}
diff --git a/src/components/Weight/models/WeightEntry.ts b/src/components/Weight/models/WeightEntry.ts
index a79b27c62..cf94d2b6a 100644
--- a/src/components/Weight/models/WeightEntry.ts
+++ b/src/components/Weight/models/WeightEntry.ts
@@ -1,19 +1,25 @@
import { Adapter } from "@/core/lib/Adapter";
+/**
+ * A body weight entry, stored on the server as a measurement in the user's
+ * official body weight category. The id is the measurement's UUID.
+ */
export class WeightEntry {
constructor(
public date: Date,
public weight: number,
- public id?: number,
+ public id?: string,
+ public notes: string = '',
) {
}
- static clone(other: WeightEntry, overrides?: Partial>): WeightEntry {
+ static clone(other: WeightEntry, overrides?: Partial>): WeightEntry {
return new WeightEntry(
overrides?.date ?? other.date,
overrides?.weight ?? other.weight,
overrides?.id ?? other.id,
+ overrides?.notes ?? other.notes,
);
}
@@ -32,17 +38,19 @@ class WeightAdapter implements Adapter {
fromJson(item: any): WeightEntry {
return new WeightEntry(
new Date(item.date),
- parseFloat(item.weight),
+ parseFloat(item.value),
item.id,
+ item.notes ?? '',
);
}
toJson(item: WeightEntry) {
return {
date: item.date.toISOString(),
- weight: item.weight,
+ value: item.weight,
+ notes: item.notes,
};
}
}
-const adapter = new WeightAdapter();
\ No newline at end of file
+const adapter = new WeightAdapter();
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index 44571b6ab..b353dbdfb 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -1,14 +1,37 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { createWeight, deleteWeight, getWeights, updateWeight } from "@/components/Weight/api/weight";
+import {
+ createWeight,
+ deleteWeight,
+ getBodyWeightCategory,
+ getWeights,
+ updateWeight
+} from "@/components/Weight/api/weight";
import { QueryKey, } from "@/core/lib/consts";
import { FilterType } from "../widgets/FilterButtons";
+/*
+ * The official body weight category basically never changes, resolve it once
+ * per session (ensureQueryData returns the cached result on later calls)
+ */
+const bodyWeightCategoryQueryOptions = {
+ queryKey: [QueryKey.BODY_WEIGHT_CATEGORY],
+ queryFn: getBodyWeightCategory,
+};
+
+export function useBodyWeightCategoryQuery() {
+ return useQuery(bodyWeightCategoryQueryOptions);
+}
export function useBodyWeightQuery(filter: FilterType = 'lastWeek') {
+ const queryClient = useQueryClient();
+
return useQuery({
queryKey: [QueryKey.BODY_WEIGHT, filter],
- queryFn: () => getWeights(filter),
+ queryFn: async () => {
+ const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
+ return getWeights(category.id!, filter);
+ },
});
}
@@ -16,7 +39,7 @@ export const useDeleteWeightEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (id: number) => deleteWeight(id),
+ mutationFn: (id: string) => deleteWeight(id),
onSuccess: () => queryClient.invalidateQueries({
queryKey: [QueryKey.BODY_WEIGHT]
})
@@ -28,7 +51,10 @@ export const useAddWeightEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (weightEntry: WeightEntry) => createWeight(weightEntry),
+ mutationFn: async (weightEntry: WeightEntry) => {
+ const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
+ return createWeight(weightEntry, category.id!);
+ },
onSuccess: () => queryClient.invalidateQueries({
queryKey: [QueryKey.BODY_WEIGHT,]
})
@@ -46,4 +72,4 @@ export const useEditWeightEntryQuery = () => {
});
}
});
-};
\ No newline at end of file
+};
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx
index 66a4accb5..6b27af6aa 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Weight/screens/BodyWeight.test.tsx
@@ -1,8 +1,9 @@
import { QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { getWeights } from "@/components/Weight/api/weight";
+import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { testQueryClient } from "@/tests/queryClient";
+import { testBodyWeightCategory, TEST_BODY_WEIGHT_CATEGORY_UUID } from "@/tests/weight/testData";
import { BodyWeight } from "./BodyWeight";
import { FilterType } from "../widgets/FilterButtons";
import type { Mock } from 'vitest';
@@ -12,6 +13,11 @@ console.log = vi.fn();
describe("Test BodyWeight component", () => {
+ beforeEach(() => {
+ testQueryClient.clear();
+ (getBodyWeightCategory as Mock).mockImplementation(() => Promise.resolve(testBodyWeightCategory));
+ });
+
// See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest
afterEach(() => {
vi.restoreAllMocks();
@@ -19,8 +25,8 @@ describe("Test BodyWeight component", () => {
// Arrange
const weightData = [
- new WeightEntry(new Date('2021-12-10'), 80, 1),
- new WeightEntry(new Date('2021-12-20'), 90, 2),
+ new WeightEntry(new Date('2021-12-10'), 80, 'dddddddd-dddd-dddd-dddd-000000000001'),
+ new WeightEntry(new Date('2021-12-20'), 90, 'dddddddd-dddd-dddd-dddd-000000000002'),
];
test('renders without crashing', async () => {
@@ -34,18 +40,16 @@ describe("Test BodyWeight component", () => {
);
- // Assert
- expect(getWeights).toHaveBeenCalledTimes(1);
-
- // Both weights are found in the document
+ // Assert - both weights are found in the document
expect(await screen.findByText("80")).toBeInTheDocument();
expect(await screen.findByText("90")).toBeInTheDocument();
+ expect(getWeights).toHaveBeenCalledWith(TEST_BODY_WEIGHT_CATEGORY_UUID, 'lastYear');
});
test('changes filter and updates displayed data', async () => {
// Mock the getWeights response based on the filter
- (getWeights as Mock).mockImplementation((filter: FilterType) => {
+ (getWeights as Mock).mockImplementation((categoryId: string, filter: FilterType) => {
if (filter === 'lastYear') {
return Promise.resolve(weightData);
} else if (filter === 'lastMonth') {
@@ -69,7 +73,9 @@ describe("Test BodyWeight component", () => {
fireEvent.click(filterButton);
// Expect getWeights to be called with 'lastMonth'
- expect(getWeights).toHaveBeenCalledWith('lastMonth');
+ await waitFor(() => {
+ expect(getWeights).toHaveBeenCalledWith(TEST_BODY_WEIGHT_CATEGORY_UUID, 'lastMonth');
+ });
// Check that entries for last year are no longer in the document
expect(screen.queryByText("80")).not.toBeInTheDocument();
diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx
index 9077f53ce..b9483256e 100644
--- a/src/components/Weight/widgets/Table/index.test.tsx
+++ b/src/components/Weight/widgets/Table/index.test.tsx
@@ -14,11 +14,15 @@ const renderTable = (weights: WeightEntry[]) =>
);
+const ENTRY_UUID_1 = 'dddddddd-dddd-dddd-dddd-000000000001';
+const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000002';
+const ENTRY_UUID_3 = 'dddddddd-dddd-dddd-dddd-000000000003';
+
describe("Body weight table", () => {
test('renders rows for all weight entries', async () => {
const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, 1),
- new WeightEntry(new Date('2021/12/20'), 90, 2),
+ new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1),
+ new WeightEntry(new Date('2021/12/20'), 90, ENTRY_UUID_2),
];
renderTable(weights);
@@ -29,19 +33,20 @@ describe("Body weight table", () => {
test('displays total change column correctly', async () => {
const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, 1),
- new WeightEntry(new Date('2021/12/20'), 90, 2),
- new WeightEntry(new Date('2021/12/25'), 85, 3),
+ new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1),
+ new WeightEntry(new Date('2021/12/20'), 90, ENTRY_UUID_2),
+ new WeightEntry(new Date('2021/12/25'), 85, ENTRY_UUID_3),
];
renderTable(weights);
await screen.findByText('80');
// DataGrid rows are sorted newest-first: 85 (total +5), 90 (+10), 80 (0)
+ const rowIds: Record = { '80': ENTRY_UUID_1, '90': ENTRY_UUID_2, '85': ENTRY_UUID_3 };
const expectedTotals: Record = { '85': '5', '90': '10', '80': '0' };
for (const [weight, totalChange] of Object.entries(expectedTotals)) {
- const row = document.querySelector(`[data-id="${weight === '80' ? 1 : weight === '90' ? 2 : 3}"]`) as HTMLElement;
+ const row = document.querySelector(`[data-id="${rowIds[weight]}"]`) as HTMLElement;
expect(row).not.toBeNull();
const cell = row.querySelector('[data-field="totalChange"]') as HTMLElement;
expect(cell.textContent).toBe(totalChange);
@@ -49,7 +54,7 @@ describe("Body weight table", () => {
});
test('shows inline edit and delete actions per row', async () => {
- const weights: WeightEntry[] = [new WeightEntry(new Date('2021/12/10'), 80, 1)];
+ const weights: WeightEntry[] = [new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1)];
renderTable(weights);
await screen.findByText('80');
diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx
index 53bce6263..156fb9ad9 100644
--- a/src/components/Weight/widgets/Table/index.tsx
+++ b/src/components/Weight/widgets/Table/index.tsx
@@ -61,7 +61,7 @@ export const WeightTable = ({ weights }: WeightTableProps) => {
};
const handleDeleteClick = (id: GridRowId) => () => {
- deleteEntryQuery.mutate(Number(id));
+ deleteEntryQuery.mutate(String(id));
};
const handleCancelClick = (id: GridRowId) => () => {
@@ -73,7 +73,8 @@ export const WeightTable = ({ weights }: WeightTableProps) => {
const processRowUpdate = (newRow: GridRowModel) => {
const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date);
- editEntryQuery.mutate(new WeightEntry(date, Number(newRow.weight), Number(newRow.id)));
+ const entry = weights.find(w => w.id === newRow.id)!;
+ editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight) }));
return newRow;
};
diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
index 36b227c58..d62e7da97 100644
--- a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
+++ b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
@@ -7,8 +7,8 @@ describe("Body weight test", () => {
test('renders without crashing', async () => {
const weightsData: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, 1),
- new WeightEntry(new Date('2021/12/20'), 90, 2),
+ new WeightEntry(new Date('2021/12/10'), 80, 'd-1'),
+ new WeightEntry(new Date('2021/12/20'), 90, 'd-2'),
];
// since I used context api to provide state, also need it here
diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Weight/widgets/WeightChart/index.test.tsx
index 1e1c41a27..911df9c28 100644
--- a/src/components/Weight/widgets/WeightChart/index.test.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.test.tsx
@@ -21,8 +21,8 @@ const renderChart = (weights: WeightEntry[], height?: number) =>
describe("WeightChart", () => {
test('mounts with weight data', () => {
renderChart([
- new WeightEntry(new Date('2021-12-10'), 80, 1),
- new WeightEntry(new Date('2021-12-20'), 90, 2),
+ new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
+ new WeightEntry(new Date('2021-12-20'), 90, 'd-2'),
]);
});
@@ -31,22 +31,22 @@ describe("WeightChart", () => {
});
test('mounts with a single entry', () => {
- renderChart([new WeightEntry(new Date('2021-12-10'), 80, 1)]);
+ renderChart([new WeightEntry(new Date('2021-12-10'), 80, 'd-1')]);
});
test('mounts with unsorted data', () => {
renderChart([
- new WeightEntry(new Date('2021-12-20'), 90, 2),
- new WeightEntry(new Date('2021-12-10'), 80, 1),
- new WeightEntry(new Date('2021-12-15'), 85, 3),
+ new WeightEntry(new Date('2021-12-20'), 90, 'd-2'),
+ new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
+ new WeightEntry(new Date('2021-12-15'), 85, 'd-3'),
]);
});
test('respects the height prop', () => {
renderChart(
[
- new WeightEntry(new Date('2021-12-10'), 80, 1),
- new WeightEntry(new Date('2021-12-20'), 85, 2),
+ new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
+ new WeightEntry(new Date('2021-12-20'), 85, 'd-2'),
],
500,
);
diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts
index f427982a0..dc3a914c0 100644
--- a/src/core/lib/consts.ts
+++ b/src/core/lib/consts.ts
@@ -46,6 +46,7 @@ export enum QueryKey {
// Body weight
BODY_WEIGHT = 'body-weight',
+ BODY_WEIGHT_CATEGORY = 'body-weight-category',
// Profile
PROFILE = 'profile',
diff --git a/src/tests/weight/testData.ts b/src/tests/weight/testData.ts
index 27dfdcdf4..3f812a474 100644
--- a/src/tests/weight/testData.ts
+++ b/src/tests/weight/testData.ts
@@ -1,7 +1,19 @@
+import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-export const testWeightEntry1 = new WeightEntry(new Date('2023-11-01'), 100, 1);
-export const testWeightEntry2 = new WeightEntry(new Date('2023-10-01'), 90, 2);
-export const testWeightEntry3 = new WeightEntry(new Date('2023-09-01'), 110, 3);
+export const TEST_BODY_WEIGHT_CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000042';
-export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3];
\ No newline at end of file
+export const testBodyWeightCategory = new MeasurementCategory(
+ TEST_BODY_WEIGHT_CATEGORY_UUID,
+ 'Body weight',
+ 'kg',
+ undefined,
+ METRIC_TYPE_BODY_WEIGHT,
+ true,
+);
+
+export const testWeightEntry1 = new WeightEntry(new Date('2023-11-01'), 100, 'dddddddd-dddd-dddd-dddd-000000000001');
+export const testWeightEntry2 = new WeightEntry(new Date('2023-10-01'), 90, 'dddddddd-dddd-dddd-dddd-000000000002');
+export const testWeightEntry3 = new WeightEntry(new Date('2023-09-01'), 110, 'dddddddd-dddd-dddd-dddd-000000000003');
+
+export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3];
diff --git a/src/types.ts b/src/types.ts
index a78dad30e..931ed39d0 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,11 +1,5 @@
import { ApiIngredientThumbnailType } from "@/components/Nutrition/models/IngredientImageThumbnails";
-export interface ApiBodyWeightType {
- id: number,
- date: string,
- weight: string,
-}
-
export interface ApiMuscleType {
id: number,
name: string,
@@ -50,7 +44,9 @@ export interface ApiAliasType {
export interface ApiMeasurementCategoryType {
id: string,
name: string,
- unit: string
+ unit: string,
+ metric_type: string,
+ is_official: boolean,
}
export const NUTRI_SCORES = ['a', 'b', 'c', 'd', 'e'] as const;
From 0d7eb3adda7be624ba7649aa8ce8528d4e44be36 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 30 Jul 2026 17:58:48 +0200
Subject: [PATCH 02/71] Save and use unit data from the measurement extra_data
field
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
.../Components/CalendarComponent.test.tsx | 5 ++-
.../Calendar/Components/Entries.test.tsx | 5 ++-
.../Calendar/Components/Entries.tsx | 4 +-
src/components/Dashboard/WeightCard.test.tsx | 3 +-
src/components/Dashboard/WeightCard.tsx | 14 ++++--
src/components/Measurements/models/Entry.ts | 12 +++++-
.../widgets/CategoryDetailDataGrid.tsx | 19 +++++++-
.../Nutrition/screens/BmiCalculator.test.tsx | 2 +-
.../Nutrition/screens/BmiCalculator.tsx | 10 ++---
src/components/Weight/api/weight.test.ts | 39 ++++++++++++-----
src/components/Weight/api/weight.ts | 14 +++---
.../Weight/forms/WeightForm.test.tsx | 2 +
src/components/Weight/forms/WeightForm.tsx | 43 +++++++++++++------
src/components/Weight/index.ts | 2 +-
.../Weight/models/WeightEntry.test.ts | 19 ++++++++
src/components/Weight/models/WeightEntry.ts | 29 +++++++++++--
src/components/Weight/queries/index.ts | 14 +++++-
.../Weight/screens/BodyWeight.test.tsx | 9 ++--
src/components/Weight/screens/BodyWeight.tsx | 7 +--
.../Weight/widgets/Table/index.test.tsx | 35 ++++++++++++++-
src/components/Weight/widgets/Table/index.tsx | 36 ++++++++++++----
.../TableDashboard/TableDashboard.test.tsx | 15 ++++++-
.../widgets/TableDashboard/TableDashboard.tsx | 8 ++--
.../Weight/widgets/WeightChart/index.test.tsx | 16 ++++++-
.../Weight/widgets/WeightChart/index.tsx | 22 +++++++---
src/core/lib/weightUnit.test.ts | 19 ++++++++
src/core/lib/weightUnit.ts | 17 ++++++++
src/types.ts | 4 +-
32 files changed, 347 insertions(+), 81 deletions(-)
create mode 100644 src/components/Weight/models/WeightEntry.test.ts
create mode 100644 src/core/lib/weightUnit.test.ts
create mode 100644 src/core/lib/weightUnit.ts
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index eccc9f3b9..7fc941c00 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -119,6 +119,7 @@
},
"submit": "Abschicken",
"weight": "Gewicht",
+ "syncedEntryInfo": "Dieser Eintrag wurde aus einer Health-App synchronisiert und kann nur dort geändert werden",
"workout": "Training",
"images": "Bilder",
"description": "Beschreibung",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 83bd027c2..c910cae33 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -9,6 +9,7 @@
"customize": "Customize"
},
"weight": "Weight",
+ "syncedEntryInfo": "This entry was synced from a health app and can only be changed there",
"height": "Height",
"cm": "cm",
"date": "Date",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 36780422f..b096b0244 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -11,6 +11,7 @@
"nutritionalPlan": "Plan nutricional",
"submit": "Enviar",
"weight": "Peso",
+ "syncedEntryInfo": "Esta entrada se ha sincronizado desde una aplicación de salud y solo puede modificarse allí",
"workout": "Entrenamiento",
"exercises": {
"secondaryMuscles": "Músculos secundarios",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 325d163b8..1d862e830 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -5,6 +5,7 @@
"days": "Jours",
"edit": "Modifier",
"weight": "Poids",
+ "syncedEntryInfo": "Cette entrée a été synchronisée depuis une application de santé et ne peut être modifiée que dans celle-ci",
"submit": "Envoyer",
"add": "Ajouter",
"close": "Fermer",
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index 25cc6f37e..f74eaf9c5 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -23,6 +23,9 @@ vi.mock("@/components/Measurements/api/measurements");
vi.mock("@/components/Nutrition/api/nutritionalDiary");
vi.mock("@/components/Routines/api/session");
vi.mock("@/components/Weight/api/weight");
+vi.mock('@/components/User/queries/profile', () => ({
+ useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }),
+}));
// TODO: using vi.useFakeTimers() and vi.setSystemTime(new Date('2024-12-01'));
@@ -144,6 +147,6 @@ describe('CalendarComponent', () => {
await user.click(day);
// Assert
- expect(screen.getByText('70.0')).toBeInTheDocument();
+ expect(screen.getByText('70.0 server.kg')).toBeInTheDocument();
});
});
\ No newline at end of file
diff --git a/src/components/Calendar/Components/Entries.test.tsx b/src/components/Calendar/Components/Entries.test.tsx
index eab6750e8..5b6032c12 100644
--- a/src/components/Calendar/Components/Entries.test.tsx
+++ b/src/components/Calendar/Components/Entries.test.tsx
@@ -6,6 +6,9 @@ import { dateToLocale } from "@/core/lib/date";
import { DayProps } from './CalendarComponent';
import Entries from './Entries';
+vi.mock('@/components/User/queries/profile', () => ({
+ useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }),
+}));
describe('Entries Component', () => {
const mockDate = new Date('2025-4-25');
@@ -39,7 +42,7 @@ describe('Entries Component', () => {
render( );
expect(screen.getByText('weight')).toBeInTheDocument();
- expect(screen.getByText('75.5')).toBeInTheDocument();
+ expect(screen.getByText('75.5 server.kg')).toBeInTheDocument();
});
test('Shows measurement directly, if theres only one entry', () => {
diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx
index 9b5b23497..352f95af4 100644
--- a/src/components/Calendar/Components/Entries.tsx
+++ b/src/components/Calendar/Components/Entries.tsx
@@ -12,6 +12,7 @@ import {
} from '@mui/material';
import React from 'react';
import { useTranslation } from "react-i18next";
+import { useDisplayWeightUnit } from "@/components/Weight";
import { dateToLocale } from "@/core/lib/date";
import { DayProps } from "./CalendarComponent";
@@ -22,6 +23,7 @@ interface LogProps {
const Entries: React.FC = ({ selectedDay, isStandalone }) => {
const [t] = useTranslation();
+ const displayWeightUnit = useDisplayWeightUnit();
const [openMeasurements, setOpenMeasurements] = React.useState(false);
const [openSession, setOpenSession] = React.useState(false);
@@ -65,7 +67,7 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => {
}
diff --git a/src/components/Dashboard/WeightCard.test.tsx b/src/components/Dashboard/WeightCard.test.tsx
index 2a4d95120..c972cb5de 100644
--- a/src/components/Dashboard/WeightCard.test.tsx
+++ b/src/components/Dashboard/WeightCard.test.tsx
@@ -1,6 +1,6 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
-import { useBodyWeightQuery } from "@/components/Weight";
+import { useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Weight";
import { WeightCard } from "@/components/Dashboard/WeightCard";
import { testQueryClient } from "@/tests/queryClient";
import { testWeightEntries } from "@/tests/weight/testData";
@@ -17,6 +17,7 @@ describe("test the WeightCard component", () => {
isLoading: false,
data: testWeightEntries
}));
+ (useDisplayWeightUnit as Mock).mockReturnValue('kg');
});
afterEach(() => {
diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx
index 00f81ce1e..d28b4f0bf 100644
--- a/src/components/Dashboard/WeightCard.tsx
+++ b/src/components/Dashboard/WeightCard.tsx
@@ -1,7 +1,14 @@
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
-import { useBodyWeightQuery, WeightChart, WeightEntry, WeightForm, WeightTableDashboard } from "@/components/Weight";
+import {
+ useBodyWeightQuery,
+ useDisplayWeightUnit,
+ WeightChart,
+ WeightEntry,
+ WeightForm,
+ WeightTableDashboard
+} from "@/components/Weight";
import { makeLink, WgerLink } from "@/core/lib/url";
import AddIcon from "@mui/icons-material/Add";
import { Box, Button, IconButton } from "@mui/material";
@@ -29,6 +36,7 @@ export const WeightCardContent = (props: { entries: WeightEntry[] }) => {
const handleOpenModal = () => setOpenModal(true);
const handleCloseModal = () => setOpenModal(false);
const [t, i18n] = useTranslation();
+ const displayUnit = useDisplayWeightUnit();
return (
<>
@@ -48,9 +56,9 @@ export const WeightCardContent = (props: { entries: WeightEntry[] }) => {
>
}
>
-
+
-
+
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index 01df0ec59..2f7532c5e 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -7,10 +7,16 @@ export class MeasurementEntry {
public category: string,
public date: Date,
public value: number,
- public notes: string
+ public notes: string,
+ public source: string = 'user',
) {
}
+ /** Entries synced from a health app are managed by the source app */
+ get isEditable(): boolean {
+ return this.source === 'user';
+ }
+
static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry {
return new MeasurementEntry(
overrides?.id ?? other.id,
@@ -18,6 +24,7 @@ export class MeasurementEntry {
overrides?.date ?? other.date,
overrides?.value ?? other.value,
overrides?.notes ?? other.notes,
+ other.source,
);
}
@@ -41,7 +48,8 @@ class MeasurementEntryAdapter implements Adapter {
// full ISO datetime from the server, parsing is timezone-safe
new Date(item.date),
item.value,
- item.notes
+ item.notes,
+ item.source,
);
}
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index c4ff776ab..877e47238 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -5,10 +5,11 @@ import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/comp
import { PAGINATION_OPTIONS } from "@/core/lib/consts";
import { luxonDateTimeToLocale } from "@/core/lib/date";
import CancelIcon from "@mui/icons-material/Close";
+import CloudSyncIcon from "@mui/icons-material/CloudSync";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save";
-import { Box } from "@mui/material";
+import { Box, Tooltip } from "@mui/material";
import {
DataGrid,
GridActionsCellItem,
@@ -31,6 +32,7 @@ const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp =>
date: row.entry.date,
value: row.entry.value,
notes: row.entry.notes,
+ isEditable: row.entry.isEditable,
change: +row.change.toFixed(2),
totalChange: +row.totalChange.toFixed(2),
days: +row.days.toFixed(1),
@@ -154,7 +156,19 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
headerName: t('actions'),
width: 100,
cellClassName: 'actions',
- getActions: ({ id }) => {
+ getActions: ({ id, row }) => {
+ // synced entries are managed by the source app, offer no actions
+ if (!row.isEditable) {
+ return [
+ }
+ label={t('syncedEntryInfo')}
+ color="inherit"
+ />,
+ ];
+ }
+
const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
if (isInEditMode) {
@@ -212,6 +226,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
}}
pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions}
disableRowSelectionOnClick
+ isCellEditable={(params) => params.row.isEditable}
rowModesModel={rowModesModel}
onRowModesModelChange={handleRowModesModelChange}
onRowEditStop={handleRowEditStop}
diff --git a/src/components/Nutrition/screens/BmiCalculator.test.tsx b/src/components/Nutrition/screens/BmiCalculator.test.tsx
index 222fe9159..11df2c0e5 100644
--- a/src/components/Nutrition/screens/BmiCalculator.test.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.test.tsx
@@ -8,7 +8,7 @@ import { testQueryClient } from "@/tests/queryClient";
vi.mock('@/components/Weight/queries', () => ({
useBodyWeightQuery: () => ({
isLoading: false,
- data: [{ weight: 55, date: new Date() }], // Provide mock weight data
+ data: [{ weight: 55, unit: 'kg', valueIn: () => 55, date: new Date() }], // Provide mock weight data
}),
}));
diff --git a/src/components/Nutrition/screens/BmiCalculator.tsx b/src/components/Nutrition/screens/BmiCalculator.tsx
index 7dfab7182..4f37062b0 100644
--- a/src/components/Nutrition/screens/BmiCalculator.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.tsx
@@ -29,16 +29,12 @@ export const BmiCalculator = () => {
const [height, setHeight] = useState();
const [weight, setWeight] = useState();
- // Set default weight from last weight entry
+ // Set default weight from last weight entry, the BMI is always computed in kg
useEffect(() => {
if (weightQuery.data && weightQuery.data.length > 0) {
- const lastWeightEntry = weightQuery.data[0];
- const weightInKg = profileQuery.data?.useMetric
- ? lastWeightEntry.weight
- : lastWeightEntry.weight * 0.453592; // Convert lb to kg
- setWeight(weightInKg);
+ setWeight(weightQuery.data[0].valueIn('kg'));
}
- }, [weightQuery.data, profileQuery.data]);
+ }, [weightQuery.data]);
useEffect(() => {
if (profileQuery.data?.height) {
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts
index 4dc3c436d..ed506823e 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Weight/api/weight.test.ts
@@ -1,5 +1,6 @@
import axios from "axios";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { testBodyWeightCategory } from "@/tests/weight/testData";
import { createWeight, deleteWeight, getBodyWeightCategory, getWeights, updateWeight } from "./weight";
import type { Mock } from 'vitest';
@@ -40,19 +41,36 @@ describe("weight service tests", () => {
test('GET weight entries', async () => {
+ // one entry carries its own unit, one falls back to the category unit
const weightResponse = {
count: 2,
next: null,
previous: null,
results: [
- { id: ENTRY_UUID, category: CATEGORY_UUID, value: 80, date: '2021-12-10', notes: '' },
- { id: ENTRY_UUID_2, category: CATEGORY_UUID, value: 90, date: '2021-12-20', notes: '' },
+ {
+ id: ENTRY_UUID,
+ category: CATEGORY_UUID,
+ value: 80,
+ date: '2021-12-10',
+ notes: '',
+ source: 'user',
+ extra_data: {}
+ },
+ {
+ id: ENTRY_UUID_2,
+ category: CATEGORY_UUID,
+ value: 90,
+ date: '2021-12-20',
+ notes: '',
+ source: 'apple',
+ extra_data: { unit: 'lb' }
+ },
]
};
(axios.get as Mock).mockImplementation(() => Promise.resolve({ data: weightResponse }));
- const result = await getWeights(CATEGORY_UUID);
+ const result = await getWeights(testBodyWeightCategory);
expect(axios.get).toHaveBeenCalledTimes(1);
expect(axios.get).toHaveBeenCalledWith(
@@ -60,8 +78,8 @@ describe("weight service tests", () => {
expect.anything()
);
expect(result).toStrictEqual([
- new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID),
- new WeightEntry(new Date('2021-12-20'), 90, ENTRY_UUID_2),
+ new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID, '', 'kg', 'user'),
+ new WeightEntry(new Date('2021-12-20'), 90, ENTRY_UUID_2, '', 'lb', 'apple'),
]);
});
@@ -103,21 +121,22 @@ describe("weight service tests", () => {
expect(axios.patch).toHaveBeenCalledTimes(1);
const [url, body] = (axios.patch as Mock).mock.calls[0];
expect(url).toContain(`measurement/${ENTRY_UUID}`);
- expect(body).toMatchObject({ value: 80 });
+ expect(body).toMatchObject({ value: 80, extra_data: { unit: 'kg' } });
expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID));
});
test('POST a new weight entry', async () => {
// Arrange
- const weightEntry = new WeightEntry(new Date('2021-12-10'), 80);
+ const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, undefined, '', 'lb');
const weightResponse = {
data: {
id: ENTRY_UUID,
category: CATEGORY_UUID,
value: 80,
date: '2021-12-10',
- notes: ''
+ notes: '',
+ extra_data: { unit: 'lb' }
}
};
@@ -128,8 +147,8 @@ describe("weight service tests", () => {
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body] = (axios.post as Mock).mock.calls[0];
- expect(body).toMatchObject({ category: CATEGORY_UUID, value: 80 });
- expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID));
+ expect(body).toMatchObject({ category: CATEGORY_UUID, value: 80, extra_data: { unit: 'lb' } });
+ expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID, '', 'lb'));
});
});
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index 4863fa7a7..1ca244273 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -7,6 +7,7 @@ import {
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import { ResponseType } from "@/core/api/responseType";
import { calculatePastDate } from '@/core/lib/date';
+import { WeightUnit } from "@/core/lib/weightUnit";
import { makeHeader, makeUrl } from "@/core/lib/url";
import { ApiMeasurementCategoryType, ApiMeasurementEntryType } from '@/types';
import axios from 'axios';
@@ -30,13 +31,15 @@ export const getBodyWeightCategory = async (): Promise => {
/*
* Fetch weight entries based on filter value
+ *
+ * Entries without their own unit in extra_data fall back to the category unit
*/
-export const getWeights = async (categoryId: string, filter: FilterType = ''): Promise => {
+export const getWeights = async (category: MeasurementCategory, filter: FilterType = ''): Promise => {
const date__gte = calculatePastDate(filter);
const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
query: {
- category: categoryId,
+ category: category.id!,
ordering: '-date',
limit: 900,
...(date__gte && { date__gte })
@@ -46,7 +49,8 @@ export const getWeights = async (categoryId: string, filter: FilterType = ''): P
headers: makeHeader(),
});
- return data.results.map(entry => WeightEntry.fromJson(entry));
+ const fallbackUnit: WeightUnit = category.unit === 'lb' ? 'lb' : 'kg';
+ return data.results.map(entry => WeightEntry.fromJson(entry, fallbackUnit));
};
/*
@@ -68,7 +72,7 @@ export const updateWeight = async (entry: WeightEntry): Promise =>
headers: makeHeader(),
});
- return WeightEntry.fromJson(response.data);
+ return WeightEntry.fromJson(response.data, entry.unit);
};
/*
@@ -81,5 +85,5 @@ export const createWeight = async (entry: WeightEntry, categoryId: string): Prom
{ headers: makeHeader() },
);
- return WeightEntry.fromJson(response.data);
+ return WeightEntry.fromJson(response.data, entry.unit);
};
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx
index a848c5eab..24a174fea 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Weight/forms/WeightForm.test.tsx
@@ -6,6 +6,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import {
useAddWeightEntryQuery,
useBodyWeightCategoryQuery,
+ useDisplayWeightUnit,
useEditWeightEntryQuery
} from "@/components/Weight/queries";
import React from 'react';
@@ -27,6 +28,7 @@ describe("Test WeightForm component", () => {
}));
(useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
(useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useDisplayWeightUnit as Mock).mockReturnValue('kg');
});
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index 5fcdcdb4d..44a6f2132 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -1,12 +1,14 @@
-import { Button, Stack, TextField } from "@mui/material";
+import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import {
useAddWeightEntryQuery,
useBodyWeightCategoryQuery,
+ useDisplayWeightUnit,
useEditWeightEntryQuery
} from "@/components/Weight/queries";
+import { WeightUnit } from "@/core/lib/weightUnit";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { Form, Formik } from "formik";
import { DateTime } from "luxon";
@@ -24,6 +26,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
const categoryQuery = useBodyWeightCategoryQuery();
const addWeightQuery = useAddWeightEntryQuery();
const editWeightQuery = useEditWeightEntryQuery();
+ const displayUnit = useDisplayWeightUnit();
const [dateValue, setDateValue] = useState(weightEntry ? DateTime.fromJSDate(weightEntry.date) : DateTime.now);
const [t, i18n] = useTranslation();
@@ -43,7 +46,9 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
return (
( {
if (weightEntry) {
editWeightQuery.mutate(WeightEntry.clone(
weightEntry,
- { weight: values.weight, date: values.date }
+ { weight: values.weight, date: values.date, unit: values.unit }
));
// Create a new weight entry
} else {
- addWeightQuery.mutate(new WeightEntry(values.date, values.weight));
+ addWeightQuery.mutate(new WeightEntry(values.date, values.weight, undefined, '', values.unit));
}
if (closeFn) {
@@ -69,15 +74,29 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
{formik => (
)
);
+};
+
+interface GroupEntryFormProps {
+ group: MeasurementCategory,
+ closeFn?: () => void,
+}
+
+/**
+ * Adds one reading for every component of a multi-value group (e.g. systolic
+ * and diastolic blood pressure): date and time are shared, one value field
+ * per child category
+ */
+export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
+
+ const [t, i18n] = useTranslation();
+ const addGroupEntriesQuery = useAddGroupEntriesQuery();
+
+ const [dateValue, setDateValue] = React.useState(DateTime.now());
+
+ const validationSchema = yup.object({
+ date: yup
+ .date()
+ .required(t('forms.fieldRequired')),
+ values: yup.object(Object.fromEntries(group.children.map(child => [
+ child.id!,
+ yup
+ .number()
+ .required(t('forms.fieldRequired'))
+ .min(0, t('forms.minValue', { value: '0' }))
+ .max(1000, t('forms.maxValue', { value: '1000' })),
+ ]))),
+ });
+
+ return (
+ ( [child.id!, ''])),
+ }}
+ validationSchema={validationSchema}
+ onSubmit={async (values) => {
+ addGroupEntriesQuery.mutate(group.children.map(child => new MeasurementEntry(
+ null,
+ child.id!,
+ values.date,
+ Number(values.values[child.id!]),
+ '',
+ )));
+
+ if (closeFn) {
+ closeFn();
+ }
+ }}
+ >
+ {formik => (
+
+ )}
+ )
+ );
};
\ No newline at end of file
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 6dba77210..76bbb3672 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -34,6 +34,22 @@ describe('MeasurementChart', () => {
render( );
render( );
});
+
+ test('mounts a combined chart for a group', () => {
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
+ const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', [], 'blood_pressure', false, 'g-1');
+ systolic.entries = [
+ new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''),
+ new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''),
+ ];
+ const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', [], 'blood_pressure', false, 'g-1');
+ diastolic.entries = [
+ new MeasurementEntry('d-3', 'c-dia', new Date(2023, 1, 1, 8), 80, ''),
+ ];
+ group.children = [systolic, diastolic];
+
+ render( );
+ });
});
describe('aggregatePerDay', () => {
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index f3febdbba..8089e4f79 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -3,8 +3,9 @@ import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/m
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import React from "react";
import { useTranslation } from "react-i18next";
-import { Bar, BarChart, CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
+import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
import { theme } from "@/theme";
+import { generateChartColors } from "@/core/lib/colors";
import { dateToLocale } from "@/core/lib/date";
import { calculateEMA } from "@/core/lib/ema";
@@ -89,7 +90,7 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
dataKey="date"
tickFormatter={timeStr => dateToLocale(new Date(timeStr))!}
/>
-
+
)} />
{
tickFormatter={timeStr => dateToLocale(new Date(timeStr))!}
tickCount={10}
/>
-
+
{ )} />}
;
};
+/**
+ * Renders all components of a multi-value group (e.g. systolic and diastolic
+ * blood pressure) as series of one combined chart
+ */
+const MeasurementGroupChart = (props: { category: MeasurementCategory }) => {
+ const colorGenerator = generateChartColors(props.category.children.length);
+
+ return
+
+
+ dateToLocale(new Date(timeStr))!}
+ tickCount={10}
+ />
+
+ dateToLocale(new Date(label as number))!}
+ formatter={(value, name, item) =>
+ `${value} ${item.payload.unit || props.category.unit}`}
+ />
+
+ {props.category.children.map(child => {
+ const data = [...child.entries]
+ .sort((a, b) => a.date.getTime() - b.date.getTime())
+ .map(entry => ({
+ date: entry.date.getTime(),
+ value: entry.value,
+ unit: child.unit,
+ }));
+
+ return ;
+ })}
+
+ ;
+};
+
export const MeasurementChart = (props: { category: MeasurementCategory }) => {
+ if (props.category.isGroup) {
+ return ;
+ }
+
return isSummedPerDay(props.category.metricType)
?
: ;
diff --git a/src/components/Measurements/widgets/fab.tsx b/src/components/Measurements/widgets/fab.tsx
index 4f6f04494..4c498df42 100644
--- a/src/components/Measurements/widgets/fab.tsx
+++ b/src/components/Measurements/widgets/fab.tsx
@@ -3,9 +3,9 @@ import { Fab } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
-import { EntryForm } from "@/components/Measurements/widgets/EntryForm";
-import { useParams } from "react-router-dom";
+import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/EntryForm";
export const AddMeasurementCategoryFab = () => {
const [t] = useTranslation();
@@ -35,15 +35,12 @@ export const AddMeasurementCategoryFab = () => {
);
};
-export const AddMeasurementEntryFab = () => {
+export const AddMeasurementEntryFab = ({ category }: { category: MeasurementCategory }) => {
const [t] = useTranslation();
const [openModal, setOpenModal] = React.useState(false);
const handleOpenModal = () => setOpenModal(true);
const handleCloseModal = () => setOpenModal(false);
- const params = useParams<{ categoryId: string }>();
- const categoryId = params.categoryId!;
-
return (<>
{
-
+ {category.isGroup
+ ?
+ : }
>);
};
\ No newline at end of file
From f290735714a8b16a850293582dd3af13096c4237 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 31 Jul 2026 12:21:54 +0200
Subject: [PATCH 14/71] olish the group display on the dashboard and delete
dialog
This with the last commits closes #1265
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 3 +-
public/locales/fr/translation.json | 3 +-
.../Dashboard/MeasurementCard.test.tsx | 43 ++++++++++++++++++-
src/components/Dashboard/MeasurementCard.tsx | 27 +++++++++---
.../widgets/CategoryDetailDropdown.tsx | 2 +-
7 files changed, 69 insertions(+), 11 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 2011c54a4..03f9f80c2 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -251,6 +251,7 @@
"success": "Geschafft!",
"measurements": {
"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",
"unitFormHelpText": "Die Einheit, in der die Kategorie gemessen wird, wie cm oder %",
"measurements": "Messungen",
"metricType": "Metrik-Typ",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index ead6986df..32a61c5c4 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -337,6 +337,7 @@
"measurements": "Measurements",
"unitFormHelpText": "The unit in which the category will be measured, such as cm or %",
"deleteInfo": "This will delete the category as well as all its entries",
+ "deleteInfoGroup": "This will delete the group as well as all its components and their entries",
"metricType": "Metric Type",
"partOfGroup": "Part of group",
"noGroup": "No group",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index e125d4790..8fab6e1b6 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -11,7 +11,7 @@
"nutritionalPlan": "Plan nutricional",
"submit": "Enviar",
"weight": "Peso",
- "syncedEntryInfo": "Esta entrada se ha sincronizado desde una aplicación de salud y solo puede modificarse allí",
+ "syncedEntryInfo": "Esta entrada se sincronizó desde una aplicación de salud y solo se puede cambiar allí",
"workout": "Entrenamiento",
"exercises": {
"secondaryMuscles": "Músculos secundarios",
@@ -255,6 +255,7 @@
"measurements": "Mediciones",
"unitFormHelpText": "La unidad en la que se medirá la categoría, como cm o %",
"deleteInfo": "Esto eliminará la categoría así como todas sus entradas",
+ "deleteInfoGroup": "Esto eliminará el grupo así como todos sus componentes y sus entradas",
"metricType": "Tipo de métrica",
"partOfGroup": "Parte del grupo",
"noGroup": "Sin grupo",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 45af4c0e8..771382f4e 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -5,7 +5,7 @@
"days": "Jours",
"edit": "Modifier",
"weight": "Poids",
- "syncedEntryInfo": "Cette entrée a été synchronisée depuis une application de santé et ne peut être modifiée que dans celle-ci",
+ "syncedEntryInfo": "Cette entrée a été synchronisée depuis une app de santé et ne peut être modifiée que là-bas",
"submit": "Envoyer",
"add": "Ajouter",
"close": "Fermer",
@@ -338,6 +338,7 @@
"measurements": "Mesures",
"unitFormHelpText": "L'unité dans laquelle la catégorie sera mesurée, telle que cm ou %",
"deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées",
+ "deleteInfoGroup": "Ceci supprimera le groupe ainsi que tous ses composants et leurs entrées",
"metricType": "Type de métrique",
"partOfGroup": "Fait partie du groupe",
"noGroup": "Aucun groupe",
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index 7b2252b39..5d7795726 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -1,7 +1,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
import { MeasurementCard } from "@/components/Dashboard/MeasurementCard";
-import { useMeasurementsCategoryQuery } from "@/components/Measurements";
+import { MeasurementCategory, useMeasurementsCategoryQuery } from "@/components/Measurements";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
@@ -45,6 +46,46 @@ describe("smoke test the MeasurementCard component", () => {
});
+ describe("Multi-value group", () => {
+
+ beforeEach(() => {
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
+ const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1');
+ systolic.entries = [
+ // sorted by date descending, like the server delivers them
+ new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''),
+ new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''),
+ ];
+ const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1');
+ group.children = [systolic, diastolic];
+
+ (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
+ isSuccess: true,
+ isLoading: false,
+ data: [group]
+ }));
+ });
+
+ test('lists the latest reading of each component', async () => {
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.getAllByText('Blood pressure').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Systolic').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('125 mmHg').length).toBeGreaterThan(0);
+ // no reading yet for the diastolic component
+ expect(screen.getAllByText('—').length).toBeGreaterThan(0);
+ expect(screen.queryByText('120 mmHg')).toBeNull();
+ });
+ });
+
+
describe("No data available", () => {
beforeEach(() => {
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index 47ff10a85..6eef1c245 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -92,17 +92,30 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
- {t('date')}
+ {props.category.isGroup ? t('name') : t('date')}
{t('value')}
- {[...props.category.entries].slice(0, 5).map(entry => (
-
- {entry.date.toLocaleDateString()}
- {entry.value} {props.category.unit}
-
- ))}
+ {props.category.isGroup
+ // group parents hold no entries themselves, list the
+ // latest reading of each component instead
+ ? props.category.children.map(child => {
+ // entries arrive sorted by date descending
+ const latest = child.entries[0];
+ return
+ {child.name}
+
+ {latest !== undefined ? `${latest.value} ${child.unit || props.category.unit}` : '—'}
+
+ ;
+ })
+ : [...props.category.entries].slice(0, 5).map(entry => (
+
+ {entry.date.toLocaleDateString()}
+ {entry.value} {props.category.unit}
+
+ ))}
>);
diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
index 7ed1cb5a4..5792f712d 100644
--- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
@@ -75,7 +75,7 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory })
Date: Fri, 31 Jul 2026 13:09:11 +0200
Subject: [PATCH 15/71] Add drag-and-drop reordering for measurement categories
Closes #1276
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
.../Measurements/api/measurements.ts | 8 +++
src/components/Measurements/queries/index.ts | 17 ++++-
.../MeasurementCategoryOverview.test.tsx | 28 +++++++-
.../screens/MeasurementCategoryOverview.tsx | 38 ++++++++---
.../widgets/CategoryReorderList.test.tsx | 65 +++++++++++++++++++
.../widgets/CategoryReorderList.tsx | 59 +++++++++++++++++
10 files changed, 207 insertions(+), 12 deletions(-)
create mode 100644 src/components/Measurements/widgets/CategoryReorderList.test.tsx
create mode 100644 src/components/Measurements/widgets/CategoryReorderList.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 03f9f80c2..7afc34202 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -250,6 +250,7 @@
"preferences": "Voreinstellungen",
"success": "Geschafft!",
"measurements": {
+ "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",
"unitFormHelpText": "Die Einheit, in der die Kategorie gemessen wird, wie cm oder %",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 32a61c5c4..164f25bc6 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -335,6 +335,7 @@
},
"measurements": {
"measurements": "Measurements",
+ "reorderCategories": "Reorder categories",
"unitFormHelpText": "The unit in which the category will be measured, such as cm or %",
"deleteInfo": "This will delete the category as well as all its entries",
"deleteInfoGroup": "This will delete the group as well as all its components and their entries",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 8fab6e1b6..f0d9dffc0 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -253,6 +253,7 @@
"seeDetails": "Ver los detalles",
"measurements": {
"measurements": "Mediciones",
+ "reorderCategories": "Reordenar categorías",
"unitFormHelpText": "La unidad en la que se medirá la categoría, como cm o %",
"deleteInfo": "Esto eliminará la categoría así como todas sus entradas",
"deleteInfoGroup": "Esto eliminará el grupo así como todos sus componentes y sus entradas",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 771382f4e..37bf51458 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -336,6 +336,7 @@
"filters": "Filtres",
"measurements": {
"measurements": "Mesures",
+ "reorderCategories": "Réorganiser les catégories",
"unitFormHelpText": "L'unité dans laquelle la catégorie sera mesurée, telle que cm ou %",
"deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées",
"deleteInfoGroup": "Ceci supprimera le groupe ainsi que tous ses composants et leurs entrées",
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index d4d74646f..bf57f6c71 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -116,6 +116,14 @@ export const editMeasurementCategory = async (category: MeasurementCategory): Pr
return MeasurementCategory.fromJson(response.data);
};
+export const updateMeasurementCategoryOrder = async (id: string, order: number): Promise => {
+ await axios.patch(
+ makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }),
+ { order: order },
+ { headers: makeHeader() }
+ );
+};
+
export const deleteMeasurementCategory = async (id: string): Promise => {
await axios.delete(makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }), { headers: makeHeader() });
};
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index 5a4eea020..f6e04ad74 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -7,7 +7,8 @@ import {
editMeasurementEntry,
getMeasurementCategories,
getMeasurementCategory,
- MeasurementQueryOptions
+ MeasurementQueryOptions,
+ updateMeasurementCategoryOrder
} from "@/components/Measurements/api/measurements";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -66,6 +67,20 @@ export const useDeleteMeasurementCategoryQuery = (id: string) => {
};
+/** Persists a new top-level category order, the position in the list becomes the order value */
+export const useReorderMeasurementCategoriesQuery = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (categories: MeasurementCategory[]) => Promise.all(
+ categories.map((category, index) => updateMeasurementCategoryOrder(category.id!, index))
+ ),
+ onSuccess: () => queryClient.invalidateQueries({
+ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
+ })
+ });
+};
+
export function useMeasurementsQuery(id: string) {
return useQuery({
queryKey: [QueryKey.MEASUREMENTS, id],
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
index 8de66f9f2..d542bf0bc 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from '@testing-library/react';
-import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries";
+import userEvent from "@testing-library/user-event";
+import { useMeasurementsCategoryQuery, useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
import { MeasurementCategoryOverview } from "@/components/Measurements/screens/MeasurementCategoryOverview";
import React from 'react';
import { BrowserRouter } from "react-router-dom";
@@ -19,6 +20,9 @@ describe("Test the MeasurementCategoryOverview component", () => {
isLoading: false,
data: [TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2]
}));
+ (useReorderMeasurementCategoriesQuery as Mock).mockImplementation(() => ({
+ mutate: vi.fn()
+ }));
});
afterEach(() => {
@@ -41,4 +45,26 @@ describe("Test the MeasurementCategoryOverview component", () => {
expect(screen.getByText('measurements.measurements')).toBeInTheDocument();
expect(screen.getByText('Body fat')).toBeInTheDocument();
});
+
+ test('opens the reorder modal', async () => {
+
+ // Arrange
+ render(
+
+
+
+
+
+ );
+
+ // Act
+ await userEvent.click(screen.getByTestId('SortIcon').closest('button')!);
+
+ // Assert - the modal shows the categories as a sortable list
+ // (the string can also appear in the tooltip of the button itself)
+ expect(screen.getAllByText('measurements.reorderCategories').length).toBeGreaterThan(0);
+ // Each category now appears twice, on its card and in the sortable list
+ expect(screen.getAllByText('Biceps')).toHaveLength(2);
+ expect(screen.getAllByText('Body fat')).toHaveLength(2);
+ });
});
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index 16e6cd244..1b09e04f7 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -1,6 +1,7 @@
import React from "react";
-import { Button, Card, CardActions, CardContent, CardHeader, IconButton, Stack, } from "@mui/material";
+import { Button, Card, 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";
@@ -11,6 +12,7 @@ import { AddMeasurementCategoryFab } from "@/components/Measurements/widgets/fab
import { WgerContainerRightSidebar } 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";
import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/EntryForm";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
@@ -51,16 +53,32 @@ export const CategoryList = (props: { category: MeasurementCategory }) => {
export const MeasurementCategoryOverview = () => {
const categoryQuery = useMeasurementsCategoryQuery();
const [t] = useTranslation();
+ const [openReorderModal, setOpenReorderModal] = React.useState(false);
return categoryQuery.isLoading
?
- :
- {categoryQuery.data!.length === 0 && }
- {categoryQuery.data!.map(c => )}
-
- }
- fab={ }
- />;
+ : <>
+
+ setOpenReorderModal(true)}>
+
+
+
+ }
+ mainContent={
+ {categoryQuery.data!.length === 0 && }
+ {categoryQuery.data!.map(c => )}
+
+ }
+ fab={ }
+ />
+ setOpenReorderModal(false)}>
+
+
+ >;
};
diff --git a/src/components/Measurements/widgets/CategoryReorderList.test.tsx b/src/components/Measurements/widgets/CategoryReorderList.test.tsx
new file mode 100644
index 000000000..d1b00778d
--- /dev/null
+++ b/src/components/Measurements/widgets/CategoryReorderList.test.tsx
@@ -0,0 +1,65 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { CategoryReorderList } from "@/components/Measurements/widgets/CategoryReorderList";
+import { useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
+import React from 'react';
+import { getTestQueryClient } from "@/tests/queryClient";
+import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
+import type { Mock } from 'vitest';
+
+vi.mock("@/components/Measurements/queries");
+
+const keyCodes = { space: 32, arrowDown: 40 };
+
+describe("Test the CategoryReorderList component", () => {
+
+ const mutateMock = vi.fn();
+
+ beforeEach(() => {
+ (useReorderMeasurementCategoriesQuery as Mock).mockImplementation(() => ({
+ mutate: mutateMock
+ }));
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ const renderComponent = () => render(
+
+
+
+ );
+
+ test('renders the categories in order', () => {
+
+ // Act
+ renderComponent();
+
+ // Assert - the drag handle props override the listitem role with "button"
+ const items = screen.getAllByRole('button');
+ expect(items[0]).toHaveTextContent('Biceps');
+ expect(items[0]).toHaveTextContent('cm');
+ expect(items[1]).toHaveTextContent('Body fat');
+ expect(items[1]).toHaveTextContent('%');
+ });
+
+ test('persists the new order after a drag and drop', async () => {
+
+ // Arrange
+ renderComponent();
+ const handle = screen.getByText('Biceps').closest('[data-rfd-drag-handle-draggable-id]')!;
+ handle.focus();
+
+ // Act - move the first category down one position via keyboard drag
+ fireEvent.keyDown(handle, { keyCode: keyCodes.space });
+ fireEvent.keyDown(handle, { keyCode: keyCodes.arrowDown });
+ fireEvent.keyDown(handle, { keyCode: keyCodes.space });
+
+ // Assert
+ await waitFor(() => expect(mutateMock).toHaveBeenCalledWith(
+ [TEST_MEASUREMENT_CATEGORY_2, TEST_MEASUREMENT_CATEGORY_1]
+ ));
+ });
+});
diff --git a/src/components/Measurements/widgets/CategoryReorderList.tsx b/src/components/Measurements/widgets/CategoryReorderList.tsx
new file mode 100644
index 000000000..1dbcc2aff
--- /dev/null
+++ b/src/components/Measurements/widgets/CategoryReorderList.tsx
@@ -0,0 +1,59 @@
+import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
+import DragHandleIcon from '@mui/icons-material/DragHandle';
+import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material";
+import React, { useState } from "react";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
+
+/**
+ * Drag-and-drop reordering of the top-level measurement categories.
+ *
+ * Children of multi-value groups are not listed; they keep their in-group
+ * order and follow their parent.
+ */
+export const CategoryReorderList = (props: { categories: MeasurementCategory[] }) => {
+
+ // The list is kept locally so a drop is reflected immediately, the new
+ // order is persisted per drop like in the flutter app
+ const [categories, setCategories] = useState(props.categories);
+ const reorderQuery = useReorderMeasurementCategoriesQuery();
+
+ const onDragEnd = (result: DropResult) => {
+ if (result.destination === null || result.destination.index === result.source.index) {
+ return;
+ }
+
+ const reordered = [...categories];
+ const [moved] = reordered.splice(result.source.index, 1);
+ reordered.splice(result.destination.index, 0, moved);
+
+ setCategories(reordered);
+ reorderQuery.mutate(reordered);
+ };
+
+ return
+
+ {(provided) => (
+
+ {categories.map((category, index) => (
+
+ {(providedDraggable) => (
+
+
+
+
+
+
+ )}
+
+ ))}
+ {provided.placeholder}
+
+ )}
+
+ ;
+};
From 8e48810b5c973babaf8c4bbbcfe3de651f0f5bb0 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 31 Jul 2026 23:54:39 +0200
Subject: [PATCH 16/71] Read measurement values through a unit-aware helper
---
public/locales/de/translation.json | 1 +
public/locales/en/translation.json | 1 +
public/locales/es/translation.json | 1 +
public/locales/fr/translation.json | 1 +
.../Measurements/models/Category.ts | 1 +
.../Measurements/models/Entry.test.ts | 29 +++++++++++++++
src/components/Measurements/models/Entry.ts | 36 +++++++++++++++++++
src/components/Weight/models/WeightEntry.ts | 4 +--
src/core/lib/weightUnit.ts | 8 +++++
9 files changed, 80 insertions(+), 2 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 7afc34202..8aa5bb99d 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -265,6 +265,7 @@
"height": "Körpergröße",
"blood_pressure": "Blutdruck",
"heart_rate": "Herzfrequenz",
+ "resting_heart_rate": "Ruhepuls",
"steps": "Schritte",
"distance": "Distanz",
"energy": "Energie",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 164f25bc6..937b84f37 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -349,6 +349,7 @@
"height": "Height",
"blood_pressure": "Blood pressure",
"heart_rate": "Heart rate",
+ "resting_heart_rate": "Resting heart rate",
"steps": "Steps",
"distance": "Distance",
"energy": "Energy",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index f0d9dffc0..1b6d6f111 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -267,6 +267,7 @@
"height": "Altura",
"blood_pressure": "Presión arterial",
"heart_rate": "Frecuencia cardíaca",
+ "resting_heart_rate": "Frecuencia cardíaca en reposo",
"steps": "Pasos",
"distance": "Distancia",
"energy": "Energía",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 37bf51458..cacf4de42 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -350,6 +350,7 @@
"height": "Taille",
"blood_pressure": "Pression artérielle",
"heart_rate": "Fréquence cardiaque",
+ "resting_heart_rate": "Fréquence cardiaque au repos",
"steps": "Pas",
"distance": "Distance",
"energy": "Énergie",
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 770393718..63e78a754 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -9,6 +9,7 @@ export const METRIC_TYPES = [
'height',
'blood_pressure',
'heart_rate',
+ 'resting_heart_rate',
'steps',
'distance',
'energy',
diff --git a/src/components/Measurements/models/Entry.test.ts b/src/components/Measurements/models/Entry.test.ts
index 460b24df5..3af4610ae 100644
--- a/src/components/Measurements/models/Entry.test.ts
+++ b/src/components/Measurements/models/Entry.test.ts
@@ -39,3 +39,32 @@ describe('MeasurementEntry', () => {
expect(entry.source).toBe('user');
});
});
+
+describe('MeasurementEntry units', () => {
+
+ const entry = (value: number, extraData: Record = {}) =>
+ new MeasurementEntry('d-1', 'c-1', new Date(2023, 1, 1), value, '', 'user', extraData);
+
+ test('falls back to the category unit when extra_data has none', () => {
+ expect(entry(80).unitOrFallback('kg')).toBe('kg');
+ expect(entry(80, { unit: '' }).unitOrFallback('kg')).toBe('kg');
+ expect(entry(80, { unit: 42 }).unitOrFallback('kg')).toBe('kg');
+ });
+
+ test('converts a value stored in another unit', () => {
+ expect(entry(176.37, { unit: 'lb' }).valueIn('kg', 'kg')).toBe(80);
+ expect(entry(80, { unit: 'kg' }).valueIn('lb', 'kg')).toBe(176.37);
+ });
+
+ test('leaves free-form category units untouched', () => {
+ expect(entry(42).valueIn('cm', 'cm')).toBe(42);
+ expect(entry(42, { unit: 'cm' }).valueIn('kg', 'cm')).toBe(42);
+ });
+
+ test('bounds follow the value through the same conversion', () => {
+ const aggregate = entry(80, { unit: 'lb', min: 70, max: 90 });
+
+ expect(aggregate.boundIn(70, 'kg', 'kg')).toBe(31.75);
+ expect(aggregate.boundIn(90, 'kg', 'kg')).toBe(40.82);
+ });
+});
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index 96f1228ec..be6357a95 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -1,4 +1,5 @@
import { Adapter } from "@/core/lib/Adapter";
+import { convertWeight, isWeightUnit } from "@/core/lib/weightUnit";
export class MeasurementEntry {
@@ -18,6 +19,41 @@ export class MeasurementEntry {
return this.source === 'user';
}
+ /**
+ * The unit the value was entered in: extra_data.unit, falling back to the
+ * category unit when absent (same chain as the server)
+ */
+ unitOrFallback(categoryUnit: string): string {
+ const stored = this.extraData['unit'];
+ return typeof stored === 'string' && stored !== '' ? stored : categoryUnit;
+ }
+
+ /**
+ * The value in the given unit. The only way to read a measurement for
+ * display or calculation: a category can hold entries in mixed units, so
+ * the raw value on its own is meaningless.
+ */
+ valueIn(targetUnit: string, categoryUnit: string): number {
+ return this.convert(this.value, targetUnit, categoryUnit);
+ }
+
+ /**
+ * A number stored in extra_data next to the value, such as the bounds of a
+ * daily aggregate. They are written in the value's unit, so they have to
+ * follow it through the same conversion.
+ */
+ boundIn(bound: number, targetUnit: string, categoryUnit: string): number {
+ return this.convert(bound, targetUnit, categoryUnit);
+ }
+
+ private convert(value: number, targetUnit: string, categoryUnit: string): number {
+ const from = this.unitOrFallback(categoryUnit);
+
+ return isWeightUnit(from) && isWeightUnit(targetUnit)
+ ? convertWeight(value, from, targetUnit)
+ : value;
+ }
+
static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry {
return new MeasurementEntry(
overrides?.id ?? other.id,
diff --git a/src/components/Weight/models/WeightEntry.ts b/src/components/Weight/models/WeightEntry.ts
index b7d7b2d05..9154947ba 100644
--- a/src/components/Weight/models/WeightEntry.ts
+++ b/src/components/Weight/models/WeightEntry.ts
@@ -1,5 +1,5 @@
import { Adapter } from "@/core/lib/Adapter";
-import { convertWeight, WeightUnit } from "@/core/lib/weightUnit";
+import { convertWeight, isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
/**
* A body weight entry, stored on the server as a measurement in the user's
@@ -58,7 +58,7 @@ class WeightAdapter implements Adapter {
// narrow the server value instead of trusting the cast, an unexpected
// unit would otherwise silently convert wrongly
const serverUnit = item.extra_data?.unit;
- const unit: WeightUnit = serverUnit === 'kg' || serverUnit === 'lb' ? serverUnit : fallbackUnit;
+ const unit: WeightUnit = isWeightUnit(serverUnit) ? serverUnit : fallbackUnit;
return new WeightEntry(
new Date(item.date),
diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts
index 94915fd83..464da16b6 100644
--- a/src/core/lib/weightUnit.ts
+++ b/src/core/lib/weightUnit.ts
@@ -2,6 +2,14 @@ export type WeightUnit = 'kg' | 'lb';
export const KG_PER_LB = 0.45359237;
+/*
+ * Narrows a stored or server-provided unit. Everything else is a free-text
+ * category label, which is never converted.
+ */
+export function isWeightUnit(value: unknown): value is WeightUnit {
+ return value === 'kg' || value === 'lb';
+}
+
/*
* Converts a body weight value between kg and lb, quantized to 2 decimal
* places like the server. Free-text units of custom measurement categories
From b4e89520cb06d17b4611943651165d8c5572ed47 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 00:03:31 +0200
Subject: [PATCH 17/71] Add the chart series model and its data functions
---
.../Measurements/api/measurements.ts | 6 +
.../Measurements/charts/data.test.ts | 297 ++++++++++++++++++
src/components/Measurements/charts/data.ts | 254 +++++++++++++++
src/components/Measurements/charts/series.ts | 45 +++
.../widgets/MeasurementChart.test.tsx | 62 +---
.../Measurements/widgets/MeasurementChart.tsx | 61 +---
6 files changed, 611 insertions(+), 114 deletions(-)
create mode 100644 src/components/Measurements/charts/data.test.ts
create mode 100644 src/components/Measurements/charts/data.ts
create mode 100644 src/components/Measurements/charts/series.ts
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index bf57f6c71..754a2ae19 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -67,6 +67,11 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
byId.get(category.parentId)?.children.push(category);
}
}
+ // For children, order is the position within the group (systolic before
+ // diastolic); the chart colours the components by that position
+ for (const category of categories) {
+ category.children.sort((a, b) => a.order - b.order);
+ }
return categories.filter(c => c.parentId === null);
};
@@ -88,6 +93,7 @@ export const getMeasurementCategory = async (id: string): Promise a.order - b.order);
await Promise.all([category, ...category.children].map(async (cat) => {
cat.entries = await loadEntries(cat.id!);
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
new file mode 100644
index 000000000..c486d5ccf
--- /dev/null
+++ b/src/components/Measurements/charts/data.test.ts
@@ -0,0 +1,297 @@
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import {
+ aggregatePerDay,
+ chartPointsFor,
+ downsample,
+ fillMissingDays,
+ groupComponentSeries,
+ groupRangeEntries,
+ moving7dAverage,
+ overallChange,
+ smoothedTrendline
+} from "@/components/Measurements/charts/data";
+import { ChartPoint } from "@/components/Measurements/charts/series";
+import { describe, expect, test } from 'vitest';
+
+const entry = (date: Date, value: number, extraData: Record = {}) =>
+ new MeasurementEntry('d-1', 'c-1', date, value, '', 'user', extraData);
+
+const point = (date: Date, value: number): ChartPoint => ({ date: date.getTime(), value: value });
+
+const day = (dayOfMonth: number, hour: number = 0) => new Date(2023, 1, dayOfMonth, hour);
+
+describe('chartPointsFor', () => {
+ test('returns the points chronologically', () => {
+ const points = chartPointsFor([
+ entry(day(3), 30),
+ entry(day(1), 10),
+ entry(day(2), 20),
+ ], 'cm', 'cm');
+
+ expect(points.map(p => p.value)).toEqual([10, 20, 30]);
+ });
+
+ test('converts the value into the target unit', () => {
+ const points = chartPointsFor([entry(day(1), 176.37, { unit: 'lb' })], 'kg', 'kg');
+
+ expect(points[0].value).toBe(80);
+ });
+
+ test('lifts the bounds of a daily aggregate, converted along with the value', () => {
+ const points = chartPointsFor(
+ [entry(day(1), 176.37, { unit: 'lb', min: 154.32, max: 198.42 })],
+ 'kg',
+ 'kg',
+ );
+
+ expect(points[0].min).toBe(70);
+ expect(points[0].max).toBe(90);
+ });
+
+ test('leaves a plain sample without bounds', () => {
+ const points = chartPointsFor([entry(day(1), 80)], 'kg', 'kg');
+
+ expect(points[0]).toStrictEqual({ date: day(1).getTime(), value: 80 });
+ });
+
+ test('ignores a half-written range', () => {
+ const points = chartPointsFor([entry(day(1), 80, { min: 70 })], 'kg', 'kg');
+
+ expect(points[0].min).toBeUndefined();
+ expect(points[0].max).toBeUndefined();
+ });
+});
+
+describe('moving7dAverage', () => {
+ test('returns an empty series unchanged', () => {
+ expect(moving7dAverage([])).toEqual([]);
+ });
+
+ test('averages over the 7 days preceding each point', () => {
+ const result = moving7dAverage([
+ point(day(1), 10),
+ point(day(2), 20),
+ point(day(3), 30),
+ ]);
+
+ expect(result.map(p => p.value)).toEqual([10, 15, 20]);
+ });
+
+ test('drops points that fell out of the window', () => {
+ const result = moving7dAverage([
+ point(day(1), 10),
+ point(day(20), 30),
+ point(day(21), 50),
+ ]);
+
+ // the first point is more than 7 days away and no longer counts
+ expect(result.map(p => p.value)).toEqual([10, 30, 40]);
+ });
+
+ test('sorts the input before averaging', () => {
+ const result = moving7dAverage([point(day(2), 20), point(day(1), 10)]);
+
+ expect(result.map(p => p.value)).toEqual([10, 15]);
+ });
+
+ test('carries no range, an average has no spread of its own', () => {
+ const result = moving7dAverage([{ date: day(1).getTime(), value: 10, min: 5, max: 15 }]);
+
+ expect(result[0]).toStrictEqual({ date: day(1).getTime(), value: 10 });
+ });
+});
+
+describe('smoothedTrendline', () => {
+ test('returns an empty series unchanged', () => {
+ expect(smoothedTrendline([])).toEqual([]);
+ });
+
+ test('is seeded with the first value', () => {
+ const result = smoothedTrendline([point(day(1), 10), point(day(2), 20)]);
+
+ expect(result[0].value).toBe(10);
+ expect(result[1].value).toBeGreaterThan(10);
+ expect(result[1].value).toBeLessThan(20);
+ });
+});
+
+describe('downsample', () => {
+ test('returns a series that already fits unchanged', () => {
+ const points = [point(day(1), 10), point(day(2), 20)];
+
+ expect(downsample(points, 200)).toBe(points);
+ });
+
+ test('condenses into the finest calendar unit that fits', () => {
+ // three samples in each of two hours
+ const points = [
+ point(day(1, 8), 10), point(day(1, 8), 20), point(day(1, 8), 30),
+ point(day(1, 9), 40), point(day(1, 9), 50), point(day(1, 9), 60),
+ ];
+
+ const result = downsample(points, 4);
+
+ expect(result.map(p => p.value)).toEqual([20, 50]);
+ expect(result.map(p => p.date)).toEqual([day(1, 8).getTime(), day(1, 9).getTime()]);
+ });
+
+ test('carries the extremes of the bucket as a range', () => {
+ const points = [
+ point(day(1, 8), 10), point(day(1, 8), 20), point(day(1, 8), 30),
+ point(day(1, 9), 40), point(day(1, 9), 50), point(day(1, 9), 60),
+ ];
+
+ const result = downsample(points, 4);
+
+ expect(result[0].min).toBe(10);
+ expect(result[0].max).toBe(30);
+ });
+
+ test('an already condensed point contributes its bounds, not its value', () => {
+ const points = [
+ { date: day(1, 8).getTime(), value: 20, min: 5, max: 95 },
+ { date: day(1, 8).getTime(), value: 30, min: 25, max: 35 },
+ { date: day(1, 9).getTime(), value: 40 },
+ { date: day(1, 9).getTime(), value: 50 },
+ ];
+
+ const result = downsample(points, 3);
+
+ expect(result[0].min).toBe(5);
+ expect(result[0].max).toBe(95);
+ });
+
+ test('falls back to coarser units until the series fits', () => {
+ // one sample per hour over four days is too many for a per-hour bucket
+ const points = [];
+ for (let d = 1; d <= 4; d++) {
+ for (let hour = 0; hour < 24; hour++) {
+ points.push(point(day(d, hour), hour));
+ }
+ }
+
+ const result = downsample(points, 10);
+
+ expect(result).toHaveLength(4);
+ expect(result.map(p => p.date)).toEqual([1, 2, 3, 4].map(d => day(d).getTime()));
+ });
+
+ test('returns the coarsest bucketing even when it still exceeds the limit', () => {
+ const points = [];
+ for (let month = 0; month < 12; month++) {
+ points.push({ date: new Date(2023, month, 1).getTime(), value: month });
+ }
+
+ expect(downsample(points, 3)).toHaveLength(12);
+ });
+});
+
+describe('aggregatePerDay', () => {
+ test('returns an empty array for no points', () => {
+ expect(aggregatePerDay([])).toEqual([]);
+ });
+
+ test('sums all samples of the same calendar day', () => {
+ const result = aggregatePerDay([
+ point(day(1, 8), 4000),
+ point(day(1, 18), 6000),
+ point(day(2, 9), 3000),
+ ]);
+
+ expect(result).toEqual([
+ { date: day(1).getTime(), value: 10000 },
+ { date: day(2).getTime(), value: 3000 },
+ ]);
+ });
+
+ test('sorts the buckets chronologically', () => {
+ const result = aggregatePerDay([point(day(3), 30), point(day(1), 10), point(day(2), 20)]);
+
+ expect(result.map(r => r.value)).toEqual([10, 20, 30]);
+ });
+});
+
+describe('fillMissingDays', () => {
+ test('returns an empty array for no data', () => {
+ expect(fillMissingDays([])).toEqual([]);
+ });
+
+ test('fills gaps with zero-value days', () => {
+ const result = fillMissingDays([point(day(1), 10), point(day(4), 40)]);
+
+ expect(result).toEqual([
+ { date: day(1).getTime(), value: 10 },
+ { date: day(2).getTime(), value: 0 },
+ { date: day(3).getTime(), value: 0 },
+ { date: day(4).getTime(), value: 40 },
+ ]);
+ });
+
+ test('keeps a contiguous series unchanged', () => {
+ const data = [point(day(1), 10), point(day(2), 20)];
+
+ expect(fillMissingDays(data)).toEqual(data);
+ });
+});
+
+describe('groups', () => {
+
+ const bloodPressure = (readings: [Date, number, number | null][]) => {
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', [], 'blood_pressure');
+ const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', [], 'custom', false, 'g-1', 0);
+ const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', [], 'custom', false, 'g-1', 1);
+
+ for (const [date, high, low] of readings) {
+ systolic.entries.push(new MeasurementEntry(null, 'c-sys', date, high, ''));
+ if (low !== null) {
+ diastolic.entries.push(new MeasurementEntry(null, 'c-dia', date, low, ''));
+ }
+ }
+ group.children = [systolic, diastolic];
+
+ return group;
+ };
+
+ test('pairs the components of a reading into one range', () => {
+ const result = groupRangeEntries(bloodPressure([[day(1), 120, 80]]));
+
+ expect(result).toStrictEqual([{ date: day(1).getTime(), value: 100, min: 80, max: 120 }]);
+ });
+
+ test('skips a half reading, it has no range', () => {
+ const result = groupRangeEntries(bloodPressure([[day(1), 120, 80], [day(2), 125, null]]));
+
+ expect(result.map(r => r.date)).toEqual([day(1).getTime()]);
+ });
+
+ test('sorts the readings chronologically', () => {
+ const result = groupRangeEntries(bloodPressure([[day(3), 130, 90], [day(1), 120, 80]]));
+
+ expect(result.map(r => r.max)).toEqual([120, 130]);
+ });
+
+ test('reads the low and high end from the values, not from the component order', () => {
+ const group = bloodPressure([[day(1), 80, 120]]);
+
+ expect(groupRangeEntries(group)[0]).toMatchObject({ min: 80, max: 120 });
+ });
+
+ test('builds one named component series per child', () => {
+ const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]));
+
+ expect(series.map(s => s.label)).toEqual(['Systolic', 'Diastolic']);
+ expect(series.map(s => s.role)).toEqual(['component', 'component']);
+ expect(series[0].points.map(p => p.value)).toEqual([120]);
+ });
+});
+
+describe('overallChange', () => {
+ test('is null for an empty series', () => {
+ expect(overallChange([])).toBeNull();
+ });
+
+ test('is the difference between the first and the last point', () => {
+ expect(overallChange([point(day(1), 80), point(day(2), 78)])).toBe(-2);
+ });
+});
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
new file mode 100644
index 000000000..9b8597118
--- /dev/null
+++ b/src/components/Measurements/charts/data.ts
@@ -0,0 +1,254 @@
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { ChartPoint, ChartSeries } from "@/components/Measurements/charts/series";
+import { calculateEMA } from "@/core/lib/ema";
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+/** Length of the moving average window */
+const AVERAGE_WINDOW_DAYS = 7;
+
+/** Point count above which a series is condensed, see downsample */
+export const MAX_CHART_POINTS = 200;
+
+/**
+ * Turns stored entries into chart points, converting the value to the target
+ * unit. Entries stored as a daily aggregate keep the range they summarise in
+ * extra_data (heart rate min/max); it is lifted onto the point so the chart
+ * can draw a band. Those bounds share the value's unit and are converted
+ * along with it.
+ *
+ * The result is chronological; entries arrive from the API newest first.
+ */
+export const chartPointsFor = (
+ entries: MeasurementEntry[],
+ targetUnit: string,
+ categoryUnit: string,
+): ChartPoint[] => [...entries]
+ .sort((a, b) => a.date.getTime() - b.date.getTime())
+ .map(entry => {
+ const bound = (key: string) => {
+ const stored = entry.extraData[key];
+ return typeof stored === 'number'
+ ? entry.boundIn(stored, targetUnit, categoryUnit)
+ : undefined;
+ };
+
+ const min = bound('min');
+ const max = bound('max');
+
+ return {
+ date: entry.date.getTime(),
+ value: entry.valueIn(targetUnit, categoryUnit),
+ ...(min !== undefined && max !== undefined ? { min: min, max: max } : {}),
+ };
+ });
+
+/**
+ * For each point, the average of all points in the 7 days preceding it.
+ *
+ * The window total is carried along instead of re-summing the window for every
+ * point: with densely sampled metrics the window holds thousands of values,
+ * and re-adding them each time makes this quadratic.
+ */
+export const moving7dAverage = (points: ChartPoint[]): ChartPoint[] => {
+ const sorted = [...points].sort((a, b) => a.date - b.date);
+ const out: ChartPoint[] = [];
+ let start = 0;
+ let sum = 0;
+
+ for (let end = 0; end < sorted.length; end++) {
+ sum += sorted[end].value;
+
+ // Users log measurements days or minutes apart, so the start of the
+ // window has to be advanced by date, not by a fixed number of points
+ const windowStart = sorted[end].date - AVERAGE_WINDOW_DAYS * DAY_MS;
+ while (start < end && sorted[start].date < windowStart) {
+ sum -= sorted[start].value;
+ start++;
+ }
+
+ out.push({ date: sorted[end].date, value: sum / (end - start + 1) });
+ }
+
+ return out;
+};
+
+/**
+ * Smoothed trendline via an exponential moving average, seeded with the first
+ * point. A larger period tracks the values more loosely (smoother, more lag).
+ */
+export const smoothedTrendline = (points: ChartPoint[], period: number = 10): ChartPoint[] =>
+ calculateEMA([...points].sort((a, b) => a.date - b.date), p => p.value, period)
+ .map(point => ({ date: point.date, value: point.ema }));
+
+/**
+ * Time units a dense series is condensed into, finest first.
+ *
+ * Buckets follow the calendar instead of being equal slices of the total span:
+ * these metrics have a daily rhythm (asleep, awake, a workout), so slices that
+ * do not line up with a day each catch a different phase of it, and the result
+ * oscillates at the slice frequency instead of showing the shape of the data.
+ */
+const BUCKET_STARTS: ((date: Date) => Date)[] = [
+ d => new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()),
+ d => new Date(d.getFullYear(), d.getMonth(), d.getDate()),
+ d => {
+ const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate());
+ // getDay() counts from Sunday, the week starts on Monday
+ monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7));
+ return monday;
+ },
+ d => new Date(d.getFullYear(), d.getMonth(), 1),
+];
+
+/**
+ * Condenses one bucket into a single point: the mean value at the start of the
+ * bucket, spanning the values it stands for.
+ */
+const summarise = (date: number, bucket: ChartPoint[]): ChartPoint => {
+ let sum = 0;
+ // An entry that already carries a range contributes its bounds, not just
+ // its value, so re-condensing an aggregate keeps the true extremes
+ let min = Number.POSITIVE_INFINITY;
+ let max = Number.NEGATIVE_INFINITY;
+
+ for (const point of bucket) {
+ sum += point.value;
+ min = Math.min(min, point.min ?? point.value);
+ max = Math.max(max, point.max ?? point.value);
+ }
+
+ return { date: date, value: sum / bucket.length, min: min, max: max };
+};
+
+/**
+ * Reduces a dense series to at most maxPoints, keeping its shape.
+ *
+ * Plotting more points than the chart has pixels only overdraws: a season of
+ * raw heart rate samples is tens of thousands of values on a few hundred
+ * pixels, which comes out as a solid block. Entries are therefore condensed
+ * into the finest calendar unit that gets under the limit; each unit becomes
+ * one point at its mean, carrying its minimum and maximum so the chart draws
+ * the spread as a band. That keeps exactly the information a line through
+ * every single sample buries.
+ *
+ * Series that already fit are returned unchanged.
+ */
+export const downsample = (points: ChartPoint[], maxPoints: number = MAX_CHART_POINTS): ChartPoint[] => {
+ if (points.length <= maxPoints) {
+ return points;
+ }
+
+ for (const [index, bucketStart] of BUCKET_STARTS.entries()) {
+ const grouped = new Map();
+ for (const point of points) {
+ const key = bucketStart(new Date(point.date)).getTime();
+ const bucket = grouped.get(key);
+ if (bucket === undefined) {
+ grouped.set(key, [point]);
+ } else {
+ bucket.push(point);
+ }
+ }
+
+ if (grouped.size <= maxPoints || index === BUCKET_STARTS.length - 1) {
+ return [...grouped.entries()]
+ .sort(([a], [b]) => a - b)
+ .map(([date, bucket]) => summarise(date, bucket));
+ }
+ }
+
+ return points;
+};
+
+/**
+ * Sums points per local calendar day, for metric types where individual
+ * samples aren't meaningful on their own (steps, distance, energy, sleep)
+ */
+export const aggregatePerDay = (points: ChartPoint[]): ChartPoint[] => {
+ const sums = new Map();
+ for (const point of points) {
+ const date = new Date(point.date);
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+ sums.set(day, (sums.get(day) ?? 0) + point.value);
+ }
+
+ return [...sums.entries()]
+ .map(([date, value]) => ({ date: date, value: value }))
+ .sort((a, b) => a.date - b.date);
+};
+
+/**
+ * Fills gaps in a per-day series with zero-value days so a band axis keeps
+ * the spacing between bars proportional to time
+ */
+export const fillMissingDays = (points: ChartPoint[]): ChartPoint[] => {
+ if (points.length === 0) {
+ return [];
+ }
+
+ const byDay = new Map(points.map(p => [p.date, p.value]));
+ const last = points[points.length - 1].date;
+ const out: ChartPoint[] = [];
+ // aggregatePerDay emits local-midnight timestamps; stepping via setDate
+ // stays on local midnight across DST changes
+ for (const day = new Date(points[0].date); day.getTime() <= last; day.setDate(day.getDate() + 1)) {
+ out.push({ date: day.getTime(), value: byDay.get(day.getTime()) ?? 0 });
+ }
+
+ return out;
+};
+
+/**
+ * The readings of a multi-value group as ranges: one point per timestamp,
+ * spanning from the lower component to the upper one.
+ *
+ * A reading is one event, so it is drawn as a single bar (diastolic to
+ * systolic) rather than as two lines: the components belong together, and
+ * nothing was measured between two readings. Components are paired by their
+ * shared timestamp, which is how both the importer and the group form write
+ * them; an unpaired half-reading is skipped, it has no range.
+ */
+export const groupRangeEntries = (group: MeasurementCategory): ChartPoint[] => {
+ const byDate = new Map();
+ for (const child of group.children) {
+ for (const entry of child.entries) {
+ const date = entry.date.getTime();
+ const values = byDate.get(date);
+ const value = entry.valueIn(child.unit, child.unit);
+ if (values === undefined) {
+ byDate.set(date, [value]);
+ } else {
+ values.push(value);
+ }
+ }
+ }
+
+ return [...byDate.entries()]
+ .filter(([, values]) => values.length > 1)
+ .map(([date, values]) => ({
+ date: date,
+ value: values.reduce((sum, value) => sum + value, 0) / values.length,
+ // The low/high assignment comes from the values, not from the
+ // component order, so a reordered group still reads correctly
+ min: Math.min(...values),
+ max: Math.max(...values),
+ }))
+ .sort((a, b) => a.date - b.date);
+};
+
+/**
+ * One series per component of a multi-value group, in the children's in-group
+ * order and named after them
+ */
+export const groupComponentSeries = (group: MeasurementCategory): ChartSeries[] =>
+ group.children.map(child => ({
+ points: chartPointsFor(child.entries, child.unit, child.unit),
+ role: 'component' as const,
+ label: child.name,
+ }));
+
+/** 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/charts/series.ts b/src/components/Measurements/charts/series.ts
new file mode 100644
index 000000000..616053ad6
--- /dev/null
+++ b/src/components/Measurements/charts/series.ts
@@ -0,0 +1,45 @@
+/**
+ * A chart is given a list of series, not a single value list. That is what
+ * lets one chart show the components of a multi-value group.
+ */
+
+/**
+ * One point of a series. min/max are set when the point stands for a range
+ * rather than a single reading — either because the entry is a stored daily
+ * aggregate (extra_data min/max) or because several points were condensed
+ * into it. Both are set or neither.
+ */
+export interface ChartPoint {
+ date: number;
+ value: number;
+ min?: number;
+ max?: number;
+}
+
+/**
+ * What a series means, which decides how it is drawn. Colours come from the
+ * theme when the chart is built, never from the series itself.
+ */
+export type ChartSeriesRole =
+/** the measured values themselves */
+ | 'raw'
+ /** moving average over the raw values */
+ | 'average'
+ /** smoothed trend through the raw values */
+ | 'trend'
+ /** one component of a multi-value group (systolic, diastolic, ...) */
+ | 'component';
+
+export interface ChartSeries {
+ points: ChartPoint[];
+ role: ChartSeriesRole;
+ /**
+ * Name for the legend and the tooltip. Undefined for the unnamed series of
+ * a plain category, where the chart title already says what is shown.
+ */
+ label?: string;
+}
+
+/** Whether the point carries a range that can be drawn as a band */
+export const hasRange = (point: ChartPoint): boolean =>
+ point.min !== undefined && point.max !== undefined;
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 76bbb3672..43c8b6517 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,9 +1,9 @@
import { render } from '@testing-library/react';
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { aggregatePerDay, fillMissingDays, MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
+import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
import React from 'react';
-import { describe, expect, test } from 'vitest';
+import { describe, test } from 'vitest';
const entry = (id: string, date: Date, value: number) =>
new MeasurementEntry(id, 'c-1', date, value, '');
@@ -51,61 +51,3 @@ describe('MeasurementChart', () => {
render( );
});
});
-
-describe('aggregatePerDay', () => {
- test('returns an empty array for no entries', () => {
- expect(aggregatePerDay([])).toEqual([]);
- });
-
- test('sums all samples of the same calendar day', () => {
- const result = aggregatePerDay([
- entry('d-1', new Date(2023, 1, 1, 8, 0), 4000),
- entry('d-2', new Date(2023, 1, 1, 18, 30), 6000),
- entry('d-3', new Date(2023, 1, 2, 9, 0), 3000),
- ]);
-
- expect(result).toEqual([
- { date: new Date(2023, 1, 1).getTime(), value: 10000 },
- { date: new Date(2023, 1, 2).getTime(), value: 3000 },
- ]);
- });
-
- test('sorts the buckets chronologically', () => {
- const result = aggregatePerDay([
- entry('d-1', new Date(2023, 1, 3), 30),
- entry('d-2', new Date(2023, 1, 1), 10),
- entry('d-3', new Date(2023, 1, 2), 20),
- ]);
-
- expect(result.map(r => r.value)).toEqual([10, 20, 30]);
- });
-});
-
-describe('fillMissingDays', () => {
- test('returns an empty array for no data', () => {
- expect(fillMissingDays([])).toEqual([]);
- });
-
- test('fills gaps with zero-value days', () => {
- const result = fillMissingDays([
- { date: new Date(2023, 1, 1).getTime(), value: 10 },
- { date: new Date(2023, 1, 4).getTime(), value: 40 },
- ]);
-
- expect(result).toEqual([
- { date: new Date(2023, 1, 1).getTime(), value: 10 },
- { date: new Date(2023, 1, 2).getTime(), value: 0 },
- { date: new Date(2023, 1, 3).getTime(), value: 0 },
- { date: new Date(2023, 1, 4).getTime(), value: 40 },
- ]);
- });
-
- test('keeps a contiguous series unchanged', () => {
- const data = [
- { date: new Date(2023, 1, 1).getTime(), value: 10 },
- { date: new Date(2023, 1, 2).getTime(), value: 20 },
- ];
-
- expect(fillMissingDays(data)).toEqual(data);
- });
-});
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 8089e4f79..95b826661 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,6 +1,6 @@
import { Box, Paper } from "@mui/material";
import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
-import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { aggregatePerDay, chartPointsFor, fillMissingDays } from "@/components/Measurements/charts/data";
import React from "react";
import { useTranslation } from "react-i18next";
import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
@@ -38,47 +38,12 @@ const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
return null;
};
-/**
- * Sums entries per local calendar day, for metric types where individual
- * samples aren't meaningful on their own (steps, distance, energy, sleep)
- */
-export const aggregatePerDay = (entries: MeasurementEntry[]): { date: number, value: number }[] => {
- const sums = new Map();
- for (const entry of entries) {
- const day = new Date(entry.date.getFullYear(), entry.date.getMonth(), entry.date.getDate()).getTime();
- sums.set(day, (sums.get(day) ?? 0) + entry.value);
- }
-
- return [...sums.entries()]
- .map(([date, value]) => ({ date: date, value: value }))
- .sort((a, b) => a.date - b.date);
-};
-
-/**
- * Fills gaps in a per-day series with zero-value days so a band axis keeps
- * the spacing between bars proportional to time
- */
-export const fillMissingDays = (data: { date: number, value: number }[]): { date: number, value: number }[] => {
- if (data.length === 0) {
- return [];
- }
-
- const byDay = new Map(data.map(d => [d.date, d.value]));
- const last = data[data.length - 1].date;
- const out = [];
- // aggregatePerDay emits local-midnight timestamps; stepping via setDate
- // stays on local midnight across DST changes
- for (const day = new Date(data[0].date); day.getTime() <= last; day.setDate(day.getDate() + 1)) {
- out.push({ date: day.getTime(), value: byDay.get(day.getTime()) ?? 0 });
- }
- return out;
-};
-
const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
// 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.category.entries));
+ const points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
+ const data = fillMissingDays(aggregatePerDay(points));
return
@@ -103,15 +68,8 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
const MeasurementLineChart = (props: { category: MeasurementCategory }) => {
const NR_OF_ENTRIES_CHART_DOT = 30;
- // map the list of entries to an array of objects with the date and value
- const entryData = [...props.category.entries].sort((a, b) => a.date.getTime() - b.date.getTime()).map(entry => {
- return {
- date: entry.date.getTime(),
- value: entry.value,
- entry: entry
- };
- });
- const emaData = calculateEMA(entryData, p => p.value);
+ const points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
+ const emaData = calculateEMA(points, p => p.value);
return
@@ -176,13 +134,8 @@ const MeasurementGroupChart = (props: { category: MeasurementCategory }) => {
/>
{props.category.children.map(child => {
- const data = [...child.entries]
- .sort((a, b) => a.date.getTime() - b.date.getTime())
- .map(entry => ({
- date: entry.date.getTime(),
- value: entry.value,
- unit: child.unit,
- }));
+ const data = chartPointsFor(child.entries, child.unit, child.unit)
+ .map(point => ({ ...point, unit: child.unit }));
return
Date: Sat, 1 Aug 2026 00:21:24 +0200
Subject: [PATCH 18/71] Draw measurement charts from a list of typed series
---
public/locales/de/translation.json | 5 +-
public/locales/en/translation.json | 5 +-
public/locales/es/translation.json | 5 +-
public/locales/fr/translation.json | 5 +-
.../Measurements/widgets/MeasurementChart.tsx | 131 ++++---------
.../widgets/MeasurementSeriesChart.test.tsx | 69 +++++++
.../widgets/MeasurementSeriesChart.tsx | 173 ++++++++++++++++++
7 files changed, 297 insertions(+), 96 deletions(-)
create mode 100644 src/components/Measurements/widgets/MeasurementSeriesChart.test.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementSeriesChart.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 8aa5bb99d..ef9196025 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -270,7 +270,10 @@
"distance": "Distanz",
"energy": "Energie",
"sleep": "Schlaf"
- }
+ },
+ "indicatorRaw": "raw",
+ "indicatorAvg": "Durchschn.",
+ "indicatorTrend": "Trend"
},
"timeOfDay": "Uhrzeit",
"notes": "Notizen",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 937b84f37..f4b565e97 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -354,7 +354,10 @@
"distance": "Distance",
"energy": "Energy",
"sleep": "Sleep"
- }
+ },
+ "indicatorRaw": "raw",
+ "indicatorAvg": "avg",
+ "indicatorTrend": "trend"
},
"server": {
"abs": "Abs",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 1b6d6f111..b5d8b1555 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -272,7 +272,10 @@
"distance": "Distancia",
"energy": "Energía",
"sleep": "Sueño"
- }
+ },
+ "indicatorRaw": "Bruto",
+ "indicatorAvg": "medio",
+ "indicatorTrend": "tendencia"
},
"deleteConfirmation": "¿Estás seguro de que quieres borrar \"{{name}}\"?",
"nutrition": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index cacf4de42..923b02e00 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -355,7 +355,10 @@
"distance": "Distance",
"energy": "Énergie",
"sleep": "Sommeil"
- }
+ },
+ "indicatorRaw": "brut",
+ "indicatorAvg": "moy",
+ "indicatorTrend": "tendance"
},
"downloadAsPdf": "Télécharger en PDF",
"calendar": "Calendrier",
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 95b826661..a92d0e2e2 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,13 +1,20 @@
import { Box, Paper } from "@mui/material";
import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
-import { aggregatePerDay, chartPointsFor, fillMissingDays } from "@/components/Measurements/charts/data";
+import {
+ aggregatePerDay,
+ chartPointsFor,
+ downsample,
+ fillMissingDays,
+ groupComponentSeries,
+ moving7dAverage,
+ smoothedTrendline
+} from "@/components/Measurements/charts/data";
+import { ChartSeries } from "@/components/Measurements/charts/series";
+import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import React from "react";
-import { useTranslation } from "react-i18next";
-import { Bar, BarChart, CartesianGrid, Legend, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts";
+import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
import { theme } from "@/theme";
-import { generateChartColors } from "@/core/lib/colors";
import { dateToLocale } from "@/core/lib/date";
-import { calculateEMA } from "@/core/lib/ema";
export interface TooltipProps {
active?: boolean,
@@ -18,19 +25,14 @@ export interface TooltipProps {
}
const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
- const [t] = useTranslation();
-
if (active && payload && payload.length) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const value = payload.find((p: any) => p.dataKey === 'value');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const trend = payload.find((p: any) => p.dataKey === 'ema');
return (
{dateToLocale(new Date(label!))}
{value && {category.name}: {value.value} {category.unit}
}
- {trend && {t('trend')}: {trend.value.toFixed(1)} {category.unit}
}
);
}
@@ -65,98 +67,43 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
;
};
-const MeasurementLineChart = (props: { category: MeasurementCategory }) => {
- const NR_OF_ENTRIES_CHART_DOT = 30;
-
- const points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
- const emaData = calculateEMA(points, p => p.value);
-
- return
-
-
- NR_OF_ENTRIES_CHART_DOT ? false : { strokeWidth: 1, r: 4 }}
- activeDot={{
- stroke: 'black',
- strokeWidth: 1,
- r: 6,
- //onClick: handleClick
- }} />
-
- dateToLocale(new Date(timeStr))!}
- tickCount={10}
- />
-
- { )} />}
-
- ;
-};
-
/**
- * Renders all components of a multi-value group (e.g. systolic and diastolic
- * blood pressure) as series of one combined chart
+ * The values of a category with the average and trend derived from them.
+ *
+ * The points are condensed before anything is derived: a trend line over raw
+ * samples follows the swings within a single day instead of the trend across
+ * weeks, and the average would be as dense as the values it summarises. The
+ * average itself is computed over every point and only condensed afterwards,
+ * so it stays a 7-day average rather than an average of bucket means.
*/
-const MeasurementGroupChart = (props: { category: MeasurementCategory }) => {
- const colorGenerator = generateChartColors(props.category.children.length);
+const measurementSeries = (category: MeasurementCategory): ChartSeries[] => {
+ const points = chartPointsFor(category.entries, category.unit, category.unit);
+ const condensed = downsample(points);
+ const raw: ChartSeries = { points: condensed, role: 'raw' };
- return
-
-
- dateToLocale(new Date(timeStr))!}
- tickCount={10}
- />
-
- dateToLocale(new Date(label as number))!}
- formatter={(value, name, item) =>
- `${value} ${item.payload.unit || props.category.unit}`}
- />
-
- {props.category.children.map(child => {
- const data = chartPointsFor(child.entries, child.unit, child.unit)
- .map(point => ({ ...point, unit: child.unit }));
+ // A single reading has nothing to average or trend, and recharts draws a
+ // dot for a one-point series even where the dots are turned off
+ if (points.length < 2) {
+ return [raw];
+ }
- return ;
- })}
-
- ;
+ return [
+ raw,
+ { points: downsample(moving7dAverage(points)), role: 'average' },
+ { points: smoothedTrendline(condensed), role: 'trend' },
+ ];
};
export const MeasurementChart = (props: { category: MeasurementCategory }) => {
if (props.category.isGroup) {
- return ;
+ return ;
}
return isSummedPerDay(props.category.metricType)
?
- : ;
+ : ;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.test.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.test.tsx
new file mode 100644
index 000000000..ad83f12a6
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.test.tsx
@@ -0,0 +1,69 @@
+import { render } from '@testing-library/react';
+import { ChartPoint, ChartSeries } from "@/components/Measurements/charts/series";
+import { bandData, MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
+import React from 'react';
+import { describe, expect, test } from 'vitest';
+
+const ranged = (date: number, value: number, min: number, max: number): ChartPoint =>
+ ({ date: date, value: value, min: min, max: max });
+
+const plain = (date: number, value: number): ChartPoint => ({ date: date, value: value });
+
+const series = (role: ChartSeries['role'], points: ChartPoint[], label?: string): ChartSeries =>
+ ({ role: role, points: points, label: label });
+
+describe('bandData', () => {
+ test('is empty for a series without ranges', () => {
+ expect(bandData(series('raw', [plain(1, 10), plain(2, 20)]))).toEqual([]);
+ });
+
+ test('spans the bounds of the measured values', () => {
+ const band = bandData(series('raw', [ranged(1, 10, 5, 15)]));
+
+ expect(band).toStrictEqual([{ date: 1, range: [5, 15] }]);
+ });
+
+ test('is drawn for the components of a group', () => {
+ expect(bandData(series('component', [ranged(1, 10, 5, 15)], 'Systolic'))).toHaveLength(1);
+ });
+
+ test('is never drawn for a derived series', () => {
+ // condensing attaches a range to every point, so the average of a
+ // dense series carries one too — but it has no spread of its own
+ expect(bandData(series('average', [ranged(1, 10, 5, 15)]))).toEqual([]);
+ expect(bandData(series('trend', [ranged(1, 10, 5, 15)]))).toEqual([]);
+ });
+
+ test('is skipped when only some points carry a range', () => {
+ const band = bandData(series('raw', [ranged(1, 10, 5, 15), plain(2, 20)]));
+
+ expect(band).toEqual([]);
+ });
+});
+
+// Recharts only paints SVG content once a ResizeObserver entry reports real
+// dimensions, which jsdom does not provide, so we only assert it mounts
+describe('MeasurementSeriesChart', () => {
+ test('mounts the values with their average and trend', () => {
+ render( );
+ });
+
+ test('mounts the components of a group', () => {
+ render( );
+ });
+
+ test('mounts without any series', () => {
+ render( );
+ });
+});
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
new file mode 100644
index 000000000..acfc355e0
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -0,0 +1,173 @@
+import { Box, Paper, useTheme } from "@mui/material";
+import { Theme } from "@mui/material/styles";
+import { ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Area, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis } from "recharts";
+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;
+
+interface TooltipProps {
+ active?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload?: any;
+ label?: string;
+ unit: string;
+}
+
+const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ return (
+
+ {dateToLocale(new Date(Number(label)))}
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {payload.map((item: any) => (
+ {item.name}: {item.value.toFixed(1)} {unit}
+ ))}
+
+ );
+};
+
+/**
+ * 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 19/71] 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 20/71] 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 21/71] 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 (
{dateToLocale(new Date(label!))}
- {value && {category.name}: {value.value} {category.unit}
}
+ {value &&
+ {category.name}: {valueWithUnit(value.value, category.unit, i18n.language)}
+
}
);
}
@@ -42,12 +50,18 @@ const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
};
const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
+ 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 points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
const data = fillMissingDays(aggregatePerDay(points));
+ if (data.length === 0) {
+ return ;
+ }
+
return
{/*
* Bar width follows from how many bars share the width: recharts
@@ -62,9 +76,13 @@ const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
vertical={false} />
dateToLocale(new Date(timeStr))!}
+ tickFormatter={dateTick(spansYears(data))}
/>
-
+ valueWithUnit(value, props.category.unit, i18n.language)} />
)} />
{
+ const [, i18n] = useTranslation();
+
if (!active || !payload?.length) {
return null;
}
@@ -93,7 +113,10 @@ const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
{dateToLocale(new Date(Number(label)))}
{/* a range is quoted as high over low, the way a blood pressure reading is written */}
- {high}/{low} {unit}
+
+ {numberDecimalLocale(high, i18n.language)}/
+ {valueWithUnit(low, unit, i18n.language)}
+
);
};
@@ -107,6 +130,7 @@ const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
* matters, the gap within one reading.
*/
const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
+ const [, i18n] = useTranslation();
const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
return
@@ -117,9 +141,13 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
vertical={false} />
dateToLocale(new Date(timeStr))!}
+ tickFormatter={dateTick(spansYears(props.points))}
/>
-
+ valueWithUnit(value, props.unit, i18n.language)} />
} />
{
+ const [, i18n] = useTranslation();
+
if (!active || !payload?.length) {
return null;
}
@@ -29,7 +33,7 @@ const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
{dateToLocale(new Date(Number(label)))}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{payload.map((item: any) => (
- {item.name}: {item.value.toFixed(1)} {unit}
+ {item.name}: {valueWithUnit(item.value, unit, i18n.language)}
))}
);
@@ -104,7 +108,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 [t, i18n] = useTranslation();
const [chartRef, chartWidth] = useChartWidth();
const roleLabels: Record = {
@@ -127,7 +131,12 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
// 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 radius = dotRadius(chartWidth, maxPoints);
- const showLegend = props.series.some(s => s.label !== undefined);
+
+ if (maxPoints === 0) {
+ return ;
+ }
+
+ const withYear = spansYears(props.series.flatMap(s => s.points));
return
@@ -138,12 +147,14 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
dataKey="date"
type={'number'}
domain={['dataMin', 'dataMax']}
- tickFormatter={timeStr => dateToLocale(new Date(timeStr))!}
+ tickFormatter={dateTick(withYear)}
tickCount={10}
/>
-
+ valueWithUnit(value, props.unit, i18n.language)} />
} />
- {showLegend && }
{/* the bands go in first so the lines paint on top of them */}
{resolved.map(({ series, color, key }) => {
@@ -170,5 +181,18 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
name={name}
{...lineProps(series.role, color, radius)} />)}
+
+ {/*
+ * The legend is drawn outside the chart: recharts takes its swatch
+ * colour from the line's stroke, and the measured values have no
+ * stroke of their own, only dots
+ */}
+
+ {resolved.map(({ color, name, key }) =>
+
+
+ {name}
+ )}
+
;
};
diff --git a/src/core/lib/numbers.ts b/src/core/lib/numbers.ts
index 18be4d508..b0bf333c8 100644
--- a/src/core/lib/numbers.ts
+++ b/src/core/lib/numbers.ts
@@ -12,6 +12,14 @@ export function numberLocale(num: number, locale: string) {
return num.toLocaleString(locale, { maximumFractionDigits: 0 });
}
+/*
+ * 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
+ */
+export function numberDecimalLocale(num: number, locale: string) {
+ return num.toLocaleString(locale, { maximumFractionDigits: 2 });
+}
+
/*
* Formats a number with a unit, localised, no fraction digits
*
From f00d8bb41b2b2640d53a80afab099b4edfc15a35 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 00:57:15 +0200
Subject: [PATCH 22/71] Show the overall change and tie group rows to their
lines
---
public/locales/de/translation.json | 3 +-
public/locales/en/translation.json | 3 +-
public/locales/es/translation.json | 3 +-
public/locales/fr/translation.json | 3 +-
src/components/Dashboard/MeasurementCard.tsx | 41 ++++++++++++++---
src/components/Measurements/charts/colors.ts | 35 +++++++++++++++
src/components/Measurements/index.ts | 5 +++
.../MeasurementCategoryDetail.test.tsx | 4 +-
.../widgets/CategoryDetailDataGrid.tsx | 15 +++----
.../Measurements/widgets/MeasurementChart.tsx | 44 ++++++++++++-------
.../widgets/MeasurementSeriesChart.tsx | 22 +---------
11 files changed, 124 insertions(+), 54 deletions(-)
create mode 100644 src/components/Measurements/charts/colors.ts
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index ef9196025..d28d25c33 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -273,7 +273,8 @@
},
"indicatorRaw": "raw",
"indicatorAvg": "Durchschn.",
- "indicatorTrend": "Trend"
+ "indicatorTrend": "Trend",
+ "overallChangeWeight": "Allgemeine Veränderung"
},
"timeOfDay": "Uhrzeit",
"notes": "Notizen",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 9ec4e2b0d..337c86cbb 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -358,7 +358,8 @@
"indicatorRaw": "raw",
"indicatorAvg": "avg",
"indicatorTrend": "trend",
- "noDataAvailable": "No data available"
+ "noDataAvailable": "No data available",
+ "overallChangeWeight": "Overall change"
},
"server": {
"abs": "Abs",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index b5d8b1555..068e168ba 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -275,7 +275,8 @@
},
"indicatorRaw": "Bruto",
"indicatorAvg": "medio",
- "indicatorTrend": "tendencia"
+ "indicatorTrend": "tendencia",
+ "overallChangeWeight": "Cambio general"
},
"deleteConfirmation": "¿Estás seguro de que quieres borrar \"{{name}}\"?",
"nutrition": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 923b02e00..8f1f4be40 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -358,7 +358,8 @@
},
"indicatorRaw": "brut",
"indicatorAvg": "moy",
- "indicatorTrend": "tendance"
+ "indicatorTrend": "tendance",
+ "overallChangeWeight": "Changement global"
},
"downloadAsPdf": "Télécharger en PDF",
"calendar": "Calendrier",
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index 6eef1c245..6c730533c 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -3,13 +3,18 @@ import { DashboardCard } from "@/components/Dashboard/DashboardCard";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
import {
CategoryForm,
+ componentColor,
+ componentPalette,
+ groupChart,
MeasurementCategory,
MeasurementChart,
- useMeasurementsCategoryQuery
+ useMeasurementsCategoryQuery,
+ valueWithUnit
} 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";
import TableBody from "@mui/material/TableBody";
@@ -84,6 +89,12 @@ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) =>
const MeasurementCardTableContent = (props: { category: MeasurementCategory }) => {
const { t } = useTranslation();
+ // The dot ties a component row to its line in the chart above. A range is
+ // a single bar, where the ends speak for themselves.
+ const showComponentColors = props.category.isGroup
+ && groupChart(props.category).kind === 'components';
+ const palette = componentPalette(props.category.children.length);
+
return (<>
{props.category.name}
@@ -100,20 +111,40 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
{props.category.isGroup
// group parents hold no entries themselves, list the
// latest reading of each component instead
- ? props.category.children.map(child => {
+ ? props.category.children.map((child, index) => {
// entries arrive sorted by date descending
const latest = child.entries[0];
+ const unit = child.unit || props.category.unit;
+
return
- {child.name}
- {latest !== undefined ? `${latest.value} ${child.unit || props.category.unit}` : '—'}
+
+ {showComponentColors && }
+ {child.name}
+
+
+
+ {latest !== undefined
+ ? valueWithUnit(latest.valueIn(unit, unit), unit, i18n.language)
+ : '—'}
;
})
: [...props.category.entries].slice(0, 5).map(entry => (
{entry.date.toLocaleDateString()}
- {entry.value} {props.category.unit}
+
+ {valueWithUnit(
+ entry.valueIn(props.category.unit, props.category.unit),
+ props.category.unit,
+ i18n.language,
+ )}
+
))}
diff --git a/src/components/Measurements/charts/colors.ts b/src/components/Measurements/charts/colors.ts
new file mode 100644
index 000000000..04ba55c23
--- /dev/null
+++ b/src/components/Measurements/charts/colors.ts
@@ -0,0 +1,35 @@
+import { Theme } from "@mui/material/styles";
+import { ChartSeriesRole } from "@/components/Measurements/charts/series";
+import { generateChartColors } from "@/core/lib/colors";
+
+/**
+ * Colours the components of a group are drawn in, by position. Shared with the
+ * lists that name the components, so a row and its line match.
+ */
+export const componentPalette = (componentCount: number): string[] =>
+ [...generateChartColors(componentCount)];
+
+export const componentColor = (palette: string[], index: number): string =>
+ palette[index % palette.length];
+
+/**
+ * Colour of a series. Components are coloured by their position, the other
+ * roles have a fixed colour each.
+ */
+export 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 componentColor(palette, componentIndex);
+ }
+};
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 1eb4d8318..3f6533868 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -17,6 +17,11 @@ export { API_MEASUREMENTS_CATEGORY_PATH, API_MEASUREMENTS_ENTRY_PATH } from "./a
// Query hooks
export { useMeasurementsCategoryQuery } from "./queries";
+// Charts
+export { componentColor, componentPalette } from "./charts/colors";
+export { groupChart } from "./charts/data";
+export { valueWithUnit } from "./charts/format";
+
// Widgets
export { CategoryForm } from "./widgets/CategoryForm";
export { MeasurementChart } from "./widgets/MeasurementChart";
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
index 5c7d53a5d..8c07db01b 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
@@ -43,12 +43,12 @@ describe("Test the MeasurementCategoryDetail component", () => {
expect(useMeasurementsQuery).toHaveBeenCalled();
expect(screen.getByText('Biceps')).toBeInTheDocument();
- expect(screen.getByRole('gridcell', { name: /10cm/i })).toBeInTheDocument();
+ expect(screen.getByRole('gridcell', { name: /10 cm/i })).toBeInTheDocument();
// the entries now show date and time
expect(screen.getAllByText(/2\/1\/2023, 8:00 AM/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('test note')).toBeInTheDocument();
- expect(screen.getByRole('gridcell', { name: /20cm/i })).toBeInTheDocument();
+ expect(screen.getByRole('gridcell', { name: /20 cm/i })).toBeInTheDocument();
expect(screen.getByText(/2\/2\/2023, 7:45 AM/i)).toBeInTheDocument();
expect(screen.getByText('important note')).toBeInTheDocument();
});
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index cb6c05d35..fa094623d 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -1,4 +1,5 @@
import { processTimeSeries } from "@/core/lib/timeSeries";
+import { valueWithUnit } from "@/components/Measurements/charts/format";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
@@ -41,7 +42,7 @@ const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp =>
export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) => {
- const [t] = useTranslation();
+ const [t, i18n] = useTranslation();
const data: GridRowsProp = convertEntriesToObj(props.category.entries);
const updateEntryQuery = useEditMeasurementEntryQuery();
const deleteEntryQuery = useDeleteMeasurementsQuery();
@@ -102,14 +103,12 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
{
field: 'value',
headerName: t('value'),
- width: 80,
+ // wide enough for a grouped number plus its unit
+ width: 120,
editable: true,
- valueFormatter: (value?: number) => {
- if (value == null) {
- return '';
- }
- return value + props.category.unit;
- },
+ valueFormatter: (value?: number) => value == null
+ ? ''
+ : valueWithUnit(value, props.category.unit, i18n.language),
},
{
field: 'date',
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 846b7a8fb..0cda3dc5a 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,4 +1,4 @@
-import { Box, Paper } from "@mui/material";
+import { Box, Paper, Typography } from "@mui/material";
import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
import {
aggregatePerDay,
@@ -7,6 +7,7 @@ import {
fillMissingDays,
groupChart,
moving7dAverage,
+ overallChange,
smoothedTrendline
} from "@/components/Measurements/charts/data";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
@@ -166,22 +167,37 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
* average itself is computed over every point and only condensed afterwards,
* so it stays a 7-day average rather than an average of bucket means.
*/
-const measurementSeries = (category: MeasurementCategory): ChartSeries[] => {
- const points = chartPointsFor(category.entries, category.unit, category.unit);
+const MeasurementLineChart = (props: { category: MeasurementCategory }) => {
+ const [t, i18n] = useTranslation();
+
+ const points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
const condensed = downsample(points);
const raw: ChartSeries = { points: condensed, role: 'raw' };
// A single reading has nothing to average or trend, and recharts draws a
// dot for a one-point series even where the dots are turned off
- if (points.length < 2) {
- return [raw];
- }
-
- return [
- raw,
- { points: downsample(moving7dAverage(points)), role: 'average' },
- { points: smoothedTrendline(condensed), role: 'trend' },
- ];
+ const average = points.length < 2 ? [] : downsample(moving7dAverage(points));
+ const series: ChartSeries[] = points.length < 2
+ ? [raw]
+ : [
+ raw,
+ { points: average, role: 'average' },
+ { points: smoothedTrendline(condensed), role: 'trend' },
+ ];
+
+ // Read off the average rather than the values: the first and last reading
+ // are two arbitrary moments of a densely sampled metric
+ const change = overallChange(average);
+
+ return <>
+
+ {change !== null &&
+ {t('measurements.overallChangeWeight')}
+ {' '}
+ {change > 0 ? '+' : change < 0 ? '-' : ''}
+ {valueWithUnit(Math.abs(change), props.category.unit, i18n.language)}
+ }
+ >;
};
export const MeasurementChart = (props: { category: MeasurementCategory }) => {
@@ -195,7 +211,5 @@ export const MeasurementChart = (props: { category: MeasurementCategory }) => {
return isSummedPerDay(props.category.metricType)
?
- : ;
+ : ;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index 6543d3601..978250560 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -1,5 +1,5 @@
import { Box, Paper, Stack, Typography, useTheme } from "@mui/material";
-import { Theme } from "@mui/material/styles";
+import { componentPalette, seriesColor } from "@/components/Measurements/charts/colors";
import { dotRadius, useChartWidth } from "@/components/Measurements/charts/density";
import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
import { ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
@@ -7,7 +7,6 @@ import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptySta
import React from "react";
import { useTranslation } from "react-i18next";
import { Area, CartesianGrid, ComposedChart, Line, Tooltip, XAxis, YAxis } from "recharts";
-import { generateChartColors } from "@/core/lib/colors";
import { dateToLocale } from "@/core/lib/date";
/** Opacity of the band drawn around a series of ranged points */
@@ -39,23 +38,6 @@ const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
);
};
-/**
- * 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, radius: number) => {
switch (role) {
@@ -118,7 +100,7 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
component: '',
};
- const palette = [...generateChartColors(props.series.filter(s => s.role === 'component').length)];
+ const palette = componentPalette(props.series.filter(s => s.role === 'component').length);
let componentIndex = 0;
const resolved = props.series.map(series => ({
series: series,
From 614e22cd5595344b375b872cda743e3eeb983604 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 01:03:08 +0200
Subject: [PATCH 23/71] Fold a band's bounds into the row of its own line
---
.../widgets/MeasurementSeriesChart.tsx | 49 ++++++++++++++++---
1 file changed, 43 insertions(+), 6 deletions(-)
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index 978250560..a7066a135 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -8,6 +8,7 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Area, CartesianGrid, ComposedChart, Line, Tooltip, XAxis, YAxis } from "recharts";
import { dateToLocale } from "@/core/lib/date";
+import { numberDecimalLocale } from "@/core/lib/numbers";
/** Opacity of the band drawn around a series of ranged points */
const BAND_OPACITY = 0.15;
@@ -20,6 +21,36 @@ interface TooltipProps {
unit: string;
}
+/**
+ * One tooltip line per series. A band carries the same name as the line it
+ * belongs to, so its bounds join that line instead of appearing as a row of
+ * their own — where an array value would read as four numbers, not two.
+ */
+interface TooltipRow {
+ name: string;
+ value?: number;
+ range?: [number, number];
+}
+
+const tooltipRows = (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload: any[],
+): TooltipRow[] => {
+ const rows = new Map();
+
+ for (const item of payload) {
+ const row: TooltipRow = rows.get(item.name) ?? { name: item.name };
+ if (Array.isArray(item.value)) {
+ row.range = item.value as [number, number];
+ } else {
+ row.value = item.value;
+ }
+ rows.set(item.name, row);
+ }
+
+ return [...rows.values()];
+};
+
const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
const [, i18n] = useTranslation();
@@ -30,9 +61,13 @@ const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
return (
{dateToLocale(new Date(Number(label)))}
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {payload.map((item: any) => (
- {item.name}: {valueWithUnit(item.value, unit, i18n.language)}
+ {tooltipRows(payload).map(row => (
+
+ {row.name}
+ {row.value !== undefined && `: ${valueWithUnit(row.value, unit, i18n.language)}`}
+ {row.range !== undefined && ` (${numberDecimalLocale(row.range[0], i18n.language)}`
+ + `–${valueWithUnit(row.range[1], unit, i18n.language)})`}
+
))}
);
@@ -139,20 +174,22 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
} />
{/* the bands go in first so the lines paint on top of them */}
- {resolved.map(({ series, color, key }) => {
+ {resolved.map(({ series, color, name, key }) => {
const band = bandData(series);
return band.length === 0
? null
+ // the band shares the name of its line, which is what
+ // folds its bounds into that line's tooltip row
: ;
+ legendType="none" />;
})}
{resolved.map(({ series, color, name, key }) =>
From 04073b4d9a3f267bef6a9377924ca13c40b6baf3 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 01:18:08 +0200
Subject: [PATCH 24/71] Drop the WeightEntry model in favour of
MeasurementEntry
---
.../Components/CalendarComponent.test.tsx | 8 +-
.../Calendar/Components/CalendarComponent.tsx | 6 +-
.../Calendar/Components/Entries.test.tsx | 34 ++++++--
.../Calendar/Components/Entries.tsx | 6 +-
src/components/Dashboard/WeightCard.test.tsx | 8 +-
src/components/Dashboard/WeightCard.tsx | 20 ++++-
src/components/Measurements/models/Entry.ts | 4 +-
.../Nutrition/screens/BmiCalculator.test.tsx | 1 +
.../Nutrition/screens/BmiCalculator.tsx | 9 +-
src/components/Weight/api/weight.test.ts | 21 ++---
src/components/Weight/api/weight.ts | 22 ++---
.../Weight/forms/WeightForm.test.tsx | 30 +++----
src/components/Weight/forms/WeightForm.tsx | 30 +++++--
src/components/Weight/index.ts | 4 +-
.../Weight/models/WeightEntry.test.ts | 51 -----------
src/components/Weight/models/WeightEntry.ts | 87 -------------------
src/components/Weight/models/bodyWeight.ts | 27 ++++++
src/components/Weight/queries/index.ts | 9 +-
.../Weight/screens/BodyWeight.test.tsx | 7 +-
src/components/Weight/screens/BodyWeight.tsx | 22 ++++-
.../Weight/widgets/Table/index.test.tsx | 61 ++++++-------
src/components/Weight/widgets/Table/index.tsx | 24 +++--
.../TableDashboard/TableDashboard.test.tsx | 19 ++--
.../widgets/TableDashboard/TableDashboard.tsx | 9 +-
.../Weight/widgets/WeightChart/index.test.tsx | 31 +++----
.../Weight/widgets/WeightChart/index.tsx | 13 +--
src/tests/weight/testData.ts | 31 +++++--
27 files changed, 280 insertions(+), 314 deletions(-)
delete mode 100644 src/components/Weight/models/WeightEntry.test.ts
delete mode 100644 src/components/Weight/models/WeightEntry.ts
create mode 100644 src/components/Weight/models/bodyWeight.ts
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index f74eaf9c5..618bae980 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -1,12 +1,11 @@
import { MeasurementCategory, MeasurementEntry } from "@/components/Measurements";
-import { WeightEntry } from "@/components/Weight";
import { getMeasurementCategories } from "@/components/Measurements/api/measurements";
import { getNutritionalDiaryEntries } from "@/components/Nutrition/api/nutritionalDiary";
import { getSessions } from "@/components/Routines/api/session";
import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata";
import { testQueryClient } from "@/tests/queryClient";
-import { testBodyWeightCategory } from "@/tests/weight/testData";
+import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { dateToYYYYMMDD } from "@/core/lib/date";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -44,10 +43,7 @@ describe('CalendarComponent', () => {
(getBodyWeightCategory as Mock).mockImplementation(() => Promise.resolve(testBodyWeightCategory));
(getWeights as Mock).mockImplementation(() => Promise.resolve([
- new WeightEntry(
- new Date(currentYear, currentMonth, 2, 12, 0),
- 70
- ),
+ makeWeightEntry(new Date(currentYear, currentMonth, 2, 12, 0), 70),
]));
(getSessions as Mock).mockImplementation(() => Promise.resolve(
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 56f7bffe7..f11960f0e 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -2,10 +2,10 @@ import CalendarDayGrid from "@/components/Calendar/Components/CalendarDayGrid";
import CalendarHeader from "@/components/Calendar/Components/CalendarHeader";
import { CalendarMeasurement } from "@/components/Calendar/Helpers/CalendarMeasurement";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { useMeasurementsCategoryQuery } from "@/components/Measurements";
+import { MeasurementEntry, useMeasurementsCategoryQuery } from "@/components/Measurements";
import { DiaryEntry, useNutritionDiaryQuery } from "@/components/Nutrition";
import { useSessionsQuery, WorkoutSession } from "@/components/Routines";
-import { useBodyWeightQuery, WeightEntry } from "@/components/Weight";
+import { useBodyWeightQuery } from "@/components/Weight";
import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date";
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { Box, Card, CardContent, CardHeader, useMediaQuery, useTheme } from '@mui/material';
@@ -15,7 +15,7 @@ import Entries from './Entries';
export interface DayProps {
date: Date,
- weightEntry: WeightEntry | undefined,
+ weightEntry: MeasurementEntry | undefined,
measurements: CalendarMeasurement[],
nutritionLogs: DiaryEntry[],
workoutSession: WorkoutSession | undefined,
diff --git a/src/components/Calendar/Components/Entries.test.tsx b/src/components/Calendar/Components/Entries.test.tsx
index 5b6032c12..98997c00a 100644
--- a/src/components/Calendar/Components/Entries.test.tsx
+++ b/src/components/Calendar/Components/Entries.test.tsx
@@ -1,6 +1,9 @@
+import { QueryClientProvider } from '@tanstack/react-query';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { WeightEntry } from '@/components/Weight';
+import { MeasurementEntry } from "@/components/Measurements";
+import { testQueryClient } from "@/tests/queryClient";
+import { makeWeightEntry } from "@/tests/weight/testData";
import React from 'react';
import { dateToLocale } from "@/core/lib/date";
import { DayProps } from './CalendarComponent';
@@ -13,10 +16,7 @@ vi.mock('@/components/User/queries/profile', () => ({
describe('Entries Component', () => {
const mockDate = new Date('2025-4-25');
- const mockWeightEntry: WeightEntry = new WeightEntry(
- mockDate,
- 75.5
- );
+ const mockWeightEntry: MeasurementEntry = makeWeightEntry(mockDate, 75.5);
const defaultProps: DayProps = {
date: mockDate,
@@ -27,7 +27,11 @@ describe('Entries Component', () => {
};
test('Correctly shows date and title', () => {
- render( );
+ render(
+
+
+
+ );
expect(screen.getByText(/entries/i)).toBeInTheDocument();
expect(screen.getByText(dateToLocale(mockDate), { exact: false })).toBeInTheDocument();
@@ -39,7 +43,11 @@ describe('Entries Component', () => {
weightEntry: mockWeightEntry
};
- render( );
+ render(
+
+
+
+ );
expect(screen.getByText('weight')).toBeInTheDocument();
expect(screen.getByText('75.5 server.kg')).toBeInTheDocument();
@@ -53,7 +61,11 @@ describe('Entries Component', () => {
]
};
- render( );
+ render(
+
+
+
+ );
expect(screen.getByText('measurements.measurements')).toBeInTheDocument();
expect(screen.getByText('Chest size: 95 cm')).toBeInTheDocument();
@@ -68,7 +80,11 @@ describe('Entries Component', () => {
]
};
- render( );
+ render(
+
+
+
+ );
// Initially only the header is visible
expect(screen.getByText('measurements.measurements')).toBeInTheDocument();
diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx
index 352f95af4..b090695fc 100644
--- a/src/components/Calendar/Components/Entries.tsx
+++ b/src/components/Calendar/Components/Entries.tsx
@@ -12,7 +12,7 @@ import {
} from '@mui/material';
import React from 'react';
import { useTranslation } from "react-i18next";
-import { useDisplayWeightUnit } from "@/components/Weight";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight";
import { dateToLocale } from "@/core/lib/date";
import { DayProps } from "./CalendarComponent";
@@ -24,6 +24,8 @@ interface LogProps {
const Entries: React.FC = ({ selectedDay, isStandalone }) => {
const [t] = useTranslation();
const displayWeightUnit = useDisplayWeightUnit();
+ // Entries without their own unit fall back to the one of the category
+ const categoryUnit = useBodyWeightCategoryQuery().data?.unit ?? 'kg';
const [openMeasurements, setOpenMeasurements] = React.useState(false);
const [openSession, setOpenSession] = React.useState(false);
@@ -67,7 +69,7 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => {
}
diff --git a/src/components/Dashboard/WeightCard.test.tsx b/src/components/Dashboard/WeightCard.test.tsx
index c972cb5de..89dd1fa1c 100644
--- a/src/components/Dashboard/WeightCard.test.tsx
+++ b/src/components/Dashboard/WeightCard.test.tsx
@@ -1,9 +1,9 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
-import { useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Weight";
+import { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Weight";
import { WeightCard } from "@/components/Dashboard/WeightCard";
import { testQueryClient } from "@/tests/queryClient";
-import { testWeightEntries } from "@/tests/weight/testData";
+import { testBodyWeightCategory, testWeightEntries } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
vi.mock("@/components/Weight/queries");
@@ -18,6 +18,10 @@ describe("test the WeightCard component", () => {
data: testWeightEntries
}));
(useDisplayWeightUnit as Mock).mockReturnValue('kg');
+ (useBodyWeightCategoryQuery as Mock).mockImplementation(() => ({
+ isLoading: false,
+ data: testBodyWeightCategory
+ }));
});
afterEach(() => {
diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx
index d28b4f0bf..0601608e2 100644
--- a/src/components/Dashboard/WeightCard.tsx
+++ b/src/components/Dashboard/WeightCard.tsx
@@ -1,11 +1,12 @@
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
+import { MeasurementEntry } from "@/components/Measurements";
import {
+ useBodyWeightCategoryQuery,
useBodyWeightQuery,
useDisplayWeightUnit,
WeightChart,
- WeightEntry,
WeightForm,
WeightTableDashboard
} from "@/components/Weight";
@@ -31,12 +32,16 @@ export const WeightCard = () => {
} />
);
};
-export const WeightCardContent = (props: { entries: WeightEntry[] }) => {
+export const WeightCardContent = (props: { entries: MeasurementEntry[] }) => {
const [openModal, setOpenModal] = React.useState(false);
const handleOpenModal = () => setOpenModal(true);
const handleCloseModal = () => setOpenModal(false);
const [t, i18n] = useTranslation();
const displayUnit = useDisplayWeightUnit();
+ const categoryQuery = useBodyWeightCategoryQuery();
+
+ // Entries without their own unit fall back to the one of the category
+ const categoryUnit = categoryQuery.data?.unit ?? 'kg';
return (
<>
@@ -56,9 +61,16 @@ export const WeightCardContent = (props: { entries: WeightEntry[] }) => {
>
}
>
-
+
-
+
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index be6357a95..a53493756 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -54,7 +54,7 @@ export class MeasurementEntry {
: value;
}
- static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry {
+ static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry {
return new MeasurementEntry(
overrides?.id ?? other.id,
overrides?.category ?? other.category,
@@ -62,7 +62,7 @@ export class MeasurementEntry {
overrides?.value ?? other.value,
overrides?.notes ?? other.notes,
other.source,
- other.extraData,
+ overrides?.extraData ?? other.extraData,
);
}
diff --git a/src/components/Nutrition/screens/BmiCalculator.test.tsx b/src/components/Nutrition/screens/BmiCalculator.test.tsx
index 11df2c0e5..49e3fb4a6 100644
--- a/src/components/Nutrition/screens/BmiCalculator.test.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.test.tsx
@@ -10,6 +10,7 @@ vi.mock('@/components/Weight/queries', () => ({
isLoading: false,
data: [{ weight: 55, unit: 'kg', valueIn: () => 55, date: new Date() }], // Provide mock weight data
}),
+ useBodyWeightCategoryQuery: () => ({ isLoading: false, data: { unit: 'kg' } }),
}));
vi.mock('@/components/User/queries/profile', () => ({
diff --git a/src/components/Nutrition/screens/BmiCalculator.tsx b/src/components/Nutrition/screens/BmiCalculator.tsx
index 4f37062b0..7d3a95c5c 100644
--- a/src/components/Nutrition/screens/BmiCalculator.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.tsx
@@ -1,6 +1,6 @@
import { Box, Stack, TextField, Typography } from "@mui/material";
import Grid from "@mui/material/Grid";
-import { useBodyWeightQuery } from "@/components/Weight";
+import { useBodyWeightCategoryQuery, useBodyWeightQuery } from "@/components/Weight";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import { useProfileQuery } from "@/components/User";
@@ -24,7 +24,10 @@ export const BmiCalculator = () => {
const [t] = useTranslation();
const weightQuery = useBodyWeightQuery();
+ const categoryQuery = useBodyWeightCategoryQuery();
const profileQuery = useProfileQuery();
+ // Entries without their own unit fall back to the one of the category
+ const categoryUnit = categoryQuery.data?.unit ?? 'kg';
const [height, setHeight] = useState();
const [weight, setWeight] = useState();
@@ -32,9 +35,9 @@ export const BmiCalculator = () => {
// Set default weight from last weight entry, the BMI is always computed in kg
useEffect(() => {
if (weightQuery.data && weightQuery.data.length > 0) {
- setWeight(weightQuery.data[0].valueIn('kg'));
+ setWeight(weightQuery.data[0].valueIn('kg', categoryUnit));
}
- }, [weightQuery.data]);
+ }, [weightQuery.data, categoryUnit]);
useEffect(() => {
if (profileQuery.data?.height) {
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts
index da4a838bc..0f29ff17e 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Weight/api/weight.test.ts
@@ -1,6 +1,5 @@
import axios from "axios";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
-import { testBodyWeightCategory } from "@/tests/weight/testData";
+import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { createWeight, deleteWeight, getBodyWeightCategory, getWeights, updateWeight } from "./weight";
import type { Mock } from 'vitest';
@@ -87,8 +86,9 @@ describe("weight service tests", () => {
expect.anything()
);
expect(result).toStrictEqual([
- new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID, '', 'kg', 'user'),
- new WeightEntry(new Date('2021-12-20'), 90, ENTRY_UUID_2, '', 'lb', 'apple', { unit: 'lb' }),
+ // no unit of its own: the category unit applies when the value is read
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, source: 'user' }),
+ makeWeightEntry(new Date('2021-12-20'), 90, { id: ENTRY_UUID_2, unit: 'lb', source: 'apple' }),
]);
});
@@ -111,14 +111,15 @@ describe("weight service tests", () => {
test('PATCH weight entry', async () => {
// Arrange
- const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID);
+ const weightEntry = makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'kg' });
const weightResponse = {
data: {
id: ENTRY_UUID,
category: CATEGORY_UUID,
value: 80,
date: '2021-12-10',
- notes: ''
+ notes: '',
+ extra_data: { unit: 'kg' }
}
};
@@ -131,13 +132,13 @@ describe("weight service tests", () => {
const [url, body] = (axios.patch as Mock).mock.calls[0];
expect(url).toContain(`measurement/${ENTRY_UUID}`);
expect(body).toMatchObject({ value: 80, extra_data: { unit: 'kg' } });
- expect(result).toStrictEqual(new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID));
+ expect(result).toStrictEqual(makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'kg' }));
});
test('POST a new weight entry', async () => {
// Arrange
- const weightEntry = new WeightEntry(new Date('2021-12-10'), 80, undefined, '', 'lb');
+ const weightEntry = makeWeightEntry(new Date('2021-12-10'), 80, { unit: 'lb' });
const weightResponse = {
data: {
id: ENTRY_UUID,
@@ -151,14 +152,14 @@ describe("weight service tests", () => {
// Act
(axios.post as Mock).mockImplementation(() => Promise.resolve(weightResponse));
- const result = await createWeight(weightEntry, CATEGORY_UUID);
+ const result = await createWeight(weightEntry);
// Assert
expect(axios.post).toHaveBeenCalledTimes(1);
const [, body] = (axios.post as Mock).mock.calls[0];
expect(body).toMatchObject({ category: CATEGORY_UUID, value: 80, extra_data: { unit: 'lb' } });
expect(result).toStrictEqual(
- new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID, '', 'lb', 'user', { unit: 'lb' })
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'lb' })
);
});
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index 21d3160dc..1ba7f0746 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -2,12 +2,11 @@ import {
API_MEASUREMENTS_CATEGORY_PATH,
API_MEASUREMENTS_ENTRY_PATH,
MeasurementCategory,
+ MeasurementEntry,
METRIC_TYPE_BODY_WEIGHT
} from "@/components/Measurements";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import { ResponseType } from "@/core/api/responseType";
import { calculatePastDate } from '@/core/lib/date';
-import { WeightUnit } from "@/core/lib/weightUnit";
import { makeHeader, makeUrl } from "@/core/lib/url";
import { ApiMeasurementCategoryType, ApiMeasurementEntryType } from '@/types';
import axios from 'axios';
@@ -37,10 +36,8 @@ export const getBodyWeightCategory = async (): Promise => {
/*
* Fetch weight entries based on filter value
- *
- * Entries without their own unit in extra_data fall back to the category unit
*/
-export const getWeights = async (category: MeasurementCategory, filter: FilterType = ''): Promise => {
+export const getWeights = async (category: MeasurementCategory, filter: FilterType = ''): Promise => {
const date__gte = calculatePastDate(filter);
const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
@@ -55,8 +52,7 @@ export const getWeights = async (category: MeasurementCategory, filter: FilterTy
headers: makeHeader(),
});
- const fallbackUnit: WeightUnit = category.unit === 'lb' ? 'lb' : 'kg';
- return data.results.map(entry => WeightEntry.fromJson(entry, fallbackUnit));
+ return data.results.map(entry => MeasurementEntry.fromJson(entry));
};
/*
@@ -73,23 +69,23 @@ export const deleteWeight = async (id: string): Promise => {
/*
* Update a weight entry
*/
-export const updateWeight = async (entry: WeightEntry): Promise => {
- const response = await axios.patch(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: entry.id }), entry.toJson(), {
+export const updateWeight = async (entry: MeasurementEntry): Promise => {
+ const response = await axios.patch(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: entry.id! }), entry.toJson(), {
headers: makeHeader(),
});
- return WeightEntry.fromJson(response.data, entry.unit);
+ return MeasurementEntry.fromJson(response.data);
};
/*
* Add a new weight entry to the official body weight category
*/
-export const createWeight = async (entry: WeightEntry, categoryId: string): Promise => {
+export const createWeight = async (entry: MeasurementEntry): Promise => {
const response = await axios.post(
makeUrl(API_MEASUREMENTS_ENTRY_PATH),
- { ...entry.toJson(), category: categoryId },
+ entry.toJson(),
{ headers: makeHeader() },
);
- return WeightEntry.fromJson(response.data, entry.unit);
+ return MeasurementEntry.fromJson(response.data);
};
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx
index 5261fba71..e1b5e1313 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Weight/forms/WeightForm.test.tsx
@@ -1,9 +1,9 @@
+import { MeasurementEntry } from "@/components/Measurements";
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { useProfileQuery } from "@/components/User";
import { WeightForm } from "@/components/Weight/forms/WeightForm";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import {
useAddWeightEntryQuery,
useBodyWeightCategoryQuery,
@@ -12,7 +12,7 @@ import {
} from "@/components/Weight/queries";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
-import { testBodyWeightCategory } from "@/tests/weight/testData";
+import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
vi.mock("@/components/Weight/queries");
@@ -54,11 +54,7 @@ describe("Test WeightForm component", () => {
test('Passing an existing entry renders its values in the form', () => {
// Arrange
- const weightEntry = new WeightEntry(
- new Date('2021-12-10 17:00'),
- 80,
- ENTRY_UUID,
- );
+ const weightEntry = makeWeightEntry(new Date('2021-12-10 17:00'), 80, { id: ENTRY_UUID });
// Act
render(
@@ -81,11 +77,7 @@ describe("Test WeightForm component", () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
(useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- const weightEntry = new WeightEntry(
- new Date('2022-02-28'),
- 80,
- ENTRY_UUID
- );
+ const weightEntry = makeWeightEntry(new Date('2022-02-28'), 80, { id: ENTRY_UUID });
// Act
render(
@@ -103,13 +95,13 @@ describe("Test WeightForm component", () => {
await waitFor(() => {
expect(mutateEditMock).toHaveBeenCalled();
});
- const submitted = mutateEditMock.mock.calls[0][0] as WeightEntry;
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
expect(submitted).not.toBe(weightEntry);
- expect(Number(submitted.weight)).toBe(82);
+ expect(Number(submitted.value)).toBe(82);
expect(submitted.id).toBe(ENTRY_UUID);
// ...and does not mutate the passed-in entry (it comes from the query cache)
- expect(weightEntry.weight).toBe(80);
+ expect(weightEntry.value).toBe(80);
});
test('Creating a new weight entry', async () => {
@@ -147,7 +139,7 @@ describe("Test WeightForm component", () => {
const user = userEvent.setup();
render(
-
+
);
const weightInput = await screen.findByLabelText('weight');
@@ -190,9 +182,9 @@ describe("Test WeightForm component", () => {
// ...and can be submitted with the lb unit stamped
await user.click(screen.getByRole('button', { name: 'submit' }));
await waitFor(() => expect(mutateAddMock).toHaveBeenCalled());
- const submitted = mutateAddMock.mock.calls[0][0] as WeightEntry;
- expect(submitted.unit).toBe('lb');
- expect(Number(submitted.weight)).toBe(320);
+ const submitted = mutateAddMock.mock.calls[0][0] as MeasurementEntry;
+ expect(submitted.extraData.unit).toBe('lb');
+ expect(Number(submitted.value)).toBe(320);
// Act + Assert: 35 lb is below the lb minimum of 66
await user.clear(weightInput);
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index b04508c08..afdf34545 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -1,7 +1,8 @@
import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementEntry } from "@/components/Measurements";
+import { extraDataInUnit, weightUnitOf } from "@/components/Weight/models/bodyWeight";
import {
useAddWeightEntryQuery,
useBodyWeightCategoryQuery,
@@ -18,7 +19,7 @@ import { useTranslation } from "react-i18next";
import * as yup from 'yup';
interface WeightFormProps {
- weightEntry?: WeightEntry,
+ weightEntry?: MeasurementEntry,
closeFn?: () => void,
}
@@ -57,12 +58,14 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
return ;
}
+ const category = categoryQuery.data!;
+
return (
( {
// Edit existing weight entry
if (weightEntry) {
- editWeightQuery.mutate(WeightEntry.clone(
- weightEntry,
- { weight: values.weight, date: values.date, unit: values.unit }
- ));
+ editWeightQuery.mutate(MeasurementEntry.clone(weightEntry, {
+ value: values.weight,
+ date: values.date,
+ extraData: extraDataInUnit(weightEntry, values.unit),
+ }));
// Create a new weight entry
} else {
- addWeightQuery.mutate(new WeightEntry(values.date, values.weight, undefined, '', values.unit));
+ addWeightQuery.mutate(new MeasurementEntry(
+ null,
+ category.id!,
+ values.date,
+ values.weight,
+ '',
+ 'user',
+ { unit: values.unit },
+ ));
}
if (closeFn) {
diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts
index 6fa666d35..27cc9b712 100644
--- a/src/components/Weight/index.ts
+++ b/src/components/Weight/index.ts
@@ -8,6 +8,6 @@ export { BodyWeight } from "./screens/BodyWeight";
export { WeightForm } from "./forms/WeightForm";
export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard";
export { WeightChart } from "./widgets/WeightChart";
-export { WeightEntry } from "./models/WeightEntry";
-export { useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
+export { extraDataInUnit, weightUnitOf } from "./models/bodyWeight";
+export { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
export type { FilterType } from "./widgets/FilterButtons";
diff --git a/src/components/Weight/models/WeightEntry.test.ts b/src/components/Weight/models/WeightEntry.test.ts
deleted file mode 100644
index d135b15ad..000000000
--- a/src/components/Weight/models/WeightEntry.test.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { WeightEntry } from "./WeightEntry";
-
-describe('WeightEntry', () => {
-
- test('valueIn converts based on the unit the entry was stored in', () => {
- const kgEntry = new WeightEntry(new Date('2023-01-01'), 80, 'd-1', '', 'kg');
- const lbEntry = new WeightEntry(new Date('2023-01-02'), 90, 'd-2', '', 'lb');
-
- expect(kgEntry.valueIn('kg')).toBe(80);
- expect(kgEntry.valueIn('lb')).toBe(176.37);
- expect(lbEntry.valueIn('kg')).toBe(40.82);
- expect(lbEntry.valueIn('lb')).toBe(90);
- });
-
- test('unknown extra_data keys survive the json round trip', () => {
- const entry = WeightEntry.fromJson({
- id: 'd-1',
- date: '2023-01-01T12:00:00Z',
- value: '81.65',
-
- extra_data: { unit: 'kg', source_unit: 'lb', source_value: '180', device: 'Scale' },
- });
-
- // the provenance keys are kept, the unit follows the model field
- const cloned = WeightEntry.clone(entry, { unit: 'lb' });
- expect(cloned.toJson().extra_data).toStrictEqual({
- unit: 'lb',
-
- source_unit: 'lb',
-
- source_value: '180',
- device: 'Scale',
- });
- });
-
- test('an unexpected unit from the server falls back to the category unit', () => {
- const entry = WeightEntry.fromJson({
- id: 'd-1',
- date: '2023-01-01T12:00:00Z',
- value: '80',
- extra_data: { unit: 'stone' },
- }, 'lb');
-
- expect(entry.unit).toBe('lb');
- });
-
- test('only entries created by the user are editable', () => {
- expect(new WeightEntry(new Date(), 80).isEditable).toBe(true);
- expect(new WeightEntry(new Date(), 80, 'd-1', '', 'kg', 'apple').isEditable).toBe(false);
- });
-});
diff --git a/src/components/Weight/models/WeightEntry.ts b/src/components/Weight/models/WeightEntry.ts
deleted file mode 100644
index 9154947ba..000000000
--- a/src/components/Weight/models/WeightEntry.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-import { Adapter } from "@/core/lib/Adapter";
-import { convertWeight, isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
-
-/**
- * A body weight entry, stored on the server as a measurement in the user's
- * official body weight category. The id is the measurement's UUID.
- *
- * The weight is stored in the unit it was entered in: read it through
- * valueIn(), never directly.
- */
-export class WeightEntry {
-
- constructor(
- public date: Date,
- public weight: number,
- public id?: string,
- public notes: string = '',
- public unit: WeightUnit = 'kg',
- public source: string = 'user',
- public extraData: Record = {},
- ) {
- }
-
- /** Entries synced from a health app are managed by the source app */
- get isEditable(): boolean {
- return this.source === 'user';
- }
-
- static clone(other: WeightEntry, overrides?: Partial>): WeightEntry {
- return new WeightEntry(
- overrides?.date ?? other.date,
- overrides?.weight ?? other.weight,
- overrides?.id ?? other.id,
- overrides?.notes ?? other.notes,
- overrides?.unit ?? other.unit,
- other.source,
- other.extraData,
- );
- }
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- static fromJson(json: any, fallbackUnit: WeightUnit = 'kg') {
- return adapter.fromJson(json, fallbackUnit);
- }
-
- valueIn(unit: WeightUnit): number {
- return convertWeight(this.weight, this.unit, unit);
- }
-
- toJson() {
- return adapter.toJson(this);
- }
-}
-
-class WeightAdapter implements Adapter {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- fromJson(item: any, fallbackUnit: WeightUnit = 'kg'): WeightEntry {
- // narrow the server value instead of trusting the cast, an unexpected
- // unit would otherwise silently convert wrongly
- const serverUnit = item.extra_data?.unit;
- const unit: WeightUnit = isWeightUnit(serverUnit) ? serverUnit : fallbackUnit;
-
- return new WeightEntry(
- new Date(item.date),
- parseFloat(item.value),
- item.id,
- item.notes ?? '',
- unit,
- item.source ?? 'user',
- item.extra_data ?? {},
- );
- }
-
- toJson(item: WeightEntry) {
- return {
- date: item.date.toISOString(),
- value: item.weight,
- notes: item.notes,
- // The server replaces extra_data as a whole on update, so send
- // every stored key back and only override the unit
- // eslint-disable-next-line camelcase
- extra_data: { ...item.extraData, unit: item.unit },
- };
- }
-}
-
-const adapter = new WeightAdapter();
diff --git a/src/components/Weight/models/bodyWeight.ts b/src/components/Weight/models/bodyWeight.ts
new file mode 100644
index 000000000..046be7a30
--- /dev/null
+++ b/src/components/Weight/models/bodyWeight.ts
@@ -0,0 +1,27 @@
+import { MeasurementEntry } from "@/components/Measurements";
+import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
+
+/**
+ * Body weight is stored as a measurement in the user's official body weight
+ * category, so an entry is a plain MeasurementEntry. These two helpers hold
+ * what is specific to it: its value is in one of the two units the app knows,
+ * and that unit travels in extra_data.
+ */
+
+/** The unit an entry's value is stored in, narrowed to what we can convert */
+export const weightUnitOf = (entry: MeasurementEntry, categoryUnit: string): WeightUnit => {
+ const stored = entry.unitOrFallback(categoryUnit);
+
+ return isWeightUnit(stored) ? stored : 'kg';
+};
+
+/**
+ * The entry's extra_data with the unit its value is in.
+ *
+ * The server replaces extra_data as a whole on update, so the keys we do not
+ * know about have to be sent back along with it.
+ */
+export const extraDataInUnit = (
+ entry: MeasurementEntry,
+ unit: WeightUnit,
+): Record => ({ ...entry.extraData, unit: unit });
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index 97911707c..acb5d917a 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -1,5 +1,5 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementEntry } from "@/components/Measurements";
import {
createWeight,
deleteWeight,
@@ -63,10 +63,7 @@ export const useAddWeightEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async (weightEntry: WeightEntry) => {
- const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
- return createWeight(weightEntry, category.id!);
- },
+ mutationFn: (weightEntry: MeasurementEntry) => createWeight(weightEntry),
onSuccess: () => queryClient.invalidateQueries({
queryKey: [QueryKey.BODY_WEIGHT,]
})
@@ -77,7 +74,7 @@ export const useEditWeightEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: (data: WeightEntry) => updateWeight(data),
+ mutationFn: (data: MeasurementEntry) => updateWeight(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QueryKey.BODY_WEIGHT,]
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx
index 942da8d76..437a499ac 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Weight/screens/BodyWeight.test.tsx
@@ -1,9 +1,8 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { testQueryClient } from "@/tests/queryClient";
-import { testBodyWeightCategory } from "@/tests/weight/testData";
+import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { BodyWeight } from "./BodyWeight";
import { FilterType } from "../widgets/FilterButtons";
import type { Mock } from 'vitest';
@@ -28,8 +27,8 @@ describe("Test BodyWeight component", () => {
// Arrange
const weightData = [
- new WeightEntry(new Date('2021-12-10'), 80, 'dddddddd-dddd-dddd-dddd-000000000001'),
- new WeightEntry(new Date('2021-12-20'), 90, 'dddddddd-dddd-dddd-dddd-000000000002'),
+ 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' }),
];
test('renders without crashing', async () => {
diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx
index 646e65bb2..91cd709da 100644
--- a/src/components/Weight/screens/BodyWeight.tsx
+++ b/src/components/Weight/screens/BodyWeight.tsx
@@ -1,5 +1,9 @@
import { Box, Stack } from "@mui/material";
-import { useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
+import {
+ useBodyWeightCategoryQuery,
+ useBodyWeightQuery,
+ useDisplayWeightUnit
+} from "@/components/Weight/queries";
import { WeightTable } from "@/components/Weight/widgets/Table";
import { WeightChart } from "@/components/Weight/widgets/WeightChart";
import { AddBodyWeightEntryFab } from "@/components/Weight/widgets/fab";
@@ -15,24 +19,34 @@ export const BodyWeight = () => {
const [t] = useTranslation();
const [filter, setFilter] = useState('lastYear');
const weightyQuery = useBodyWeightQuery(filter);
+ const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
const handleFilterChange = (newFilter: FilterType) => {
setFilter(newFilter);
};
- if (weightyQuery.isLoading) {
+ if (weightyQuery.isLoading || categoryQuery.isLoading) {
return ;
}
+ // Entries without their own unit fall back to the one of the category
+ const categoryUnit = categoryQuery.data!.unit;
+
return
{weightyQuery.data!.length === 0 && }
{weightyQuery.data!.length !== 0 && <>
-
+
-
+
>}
}
diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx
index 295186e5b..f28f088c3 100644
--- a/src/components/Weight/widgets/Table/index.test.tsx
+++ b/src/components/Weight/widgets/Table/index.test.tsx
@@ -1,7 +1,8 @@
+import { MeasurementEntry } from "@/components/Measurements";
+import { makeWeightEntry } from "@/tests/weight/testData";
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
import { BrowserRouter } from "react-router-dom";
import { testQueryClient } from "@/tests/queryClient";
@@ -10,11 +11,11 @@ import { WeightTable } from './index';
vi.mock("@/components/Weight/queries");
-const renderTable = (weights: WeightEntry[]) =>
+const renderTable = (weights: MeasurementEntry[]) =>
render(
-
+
);
@@ -31,9 +32,9 @@ describe("Body weight table", () => {
});
test('renders rows for all weight entries', async () => {
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1),
- new WeightEntry(new Date('2021/12/20'), 90, ENTRY_UUID_2),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 }),
+ makeWeightEntry(new Date('2021/12/20'), 90, { id: ENTRY_UUID_2 }),
];
renderTable(weights);
@@ -43,10 +44,10 @@ describe("Body weight table", () => {
});
test('displays total change column correctly', async () => {
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1),
- new WeightEntry(new Date('2021/12/20'), 90, ENTRY_UUID_2),
- new WeightEntry(new Date('2021/12/25'), 85, ENTRY_UUID_3),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 }),
+ makeWeightEntry(new Date('2021/12/20'), 90, { id: ENTRY_UUID_2 }),
+ makeWeightEntry(new Date('2021/12/25'), 85, { id: ENTRY_UUID_3 }),
];
renderTable(weights);
@@ -65,7 +66,7 @@ describe("Body weight table", () => {
});
test('shows inline edit and delete actions per row', async () => {
- const weights: WeightEntry[] = [new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1)];
+ const weights: MeasurementEntry[] = [makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 })];
renderTable(weights);
await screen.findByText('80');
@@ -75,9 +76,9 @@ describe("Body weight table", () => {
test('converts mixed units to the display unit, including aggregations', async () => {
// 90 lb = 40.82 kg, entered a day after the 80 kg entry
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1, '', 'kg'),
- new WeightEntry(new Date('2021/12/11'), 90, ENTRY_UUID_2, '', 'lb'),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
+ makeWeightEntry(new Date('2021/12/11'), 90, { id: ENTRY_UUID_2, unit: 'lb' }),
];
renderTable(weights);
@@ -98,8 +99,8 @@ describe("Body weight table", () => {
const mutateEditMock = vi.fn();
(useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
// stored as 90 lb, displayed as 40.82 kg
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 90, ENTRY_UUID_1, '', 'lb'),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
];
renderTable(weights);
@@ -109,17 +110,17 @@ describe("Body weight table", () => {
// the displayed conversion must not be written back to the entry
expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as WeightEntry;
- expect(Number(submitted.weight)).toBe(90);
- expect(submitted.unit).toBe('lb');
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(90);
+ expect(submitted.extraData.unit).toBe('lb');
});
test('editing the weight cell stamps the display unit', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
(useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 90, ENTRY_UUID_1, '', 'lb'),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
];
renderTable(weights);
@@ -134,17 +135,17 @@ describe("Body weight table", () => {
await user.click(screen.getByRole('menuitem', { name: /save/i }));
expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as WeightEntry;
- expect(Number(submitted.weight)).toBe(41);
- expect(submitted.unit).toBe('kg');
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(41);
+ expect(submitted.extraData.unit).toBe('kg');
});
test('implausible inline edits are rejected and the row stays editable', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
(useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1, '', 'kg'),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
];
renderTable(weights);
@@ -166,13 +167,13 @@ describe("Body weight table", () => {
await user.type(weightInput, '90');
await user.click(screen.getByRole('menuitem', { name: /save/i }));
expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as WeightEntry;
- expect(Number(submitted.weight)).toBe(90);
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(90);
});
test('entries synced from a health app offer no edit or delete actions', async () => {
- const weights: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1, '', 'kg', 'apple'),
+ const weights: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg', source: 'apple' }),
];
renderTable(weights);
diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx
index 92aa2372c..77415c3f6 100644
--- a/src/components/Weight/widgets/Table/index.tsx
+++ b/src/components/Weight/widgets/Table/index.tsx
@@ -16,7 +16,8 @@ import {
GridRowModesModel,
GridRowsProp,
} from "@mui/x-data-grid";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementEntry } from "@/components/Measurements";
+import { extraDataInUnit } from "@/components/Weight/models/bodyWeight";
import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab";
import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
import { processTimeSeries } from "@/core/lib/timeSeries";
@@ -28,24 +29,25 @@ import { PAGINATION_OPTIONS } from "@/core/lib/consts";
import { luxonDateTimeToLocale } from "@/core/lib/date";
export interface WeightTableProps {
- weights: WeightEntry[];
+ weights: MeasurementEntry[];
unit: WeightUnit;
+ categoryUnit: string;
}
-const buildRows = (weights: WeightEntry[], unit: WeightUnit): GridRowsProp =>
- processTimeSeries(weights, e => e.valueIn(unit)).map((row) => ({
+const buildRows = (weights: MeasurementEntry[], unit: WeightUnit, categoryUnit: string): GridRowsProp =>
+ processTimeSeries(weights, e => e.valueIn(unit, categoryUnit)).map((row) => ({
id: row.entry.id,
date: row.entry.date,
- weight: row.entry.valueIn(unit),
+ weight: row.entry.valueIn(unit, categoryUnit),
isEditable: row.entry.isEditable,
change: +row.change.toFixed(2),
totalChange: +row.totalChange.toFixed(2),
days: +row.days.toFixed(1),
}));
-export const WeightTable = ({ weights, unit }: WeightTableProps) => {
+export const WeightTable = ({ weights, unit, categoryUnit }: WeightTableProps) => {
const [t] = useTranslation();
- const rows = buildRows(weights, unit);
+ const rows = buildRows(weights, unit, categoryUnit);
const editEntryQuery = useEditWeightEntryQuery();
const deleteEntryQuery = useDeleteWeightEntryQuery();
const [rowModesModel, setRowModesModel] = useState({});
@@ -85,7 +87,7 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => {
// and unit, so both only change when the weight cell was edited: the
// typed value is then stamped with the display unit the column shows
if (Number(newRow.weight) === Number(oldRow.weight)) {
- editEntryQuery.mutate(WeightEntry.clone(entry, { date }));
+ editEntryQuery.mutate(MeasurementEntry.clone(entry, { date: date }));
} else {
// the typed value is in the display unit the column header shows;
// throwing keeps the row in edit mode so it can be corrected
@@ -97,7 +99,11 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => {
if (weight > max) {
throw new Error(t('forms.maxValue', { value: `${max} ${t(`server.${unit}`)}` }));
}
- editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight, unit: unit }));
+ editEntryQuery.mutate(MeasurementEntry.clone(entry, {
+ date: date,
+ value: weight,
+ extraData: extraDataInUnit(entry, unit),
+ }));
}
return newRow;
};
diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
index cb9cfd59c..398e68921 100644
--- a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
+++ b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
@@ -1,18 +1,19 @@
+import { MeasurementEntry } from "@/components/Measurements";
+import { makeWeightEntry } from "@/tests/weight/testData";
import React from 'react';
import { render, screen } from '@testing-library/react';
import { WeightTableDashboard } from '@/components/Weight/widgets/TableDashboard/TableDashboard';
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
describe("Body weight test", () => {
test('renders without crashing', async () => {
- const weightsData: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, 'd-1'),
- new WeightEntry(new Date('2021/12/20'), 90, 'd-2'),
+ const weightsData: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: 'd-1' }),
+ makeWeightEntry(new Date('2021/12/20'), 90, { id: 'd-2' }),
];
// since I used context api to provide state, also need it here
- render( );
+ render( );
// Both weights are found in th document
const weightRow = await screen.findByText('80');
@@ -24,12 +25,12 @@ describe("Body weight test", () => {
test('converts entries stored in other units to the display unit', async () => {
- const weightsData: WeightEntry[] = [
- new WeightEntry(new Date('2021/12/10'), 80, 'd-1', '', 'kg'),
- new WeightEntry(new Date('2021/12/20'), 90, 'd-2', '', 'lb'),
+ const weightsData: MeasurementEntry[] = [
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: 'd-1', unit: 'kg' }),
+ makeWeightEntry(new Date('2021/12/20'), 90, { id: 'd-2', unit: 'lb' }),
];
- render( );
+ render( );
expect(await screen.findByText('80')).toBeInTheDocument();
expect(await screen.findByText('40.82')).toBeInTheDocument();
diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx b/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx
index a39da1f1f..a4a5d23c6 100644
--- a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx
+++ b/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx
@@ -1,6 +1,6 @@
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
import { styled } from '@mui/material/styles';
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementEntry } from "@/components/Measurements";
import React from 'react';
import { useTranslation } from "react-i18next";
import { dateTimeToLocale } from "@/core/lib/date";
@@ -26,11 +26,12 @@ const Root = styled('div')(() => {
export interface WeightTableProps {
- weights: WeightEntry[];
+ weights: MeasurementEntry[];
unit: WeightUnit;
+ categoryUnit: string;
}
-export const WeightTableDashboard = ({ weights, unit }: WeightTableProps) => {
+export const WeightTableDashboard = ({ weights, unit, categoryUnit }: WeightTableProps) => {
const [t] = useTranslation();
const WEIGHT_ENTRIES_TO_SHOW = 5;
@@ -51,7 +52,7 @@ export const WeightTableDashboard = ({ weights, unit }: WeightTableProps) => {
{filteredWeight.map((row) => (
{dateTimeToLocale(row.date)}
- {row.valueIn(unit)}
+ {row.valueIn(unit, categoryUnit)}
))}
diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Weight/widgets/WeightChart/index.test.tsx
index 0591ceec0..54d91bf6a 100644
--- a/src/components/Weight/widgets/WeightChart/index.test.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.test.tsx
@@ -1,6 +1,7 @@
+import { MeasurementEntry } from "@/components/Measurements";
+import { makeWeightEntry } from "@/tests/weight/testData";
import { QueryClientProvider } from "@tanstack/react-query";
import { render } from '@testing-library/react';
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
import React from 'react';
import { describe, test } from 'vitest';
import { testQueryClient } from "@/tests/queryClient";
@@ -11,18 +12,18 @@ import { buildWeightData, WeightChart } from "./index";
// dimensions, which neither happy-dom nor jsdom provide. We therefore only
// assert the chart mounts; the EMA logic is covered in ema.test.ts.
-const renderChart = (weights: WeightEntry[], height?: number) =>
+const renderChart = (weights: MeasurementEntry[], height?: number) =>
render(
-
+
);
describe("WeightChart", () => {
test('mounts with weight data', () => {
renderChart([
- new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
- new WeightEntry(new Date('2021-12-20'), 90, 'd-2'),
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1' }),
+ makeWeightEntry(new Date('2021-12-20'), 90, { id: 'd-2' }),
]);
});
@@ -31,22 +32,22 @@ describe("WeightChart", () => {
});
test('mounts with a single entry', () => {
- renderChart([new WeightEntry(new Date('2021-12-10'), 80, 'd-1')]);
+ renderChart([makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1' })]);
});
test('mounts with unsorted data', () => {
renderChart([
- new WeightEntry(new Date('2021-12-20'), 90, 'd-2'),
- new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
- new WeightEntry(new Date('2021-12-15'), 85, 'd-3'),
+ makeWeightEntry(new Date('2021-12-20'), 90, { id: 'd-2' }),
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1' }),
+ makeWeightEntry(new Date('2021-12-15'), 85, { id: 'd-3' }),
]);
});
test('respects the height prop', () => {
renderChart(
[
- new WeightEntry(new Date('2021-12-10'), 80, 'd-1'),
- new WeightEntry(new Date('2021-12-20'), 85, 'd-2'),
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1' }),
+ makeWeightEntry(new Date('2021-12-20'), 85, { id: 'd-2' }),
],
500,
);
@@ -56,11 +57,11 @@ describe("WeightChart", () => {
describe("buildWeightData", () => {
test('converts mixed units to the display unit before plotting', () => {
const weights = [
- new WeightEntry(new Date('2021-12-20'), 90, 'd-2', '', 'lb'),
- new WeightEntry(new Date('2021-12-10'), 80, 'd-1', '', 'kg'),
+ makeWeightEntry(new Date('2021-12-20'), 90, { id: 'd-2', unit: 'lb' }),
+ makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1', unit: 'kg' }),
];
- expect(buildWeightData(weights, 'kg').map(d => d.weight)).toStrictEqual([80, 40.82]);
- expect(buildWeightData(weights, 'lb').map(d => d.weight)).toStrictEqual([176.37, 90]);
+ expect(buildWeightData(weights, 'kg', 'kg').map(d => d.weight)).toStrictEqual([80, 40.82]);
+ expect(buildWeightData(weights, 'lb', 'kg').map(d => d.weight)).toStrictEqual([176.37, 90]);
});
});
diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx
index 92b9e6043..2c3c38911 100644
--- a/src/components/Weight/widgets/WeightChart/index.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.tsx
@@ -1,4 +1,4 @@
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementEntry } from "@/components/Measurements";
import { dateToLocale } from "@/core/lib/date";
import { calculateEMA } from "@/core/lib/ema";
import { WeightUnit } from "@/core/lib/weightUnit";
@@ -26,8 +26,9 @@ interface EMADataPoint {
}
export interface WeightChartProps {
- weights: WeightEntry[],
+ weights: MeasurementEntry[],
unit: WeightUnit,
+ categoryUnit: string,
height?: number,
}
@@ -35,12 +36,12 @@ export interface WeightChartProps {
* Chart data points in the display unit; entries may be stored in mixed
* units, so every value is converted before anything is derived from it
*/
-export const buildWeightData = (weights: WeightEntry[], unit: WeightUnit) =>
+export const buildWeightData = (weights: MeasurementEntry[], unit: WeightUnit, categoryUnit: string) =>
[...weights]
.sort((a, b) => a.date.getTime() - b.date.getTime())
.map(weight => ({
date: weight.date.getTime(),
- weight: weight.valueIn(unit),
+ weight: weight.valueIn(unit, categoryUnit),
}));
export interface TooltipProps {
@@ -109,11 +110,11 @@ const VarianceLines = ({ emaData }: { emaData: EMADataPoint[] }) => {
);
};
-export const WeightChart = ({ weights, unit, height = 300 }: WeightChartProps) => {
+export const WeightChart = ({ weights, unit, categoryUnit, height = 300 }: WeightChartProps) => {
const theme = useTheme();
const [t] = useTranslation();
- const weightData = buildWeightData(weights, unit);
+ const weightData = buildWeightData(weights, unit, categoryUnit);
const emaData = calculateEMA(weightData, p => p.weight, 10);
diff --git a/src/tests/weight/testData.ts b/src/tests/weight/testData.ts
index 3f812a474..097f669e3 100644
--- a/src/tests/weight/testData.ts
+++ b/src/tests/weight/testData.ts
@@ -1,5 +1,5 @@
-import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
-import { WeightEntry } from "@/components/Weight/models/WeightEntry";
+import { MeasurementCategory, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
+import { WeightUnit } from "@/core/lib/weightUnit";
export const TEST_BODY_WEIGHT_CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000042';
@@ -12,8 +12,29 @@ export const testBodyWeightCategory = new MeasurementCategory(
true,
);
-export const testWeightEntry1 = new WeightEntry(new Date('2023-11-01'), 100, 'dddddddd-dddd-dddd-dddd-000000000001');
-export const testWeightEntry2 = new WeightEntry(new Date('2023-10-01'), 90, 'dddddddd-dddd-dddd-dddd-000000000002');
-export const testWeightEntry3 = new WeightEntry(new Date('2023-09-01'), 110, 'dddddddd-dddd-dddd-dddd-000000000003');
+/**
+ * A body weight entry as the API delivers it. The unit an entry is stored in
+ * travels in extra_data; without it the category unit applies.
+ */
+export const makeWeightEntry = (
+ date: Date,
+ value: number,
+ options: { id?: string, unit?: WeightUnit, source?: string, extraData?: Record } = {},
+) => new MeasurementEntry(
+ options.id ?? null,
+ TEST_BODY_WEIGHT_CATEGORY_UUID,
+ date,
+ value,
+ '',
+ options.source ?? 'user',
+ { ...options.extraData, ...(options.unit ? { unit: options.unit } : {}) },
+);
+
+const weightEntry = (id: string, date: string, value: number) =>
+ makeWeightEntry(new Date(date), value, { id: id });
+
+export const testWeightEntry1 = weightEntry('dddddddd-dddd-dddd-dddd-000000000001', '2023-11-01', 100);
+export const testWeightEntry2 = weightEntry('dddddddd-dddd-dddd-dddd-000000000002', '2023-10-01', 90);
+export const testWeightEntry3 = weightEntry('dddddddd-dddd-dddd-dddd-000000000003', '2023-09-01', 110);
export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3];
From 6e3a4895dfde20510bb087c6d383a592c9c9b8a7 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 01:32:30 +0200
Subject: [PATCH 25/71] Chart body weight with the shared measurement chart
---
src/components/Measurements/charts/data.ts | 35 +++
src/components/Measurements/index.ts | 4 +-
.../Measurements/widgets/MeasurementChart.tsx | 51 +----
.../widgets/MeasurementSeriesChart.tsx | 109 ++++++++-
.../Measurements/widgets/OverallChange.tsx | 28 +++
.../Weight/widgets/WeightChart/index.test.tsx | 15 +-
.../Weight/widgets/WeightChart/index.tsx | 213 ++----------------
7 files changed, 210 insertions(+), 245 deletions(-)
create mode 100644 src/components/Measurements/widgets/OverallChange.tsx
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 708d696d2..79d2927be 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -249,6 +249,41 @@ export const groupComponentSeries = (group: MeasurementCategory): ChartSeries[]
label: child.name,
}));
+/**
+ * The values of a category with the average and trend derived from them.
+ *
+ * The points are condensed before anything is derived: a trend line over raw
+ * samples follows the swings within a single day instead of the trend across
+ * weeks, and the average would be as dense as the values it summarises. The
+ * average itself is computed over every point and only condensed afterwards,
+ * so it stays a 7-day average rather than an average of bucket means.
+ */
+export const measurementSeries = (
+ entries: MeasurementEntry[],
+ targetUnit: string,
+ categoryUnit: string,
+): ChartSeries[] => {
+ const points = chartPointsFor(entries, targetUnit, categoryUnit);
+ const condensed = downsample(points);
+ const raw: ChartSeries = { points: condensed, role: 'raw' };
+
+ // A single reading has nothing to average or trend, and recharts draws a
+ // dot for a one-point series even where the dots are turned off
+ if (points.length < 2) {
+ return [raw];
+ }
+
+ return [
+ raw,
+ { points: downsample(moving7dAverage(points)), role: 'average' },
+ { points: smoothedTrendline(condensed), role: 'trend' },
+ ];
+};
+
+/** The points of the series with the given role, empty when there is none */
+export const pointsOfRole = (series: ChartSeries[], role: ChartSeries['role']): ChartPoint[] =>
+ series.find(s => s.role === role)?.points ?? [];
+
/**
* How the readings of a group are charted.
*
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 3f6533868..32ad5dc88 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -19,9 +19,11 @@ export { useMeasurementsCategoryQuery } from "./queries";
// Charts
export { componentColor, componentPalette } from "./charts/colors";
-export { groupChart } from "./charts/data";
+export { groupChart, measurementSeries } from "./charts/data";
export { valueWithUnit } from "./charts/format";
// Widgets
export { CategoryForm } from "./widgets/CategoryForm";
export { MeasurementChart } from "./widgets/MeasurementChart";
+export { MeasurementSeriesChart } from "./widgets/MeasurementSeriesChart";
+export { OverallChange } from "./widgets/OverallChange";
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 0cda3dc5a..bed208082 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,20 +1,18 @@
-import { Box, Paper, Typography } from "@mui/material";
+import { Box, Paper } from "@mui/material";
import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
import {
aggregatePerDay,
chartPointsFor,
- downsample,
fillMissingDays,
groupChart,
- moving7dAverage,
- overallChange,
- smoothedTrendline
+ measurementSeries
} 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 { ChartPoint } from "@/components/Measurements/charts/series";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
+import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
import React from "react";
import { useTranslation } from "react-i18next";
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
@@ -158,45 +156,16 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
;
};
-/**
- * The values of a category with the average and trend derived from them.
- *
- * The points are condensed before anything is derived: a trend line over raw
- * samples follows the swings within a single day instead of the trend across
- * weeks, and the average would be as dense as the values it summarises. The
- * average itself is computed over every point and only condensed afterwards,
- * so it stays a 7-day average rather than an average of bucket means.
- */
const MeasurementLineChart = (props: { category: MeasurementCategory }) => {
- const [t, i18n] = useTranslation();
-
- const points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
- const condensed = downsample(points);
- const raw: ChartSeries = { points: condensed, role: 'raw' };
-
- // A single reading has nothing to average or trend, and recharts draws a
- // dot for a one-point series even where the dots are turned off
- const average = points.length < 2 ? [] : downsample(moving7dAverage(points));
- const series: ChartSeries[] = points.length < 2
- ? [raw]
- : [
- raw,
- { points: average, role: 'average' },
- { points: smoothedTrendline(condensed), role: 'trend' },
- ];
-
- // Read off the average rather than the values: the first and last reading
- // are two arbitrary moments of a densely sampled metric
- const change = overallChange(average);
+ const series = measurementSeries(
+ props.category.entries,
+ props.category.unit,
+ props.category.unit,
+ );
return <>
- {change !== null &&
- {t('measurements.overallChangeWeight')}
- {' '}
- {change > 0 ? '+' : change < 0 ? '-' : ''}
- {valueWithUnit(Math.abs(change), props.category.unit, i18n.language)}
- }
+
>;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index a7066a135..d7811ccbd 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -1,18 +1,33 @@
import { Box, Paper, Stack, Typography, useTheme } from "@mui/material";
import { componentPalette, seriesColor } from "@/components/Measurements/charts/colors";
+import { pointsOfRole } from "@/components/Measurements/charts/data";
import { dotRadius, useChartWidth } from "@/components/Measurements/charts/density";
import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
-import { ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
+import { ChartPoint, ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import React from "react";
import { useTranslation } from "react-i18next";
-import { Area, CartesianGrid, ComposedChart, Line, Tooltip, XAxis, YAxis } from "recharts";
+import {
+ Area,
+ CartesianGrid,
+ ComposedChart,
+ Line,
+ ReferenceLine,
+ Tooltip,
+ useXAxisScale,
+ useYAxisScale,
+ XAxis,
+ YAxis
+} from "recharts";
import { dateToLocale } from "@/core/lib/date";
import { numberDecimalLocale } from "@/core/lib/numbers";
/** Opacity of the band drawn around a series of ranged points */
const BAND_OPACITY = 0.15;
+/** Point count above which the connectors to the trend stop being readable */
+const MAX_VARIANCE_LINES = 30;
+
interface TooltipProps {
active?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -117,13 +132,68 @@ export const bandData = (series: ChartSeries): { date: number, range: [number, n
return series.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
};
+/**
+ * How far each measured value sits from the trend, as a dashed connector.
+ *
+ * Only readable while the values are few enough to tell apart, and only
+ * meaningful where a value and the trend share a date, which they do because
+ * the trend is derived from those very points.
+ */
+const VarianceLines = (props: { raw: ChartPoint[], trend: ChartPoint[] }) => {
+ const xScale = useXAxisScale();
+ const yScale = useYAxisScale();
+ const theme = useTheme();
+
+ if (!xScale || !yScale || props.raw.length > MAX_VARIANCE_LINES) {
+ return null;
+ }
+
+ const trendByDate = new Map(props.trend.map(point => [point.date, point.value]));
+
+ return
+ {props.raw.map(point => {
+ const trend = trendByDate.get(point.date);
+ if (trend === undefined) {
+ return null;
+ }
+
+ const x = xScale(point.date) as number;
+
+ return trend ? theme.palette.error.main : theme.palette.success.main}
+ strokeWidth={1}
+ strokeDasharray="2,2"
+ opacity={0.5} />;
+ })}
+ ;
+};
+
+export interface MeasurementSeriesChartProps {
+ series: ChartSeries[];
+ unit: string;
+ height?: number;
+
+ /**
+ * Extras the body weight screens had before they moved onto this chart:
+ * the mean of the values as a reference line with the current trend, and
+ * a connector from every value to the trend. Off everywhere else.
+ */
+ showMean?: boolean;
+ showVariance?: boolean;
+}
+
/**
* 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 }) => {
+export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
const theme = useTheme();
const [t, i18n] = useTranslation();
const [chartRef, chartWidth] = useChartWidth();
@@ -155,7 +225,27 @@ export const MeasurementSeriesChart = (props: { series: ChartSeries[], unit: str
const withYear = spansYears(props.series.flatMap(s => s.points));
+ const rawPoints = pointsOfRole(props.series, 'raw');
+ const trendPoints = pointsOfRole(props.series, 'trend');
+ const mean = rawPoints.length === 0
+ ? null
+ : rawPoints.reduce((sum, point) => sum + point.value, 0) / rawPoints.length;
+ const currentTrend = trendPoints.at(-1)?.value ?? null;
+
return
+ {props.showMean && mean !== null &&
+
+ {t('mean')}: {valueWithUnit(mean, props.unit, i18n.language)}
+
+ {currentTrend !== null &&
+ {t('currentTrend')}: {valueWithUnit(currentTrend, props.unit, i18n.language)}
+ }
+ }
valueWithUnit(value, props.unit, i18n.language)} />
} />
+ {props.showMean && mean !== null && }
+ {props.showMean && currentTrend !== null && }
+ {props.showVariance && }
+
{/* the bands go in first so the lines paint on top of them */}
{resolved.map(({ series, color, name, key }) => {
const band = bandData(series);
diff --git a/src/components/Measurements/widgets/OverallChange.tsx b/src/components/Measurements/widgets/OverallChange.tsx
new file mode 100644
index 000000000..96380f9da
--- /dev/null
+++ b/src/components/Measurements/widgets/OverallChange.tsx
@@ -0,0 +1,28 @@
+import { Typography } from "@mui/material";
+import { overallChange, pointsOfRole } from "@/components/Measurements/charts/data";
+import { valueWithUnit } from "@/components/Measurements/charts/format";
+import { ChartSeries } from "@/components/Measurements/charts/series";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+/**
+ * How far the values moved over the charted period.
+ *
+ * Read off the average rather than off the values: the first and the last
+ * reading of a densely sampled metric are two arbitrary moments.
+ */
+export const OverallChange = (props: { series: ChartSeries[], unit: string }) => {
+ const [t, i18n] = useTranslation();
+
+ const change = overallChange(pointsOfRole(props.series, 'average'));
+ if (change === null) {
+ return null;
+ }
+
+ return
+ {t('measurements.overallChangeWeight')}
+ {' '}
+ {change > 0 ? '+' : change < 0 ? '-' : ''}
+ {valueWithUnit(Math.abs(change), props.unit, i18n.language)}
+ ;
+};
diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Weight/widgets/WeightChart/index.test.tsx
index 54d91bf6a..104aaa282 100644
--- a/src/components/Weight/widgets/WeightChart/index.test.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.test.tsx
@@ -1,11 +1,11 @@
-import { MeasurementEntry } from "@/components/Measurements";
+import { measurementSeries, MeasurementEntry } from "@/components/Measurements";
import { makeWeightEntry } from "@/tests/weight/testData";
import { QueryClientProvider } from "@tanstack/react-query";
import { render } from '@testing-library/react';
import React from 'react';
-import { describe, test } from 'vitest';
+import { describe, expect, test } from 'vitest';
import { testQueryClient } from "@/tests/queryClient";
-import { buildWeightData, WeightChart } from "./index";
+import { WeightChart } from "./index";
// See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest
// Recharts only paints SVG content once a ResizeObserver entry reports real
@@ -54,14 +54,17 @@ describe("WeightChart", () => {
});
});
-describe("buildWeightData", () => {
+describe("the series the chart is built from", () => {
test('converts mixed units to the display unit before plotting', () => {
const weights = [
makeWeightEntry(new Date('2021-12-20'), 90, { id: 'd-2', unit: 'lb' }),
makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1', unit: 'kg' }),
];
- expect(buildWeightData(weights, 'kg', 'kg').map(d => d.weight)).toStrictEqual([80, 40.82]);
- expect(buildWeightData(weights, 'lb', 'kg').map(d => d.weight)).toStrictEqual([176.37, 90]);
+ const inKg = measurementSeries(weights, 'kg', 'kg')[0].points.map(p => p.value);
+ const inLb = measurementSeries(weights, 'lb', 'kg')[0].points.map(p => p.value);
+
+ expect(inKg).toStrictEqual([80, 40.82]);
+ expect(inLb).toStrictEqual([176.37, 90]);
});
});
diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx
index 2c3c38911..5c978f682 100644
--- a/src/components/Weight/widgets/WeightChart/index.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.tsx
@@ -1,29 +1,7 @@
-import { MeasurementEntry } from "@/components/Measurements";
-import { dateToLocale } from "@/core/lib/date";
-import { calculateEMA } from "@/core/lib/ema";
+import { MeasurementEntry, MeasurementSeriesChart, measurementSeries, OverallChange } from "@/components/Measurements";
import { WeightUnit } from "@/core/lib/weightUnit";
-import { Paper, Stack, Typography, useTheme } from "@mui/material";
+import React from "react";
import { useTranslation } from "react-i18next";
-import {
- CartesianGrid,
- Legend,
- Line,
- LineChart,
- ReferenceLine,
- Tooltip,
- useXAxisScale,
- useYAxisScale,
- XAxis,
- YAxis
-} from 'recharts';
-
-const NR_OF_WEIGHTS_CHART_DOT = 30;
-
-interface EMADataPoint {
- date: number;
- weight: number;
- ema: number;
-}
export interface WeightChartProps {
weights: MeasurementEntry[],
@@ -32,178 +10,25 @@ export interface WeightChartProps {
height?: number,
}
-/*
- * Chart data points in the display unit; entries may be stored in mixed
- * units, so every value is converted before anything is derived from it
+/**
+ * Body weight over time: the same chart every other measurement gets, plus
+ * the mean and the distance of each reading from the trend, which the weight
+ * screens showed before body weight became a measurement.
*/
-export const buildWeightData = (weights: MeasurementEntry[], unit: WeightUnit, categoryUnit: string) =>
- [...weights]
- .sort((a, b) => a.date.getTime() - b.date.getTime())
- .map(weight => ({
- date: weight.date.getTime(),
- weight: weight.valueIn(unit, categoryUnit),
- }));
-
-export interface TooltipProps {
- active?: boolean,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- payload?: any,
- label?: string,
-}
-
-const CustomTooltip = ({ active, payload, label }: TooltipProps) => {
- const [t] = useTranslation();
- const theme = useTheme();
-
- if (active && payload && payload.length) {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const actualWeight = payload.find((p: any) => p.dataKey === 'weight');
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const trendWeight = payload.find((p: any) => p.dataKey === 'ema');
- const variance = actualWeight && trendWeight ? actualWeight.value - trendWeight.value : 0;
-
- return (
-
- {dateToLocale(new Date(label!))}
- {actualWeight && {t('weight')}: {actualWeight.value.toFixed(1)}
}
- {trendWeight && {t('trend')}: {trendWeight.value.toFixed(1)}
}
- {actualWeight && trendWeight && (
- 0 ? theme.palette.error.main : theme.palette.success.main }}>
- {t('variance')}: {variance > 0 ? '+' : ''}{variance.toFixed(1)}
-
- )}
-
- );
- }
-
- return null;
-};
-
-const VarianceLines = ({ emaData }: { emaData: EMADataPoint[] }) => {
- const xScale = useXAxisScale();
- const yScale = useYAxisScale();
- const theme = useTheme();
-
- if (!xScale || !yScale || emaData.length > NR_OF_WEIGHTS_CHART_DOT) {
- return null;
- }
-
- return (
-
- {emaData.map(point => {
- const x = xScale(point.date) as number;
- return (
- point.ema ? theme.palette.error.main : theme.palette.success.main}
- strokeWidth={1}
- strokeDasharray="2,2"
- opacity={0.5}
- />
- );
- })}
-
- );
-};
-
export const WeightChart = ({ weights, unit, categoryUnit, height = 300 }: WeightChartProps) => {
- const theme = useTheme();
const [t] = useTranslation();
- const weightData = buildWeightData(weights, unit, categoryUnit);
-
- const emaData = calculateEMA(weightData, p => p.weight, 10);
-
- const meanWeight = weightData.length > 0
- ? weightData.reduce((sum, w) => sum + w.weight, 0) / weightData.length
- : 0;
- const currentTrend = emaData.length > 0 ? emaData[emaData.length - 1].ema : 0;
-
- const allWeights = emaData.flatMap(d => [d.weight, d.ema]);
- const minWeight = allWeights.length > 0 ? Math.min(...allWeights) : 0;
- const maxWeight = allWeights.length > 0 ? Math.max(...allWeights) : 0;
- const padding = (maxWeight - minWeight) * 0.1;
- const yAxisDomain: [number, number] = [minWeight - padding, maxWeight + padding];
-
- return (
-
- {weightData.length > 0 && (
-
-
- {t('mean')}: {meanWeight.toFixed(1)}
-
-
- {t('currentTrend')}: {currentTrend.toFixed(1)}
-
-
- )}
-
-
- dateToLocale(new Date(timeStr))}
- />
- Math.round(value).toString()} />
-
-
-
-
-
-
-
-
-
- NR_OF_WEIGHTS_CHART_DOT
- ? false
- : {
- fill: theme.palette.secondary.main,
- stroke: theme.palette.secondary.dark,
- strokeWidth: 1,
- r: 4
- }}
- activeDot={{
- fill: theme.palette.secondary.main,
- stroke: theme.palette.secondary.dark,
- strokeWidth: 2,
- r: 6
- }}
- name={t('weight')}
- legendType="circle"
- />
-
- } />
-
-
-
- );
+ // Entries can be stored in mixed units, so every value is converted
+ // before anything is derived from it
+ const series = measurementSeries(weights, unit, categoryUnit);
+
+ return <>
+
+
+ >;
};
From 2fb3134fd66eda9eacdfc0693b7edb72e31f6532 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 01:52:10 +0200
Subject: [PATCH 26/71] Let the charts show a range and the nutrition plans
behind it
---
public/locales/de/translation.json | 5 +-
public/locales/en/translation.json | 5 +-
public/locales/es/translation.json | 5 +-
public/locales/fr/translation.json | 5 +-
src/components/Measurements/charts/data.ts | 62 ++++++++++++++++---
src/components/Measurements/charts/range.ts | 29 +++++++++
src/components/Measurements/charts/series.ts | 11 ++++
src/components/Measurements/index.ts | 6 +-
.../Measurements/models/Category.ts | 9 +++
.../MeasurementCategoryDetail.test.tsx | 4 ++
.../screens/MeasurementCategoryDetail.tsx | 16 ++++-
.../screens/MeasurementCategoryOverview.tsx | 14 ++++-
.../widgets/ChartRangeSelector.tsx | 33 ++++++++++
.../Measurements/widgets/MeasurementChart.tsx | 41 +++++++++---
.../widgets/MeasurementSeriesChart.tsx | 53 ++++++++++++++--
src/components/Nutrition/index.ts | 1 +
src/components/Nutrition/queries/index.ts | 1 +
src/components/Nutrition/queries/plan.ts | 25 +++++++-
.../Nutrition/screens/PlanDetail.tsx | 2 +
.../widgets/charts/PlanWeightChart.tsx | 51 +++++++++++++++
src/components/Weight/queries/index.ts | 5 +-
.../Weight/screens/BodyWeight.test.tsx | 36 ++++-------
src/components/Weight/screens/BodyWeight.tsx | 21 ++++---
.../Weight/widgets/WeightChart/index.tsx | 25 +++++++-
24 files changed, 394 insertions(+), 71 deletions(-)
create mode 100644 src/components/Measurements/charts/range.ts
create mode 100644 src/components/Measurements/widgets/ChartRangeSelector.tsx
create mode 100644 src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index d28d25c33..b1ddeeb4f 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -274,7 +274,10 @@
"indicatorRaw": "raw",
"indicatorAvg": "Durchschn.",
"indicatorTrend": "Trend",
- "overallChangeWeight": "Allgemeine Veränderung"
+ "overallChangeWeight": "Allgemeine Veränderung",
+ "chartRangeAll": "Gesamt",
+ "chartRangeLastYear": "1 Jahr",
+ "chartRangeLast3Months": "3 Monate"
},
"timeOfDay": "Uhrzeit",
"notes": "Notizen",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 337c86cbb..b7b16460e 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -359,7 +359,10 @@
"indicatorAvg": "avg",
"indicatorTrend": "trend",
"noDataAvailable": "No data available",
- "overallChangeWeight": "Overall change"
+ "overallChangeWeight": "Overall change",
+ "chartRangeAll": "All",
+ "chartRangeLastYear": "1 year",
+ "chartRangeLast3Months": "3 months"
},
"server": {
"abs": "Abs",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 068e168ba..0072a7742 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -276,7 +276,10 @@
"indicatorRaw": "Bruto",
"indicatorAvg": "medio",
"indicatorTrend": "tendencia",
- "overallChangeWeight": "Cambio general"
+ "overallChangeWeight": "Cambio general",
+ "chartRangeAll": "Todo",
+ "chartRangeLastYear": "1 año",
+ "chartRangeLast3Months": "3 meses"
},
"deleteConfirmation": "¿Estás seguro de que quieres borrar \"{{name}}\"?",
"nutrition": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 8f1f4be40..b476a8722 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -359,7 +359,10 @@
"indicatorRaw": "brut",
"indicatorAvg": "moy",
"indicatorTrend": "tendance",
- "overallChangeWeight": "Changement global"
+ "overallChangeWeight": "Changement global",
+ "chartRangeAll": "Tout",
+ "chartRangeLastYear": "1 an",
+ "chartRangeLast3Months": "3 mois"
},
"downloadAsPdf": "Télécharger en PDF",
"calendar": "Calendrier",
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 79d2927be..132aab4c8 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -1,6 +1,7 @@
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { ChartPoint, ChartSeries } from "@/components/Measurements/charts/series";
+import { pointsSince } from "@/components/Measurements/charts/range";
+import { ChartPoint, ChartSeries, PlanPeriod } from "@/components/Measurements/charts/series";
import { calculateEMA } from "@/core/lib/ema";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -210,7 +211,10 @@ export const fillMissingDays = (points: ChartPoint[]): ChartPoint[] => {
* shared timestamp, which is how both the importer and the group form write
* them; an unpaired half-reading is skipped, it has no range.
*/
-export const groupRangeEntries = (group: MeasurementCategory): ChartPoint[] => {
+export const groupRangeEntries = (
+ group: MeasurementCategory,
+ cutoff: Date | null = null,
+): ChartPoint[] => {
const byDate = new Map();
for (const child of group.children) {
for (const entry of child.entries) {
@@ -225,7 +229,7 @@ export const groupRangeEntries = (group: MeasurementCategory): ChartPoint[] => {
}
}
- return [...byDate.entries()]
+ const ranges = [...byDate.entries()]
.filter(([, values]) => values.length > 1)
.map(([date, values]) => ({
date: date,
@@ -236,15 +240,20 @@ export const groupRangeEntries = (group: MeasurementCategory): ChartPoint[] => {
max: Math.max(...values),
}))
.sort((a, b) => a.date - b.date);
+
+ return pointsSince(ranges, cutoff);
};
/**
* One series per component of a multi-value group, in the children's in-group
* order and named after them
*/
-export const groupComponentSeries = (group: MeasurementCategory): ChartSeries[] =>
+export const groupComponentSeries = (
+ group: MeasurementCategory,
+ cutoff: Date | null = null,
+): ChartSeries[] =>
group.children.map(child => ({
- points: chartPointsFor(child.entries, child.unit, child.unit),
+ points: pointsSince(chartPointsFor(child.entries, child.unit, child.unit), cutoff),
role: 'component' as const,
label: child.name,
}));
@@ -262,8 +271,15 @@ export const measurementSeries = (
entries: MeasurementEntry[],
targetUnit: string,
categoryUnit: string,
+ cutoff: Date | null = null,
): ChartSeries[] => {
- const points = chartPointsFor(entries, targetUnit, categoryUnit);
+ const all = chartPointsFor(entries, targetUnit, categoryUnit);
+ // 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(moving7dAverage(all), cutoff);
+ const points = pointsSince(all, cutoff);
+
const condensed = downsample(points);
const raw: ChartSeries = { points: condensed, role: 'raw' };
@@ -275,7 +291,7 @@ export const measurementSeries = (
return [
raw,
- { points: downsample(moving7dAverage(points)), role: 'average' },
+ { points: downsample(average), role: 'average' },
{ points: smoothedTrendline(condensed), role: 'trend' },
];
};
@@ -297,14 +313,40 @@ 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) : [];
+export const groupChart = (group: MeasurementCategory, cutoff: Date | null = null): GroupChart => {
+ const ranges = group.children.length === 2 ? groupRangeEntries(group, cutoff) : [];
return ranges.length > 0
? { kind: 'range', points: ranges }
- : { kind: 'components', series: groupComponentSeries(group) };
+ : { kind: 'components', series: groupComponentSeries(group, cutoff) };
};
+/**
+ * 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
+ * axes.
+ */
+export const clampPeriods = (periods: PlanPeriod[], points: ChartPoint[]): PlanPeriod[] => {
+ if (points.length === 0) {
+ return [];
+ }
+
+ const first = points[0].date;
+ const last = points[points.length - 1].date;
+
+ return periods
+ .filter(period => period.start < last && period.end > first)
+ .map(period => ({
+ ...period,
+ start: Math.max(period.start, first),
+ end: Math.min(period.end, last),
+ }));
+};
+
+/** Names of the plans whose period contains the given date */
+export const planNamesAt = (periods: PlanPeriod[], date: number): string[] =>
+ periods.filter(period => date >= period.start && date <= period.end).map(period => period.name);
+
/** 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/charts/range.ts b/src/components/Measurements/charts/range.ts
new file mode 100644
index 000000000..035891026
--- /dev/null
+++ b/src/components/Measurements/charts/range.ts
@@ -0,0 +1,29 @@
+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.
+ */
+export const CHART_RANGES = ['last3Months', 'lastYear', 'all'] as const;
+export type ChartRange = typeof CHART_RANGES[number];
+
+export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
+
+const DAYS: Record = {
+ last3Months: 90,
+ lastYear: 365,
+ all: null,
+};
+
+/** Oldest date still shown, null for the full history */
+export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => {
+ const days = DAYS[range];
+
+ return days === null ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
+};
+
+/** The points from the cutoff on; a null cutoff covers the full history */
+export const pointsSince = (points: ChartPoint[], cutoff: Date | null): ChartPoint[] =>
+ cutoff === null ? points : points.filter(point => point.date >= cutoff.getTime());
diff --git a/src/components/Measurements/charts/series.ts b/src/components/Measurements/charts/series.ts
index 616053ad6..b4ae7f33f 100644
--- a/src/components/Measurements/charts/series.ts
+++ b/src/components/Measurements/charts/series.ts
@@ -43,3 +43,14 @@ export interface ChartSeries {
/** Whether the point carries a range that can be drawn as a band */
export const hasRange = (point: ChartPoint): boolean =>
point.min !== undefined && point.max !== undefined;
+
+/**
+ * A nutrition plan period shown for context: shaded as a vertical band in the
+ * chart, and named in the tooltip of the points it contains.
+ */
+export interface PlanPeriod {
+ start: number;
+ /** An open-ended plan runs up to now */
+ end: number;
+ name: string;
+}
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 32ad5dc88..cd0b119a8 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -8,7 +8,7 @@ export { MeasurementCategoryDetail } from "./screens/MeasurementCategoryDetail";
export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOverview";
// Models
-export { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "./models/Category";
+export { correlatesWithNutrition, MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "./models/Category";
export { MeasurementEntry } from "./models/Entry";
// API endpoints
@@ -21,9 +21,13 @@ export { useMeasurementsCategoryQuery } from "./queries";
export { componentColor, componentPalette } from "./charts/colors";
export { groupChart, measurementSeries } from "./charts/data";
export { valueWithUnit } from "./charts/format";
+export { CHART_RANGES, cutoffFor, DEFAULT_CHART_RANGE } from "./charts/range";
+export type { ChartRange } from "./charts/range";
+export type { PlanPeriod } from "./charts/series";
// Widgets
export { CategoryForm } from "./widgets/CategoryForm";
+export { ChartRangeSelector } from "./widgets/ChartRangeSelector";
export { MeasurementChart } from "./widgets/MeasurementChart";
export { MeasurementSeriesChart } from "./widgets/MeasurementSeriesChart";
export { OverallChange } from "./widgets/OverallChange";
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 63e78a754..98ee63d81 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -33,6 +33,15 @@ export function isSummedPerDay(type: MetricType): boolean {
return type === 'steps' || type === 'distance' || type === 'energy' || type === 'sleep';
}
+/**
+ * Metric types whose charts show nutrition plan periods for context. Custom
+ * categories are typically hand-kept body measurements (waist, biceps), so
+ * they qualify; the typed health metrics do not.
+ */
+export function correlatesWithNutrition(type: MetricType): boolean {
+ return type === 'body_weight' || type === 'body_fat' || type === 'custom';
+}
+
/**
* Metric types reserved for the official categories the server manages:
* users cannot create categories of these types
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
index 8c07db01b..cda7bc024 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
@@ -7,6 +7,10 @@ import React from 'react';
import { MemoryRouter, Route, Routes } from "react-router-dom";
import type { Mock } from 'vitest';
+vi.mock('@/components/Nutrition/queries/plan', () => ({
+ useNutritionPlanPeriods: () => [],
+}));
+
vi.mock("@/components/Measurements/queries");
const queryClient = new QueryClient();
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index c110e02e1..531499359 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -1,10 +1,14 @@
import { Stack, Typography } from "@mui/material";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
+import { correlatesWithNutrition } from "@/components/Measurements/models/Category";
import { useMeasurementsQuery } from "@/components/Measurements/queries";
+import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
+import { ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
import { AddMeasurementEntryFab } from "@/components/Measurements/widgets/fab";
+import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
import React from "react";
import { useParams } from "react-router-dom";
@@ -18,6 +22,12 @@ export const MeasurementCategoryDetail = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const categoryQuery = useMeasurementsQuery(categoryId);
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const [range, setRange] = React.useState(DEFAULT_CHART_RANGE);
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const planPeriods = useNutritionPlanPeriods(
+ correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
+ );
if (categoryQuery.isLoading) {
return ;
@@ -31,7 +41,11 @@ export const MeasurementCategoryDetail = () => {
: }
mainContent={
-
+
+
{categoryQuery.data!.isGroup
? categoryQuery.data!.children.map(child =>
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index 1b09e04f7..d276cfc21 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -6,6 +6,8 @@ import { useTranslation } from "react-i18next";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
+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";
@@ -17,7 +19,7 @@ import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/Ent
import { WgerModal } from "@/core/ui/Modals/WgerModal";
-export const CategoryList = (props: { category: MeasurementCategory }) => {
+export const CategoryList = (props: { category: MeasurementCategory, range: ChartRange }) => {
const [t, i18n] = useTranslation();
const [openModal, setOpenModal] = React.useState(false);
@@ -28,7 +30,7 @@ export const CategoryList = (props: { category: MeasurementCategory }) => {
-
+
@@ -54,6 +56,9 @@ export const MeasurementCategoryOverview = () => {
const categoryQuery = useMeasurementsCategoryQuery();
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);
return categoryQuery.isLoading
?
@@ -69,7 +74,10 @@ export const MeasurementCategoryOverview = () => {
}
mainContent={
{categoryQuery.data!.length === 0 && }
- {categoryQuery.data!.map(c => )}
+ {categoryQuery.data!.length > 0
+ && }
+ {categoryQuery.data!.map(c =>
+ )}
}
fab={ }
diff --git a/src/components/Measurements/widgets/ChartRangeSelector.tsx b/src/components/Measurements/widgets/ChartRangeSelector.tsx
new file mode 100644
index 000000000..9ad2fa18e
--- /dev/null
+++ b/src/components/Measurements/widgets/ChartRangeSelector.tsx
@@ -0,0 +1,33 @@
+import { ToggleButton, ToggleButtonGroup } from "@mui/material";
+import { CHART_RANGES, ChartRange } from "@/components/Measurements/charts/range";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+const LABELS = {
+ last3Months: 'measurements.chartRangeLast3Months',
+ lastYear: 'measurements.chartRangeLastYear',
+ all: 'measurements.chartRangeAll',
+} as const satisfies Record;
+
+/** Picks how far back the charts below go */
+export const ChartRangeSelector = (props: {
+ value: ChartRange,
+ onChange: (range: ChartRange) => void,
+}) => {
+ const [t] = useTranslation();
+
+ return {
+ // null arrives when the selected button is clicked again
+ if (range !== null) {
+ props.onChange(range);
+ }
+ }}
+ >
+ {CHART_RANGES.map(range =>
+ {t(LABELS[range])} )}
+ ;
+};
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index bed208082..afd42bd50 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -9,7 +9,13 @@ import {
} 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 } from "@/components/Measurements/charts/series";
+import {
+ ChartRange,
+ cutoffFor,
+ DEFAULT_CHART_RANGE,
+ pointsSince
+} from "@/components/Measurements/charts/range";
+import { ChartPoint, PlanPeriod } from "@/components/Measurements/charts/series";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
@@ -48,14 +54,14 @@ const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
return null;
};
-const MeasurementBarChart = (props: { category: MeasurementCategory }) => {
+const MeasurementBarChart = (props: { category: MeasurementCategory, cutoff: Date | null }) => {
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 points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
- const data = fillMissingDays(aggregatePerDay(points));
+ const data = fillMissingDays(aggregatePerDay(pointsSince(points, props.cutoff)));
if (data.length === 0) {
return ;
@@ -156,22 +162,36 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
;
};
-const MeasurementLineChart = (props: { category: MeasurementCategory }) => {
+const MeasurementLineChart = (props: {
+ category: MeasurementCategory,
+ cutoff: Date | null,
+ planPeriods?: PlanPeriod[],
+}) => {
const series = measurementSeries(
props.category.entries,
props.category.unit,
props.category.unit,
+ props.cutoff,
);
return <>
-
+
>;
};
-export const MeasurementChart = (props: { category: MeasurementCategory }) => {
+export const MeasurementChart = (props: {
+ category: MeasurementCategory,
+ range?: ChartRange,
+ planPeriods?: PlanPeriod[],
+}) => {
+ const cutoff = cutoffFor(props.range ?? DEFAULT_CHART_RANGE);
+
if (props.category.isGroup) {
- const chart = groupChart(props.category);
+ const chart = groupChart(props.category, cutoff);
return chart.kind === 'range'
?
@@ -179,6 +199,9 @@ export const MeasurementChart = (props: { category: MeasurementCategory }) => {
}
return isSummedPerDay(props.category.metricType)
- ?
- : ;
+ ?
+ : ;
};
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index d7811ccbd..b606529c7 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -1,9 +1,15 @@
import { Box, Paper, Stack, Typography, useTheme } from "@mui/material";
import { componentPalette, seriesColor } from "@/components/Measurements/charts/colors";
-import { pointsOfRole } from "@/components/Measurements/charts/data";
+import { clampPeriods, planNamesAt, pointsOfRole } from "@/components/Measurements/charts/data";
import { dotRadius, useChartWidth } from "@/components/Measurements/charts/density";
import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
-import { ChartPoint, ChartSeries, ChartSeriesRole, hasRange } from "@/components/Measurements/charts/series";
+import {
+ ChartPoint,
+ ChartSeries,
+ ChartSeriesRole,
+ hasRange,
+ PlanPeriod
+} from "@/components/Measurements/charts/series";
import { ChartEmptyState } from "@/components/Measurements/widgets/ChartEmptyState";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -12,6 +18,7 @@ import {
CartesianGrid,
ComposedChart,
Line,
+ ReferenceArea,
ReferenceLine,
Tooltip,
useXAxisScale,
@@ -25,6 +32,9 @@ import { numberDecimalLocale } from "@/core/lib/numbers";
/** Opacity of the band drawn around a series of ranged points */
const BAND_OPACITY = 0.15;
+/** Opacity of a shaded nutrition plan period */
+const PLAN_BAND_OPACITY = 0.15;
+
/** Point count above which the connectors to the trend stop being readable */
const MAX_VARIANCE_LINES = 30;
@@ -34,6 +44,7 @@ interface TooltipProps {
payload?: any;
label?: string;
unit: string;
+ planPeriods: PlanPeriod[];
}
/**
@@ -56,7 +67,11 @@ const tooltipRows = (
for (const item of payload) {
const row: TooltipRow = rows.get(item.name) ?? { name: item.name };
if (Array.isArray(item.value)) {
- row.range = item.value as [number, number];
+ const [low, high] = item.value as [number, number];
+ // A bucket holding a single value has no spread worth quoting
+ if (low !== high) {
+ row.range = [low, high];
+ }
} else {
row.value = item.value;
}
@@ -66,13 +81,17 @@ const tooltipRows = (
return [...rows.values()];
};
-const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
+const CustomTooltip = ({ active, payload, label, unit, planPeriods }: TooltipProps) => {
const [, i18n] = useTranslation();
if (!active || !payload?.length) {
return null;
}
+ // The plan belongs to the touched date, not to a series, so it goes below
+ // the value rows instead of onto every one of them
+ const plans = planNamesAt(planPeriods, Number(label));
+
return (
{dateToLocale(new Date(Number(label)))}
@@ -84,6 +103,7 @@ const CustomTooltip = ({ active, payload, label, unit }: TooltipProps) => {
+ `–${valueWithUnit(row.range[1], unit, i18n.language)})`}
))}
+ {plans.map(name => {name}
)}
);
};
@@ -185,6 +205,9 @@ export interface MeasurementSeriesChartProps {
*/
showMean?: boolean;
showVariance?: boolean;
+
+ /** Nutrition plan periods shaded for context, see PlanPeriod */
+ planPeriods?: PlanPeriod[];
}
/**
@@ -225,6 +248,9 @@ export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
const withYear = spansYears(props.series.flatMap(s => s.points));
+ // Clamped to the span of the data so a band never draws past the axes
+ const periods = clampPeriods(props.planPeriods ?? [], props.series.flatMap(s => s.points));
+
const rawPoints = pointsOfRole(props.series, 'raw');
const trendPoints = pointsOfRole(props.series, 'trend');
const mean = rawPoints.length === 0
@@ -261,7 +287,7 @@ export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
domain={['auto', 'auto']}
width="auto"
tickFormatter={value => valueWithUnit(value, props.unit, i18n.language)} />
- } />
+ } />
{props.showMean && mean !== null && {
ifOverflow="extendDomain" />}
{props.showVariance && }
+ {periods.map(period => )}
+
{/* the bands go in first so the lines paint on top of them */}
{resolved.map(({ series, color, name, key }) => {
const band = bandData(series);
@@ -315,6 +349,15 @@ export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
{name}
)}
+ {periods.length > 0 &&
+
+ {t('nutrition.plan')}
+ }
;
};
diff --git a/src/components/Nutrition/index.ts b/src/components/Nutrition/index.ts
index 21a518d05..cc4113a46 100644
--- a/src/components/Nutrition/index.ts
+++ b/src/components/Nutrition/index.ts
@@ -24,6 +24,7 @@ export {
useAddDiaryEntryQuery,
useFetchLastNutritionalPlanQuery,
useNutritionDiaryQuery,
+ useNutritionPlanPeriods,
} from "./queries";
// Widgets
diff --git a/src/components/Nutrition/queries/index.ts b/src/components/Nutrition/queries/index.ts
index e3e1ddc82..c464c439d 100644
--- a/src/components/Nutrition/queries/index.ts
+++ b/src/components/Nutrition/queries/index.ts
@@ -1,5 +1,6 @@
export {
useFetchNutritionalPlansQuery,
+ useNutritionPlanPeriods,
useFetchNutritionalPlanDateQuery,
useEditNutritionalPlanQuery,
useAddNutritionalPlanQuery,
diff --git a/src/components/Nutrition/queries/plan.ts b/src/components/Nutrition/queries/plan.ts
index 1940d24e9..c210fe7ae 100644
--- a/src/components/Nutrition/queries/plan.ts
+++ b/src/components/Nutrition/queries/plan.ts
@@ -8,15 +8,36 @@ import {
getNutritionalPlanFull,
getNutritionalPlansSparse
} from "@/components/Nutrition/api/nutritionalPlan";
+import { PlanPeriod } from "@/components/Measurements";
import { QueryKey } from "@/core/lib/consts";
+import { useTranslation } from "react-i18next";
-export function useFetchNutritionalPlansQuery() {
+export function useFetchNutritionalPlansQuery(enabled = true) {
return useQuery({
queryKey: [QueryKey.NUTRITIONAL_PLANS],
- queryFn: () => getNutritionalPlansSparse()
+ queryFn: () => getNutritionalPlansSparse(),
+ enabled: enabled,
});
}
+/**
+ * The plans as periods a measurement chart can shade, newest first. A plan
+ * without an end date is still running, so its period reaches up to now.
+ *
+ * Pass enabled=false where the metric has nothing to do with nutrition, so
+ * those charts do not fetch the plans at all.
+ */
+export function useNutritionPlanPeriods(enabled = true): PlanPeriod[] {
+ const [t] = useTranslation();
+ const query = useFetchNutritionalPlansQuery(enabled);
+
+ return (query.data ?? []).map(plan => ({
+ start: plan.start.getTime(),
+ end: (plan.end ?? new Date()).getTime(),
+ name: plan.description !== '' ? plan.description : t('nutrition.plan'),
+ }));
+}
+
export function useFetchLastNutritionalPlanQuery() {
return useQuery({
diff --git a/src/components/Nutrition/screens/PlanDetail.tsx b/src/components/Nutrition/screens/PlanDetail.tsx
index ca298fbc0..2ac553d3b 100644
--- a/src/components/Nutrition/screens/PlanDetail.tsx
+++ b/src/components/Nutrition/screens/PlanDetail.tsx
@@ -11,6 +11,7 @@ import { AddNutritionDiaryEntryFab } from "@/components/Nutrition/widgets/Fab";
import { MealForm } from "@/components/Nutrition/widgets/forms/MealForm";
import { MealDetail } from "@/components/Nutrition/widgets/MealDetail";
import { NutritionalValuesTable } from "@/components/Nutrition/widgets/NutritionalValuesTable";
+import { PlanWeightChart } from "@/components/Nutrition/widgets/charts/PlanWeightChart";
import { PlanDetailDropdown } from "@/components/Nutrition/widgets/PlanDetailDropdown";
import { PlanSidebar } from "@/components/Nutrition/widgets/PlanSidebar";
import React, { useState } from "react";
@@ -92,6 +93,7 @@ export const PlanDetail = () => {
logged={plan.groupDiaryEntries}
planned={plan.plannedNutritionalValues}
/>
+
>}
sideBar={ }
diff --git a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
new file mode 100644
index 000000000..b66464ded
--- /dev/null
+++ b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
@@ -0,0 +1,51 @@
+import { Typography } from "@mui/material";
+import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan";
+import {
+ useBodyWeightCategoryQuery,
+ useBodyWeightQuery,
+ useDisplayWeightUnit,
+ WeightChart
+} from "@/components/Weight";
+import React from "react";
+import { useTranslation } from "react-i18next";
+
+/**
+ * Body weight during the plan's period.
+ *
+ * Hidden while the weight data is not loaded and when fewer than two readings
+ * fall into the period. All series are derived from the readings inside the
+ * period only, so the trend starts from a real measurement instead of an
+ * interpolated boundary point.
+ */
+export const PlanWeightChart = (props: { plan: NutritionalPlan }) => {
+ const [t] = useTranslation();
+ const weightQuery = useBodyWeightQuery('');
+ const categoryQuery = useBodyWeightCategoryQuery();
+ const displayUnit = useDisplayWeightUnit();
+
+ if (!weightQuery.data || !categoryQuery.data) {
+ return null;
+ }
+
+ // The end date is inclusive: readings on the plan's last day still count
+ const endExclusive = props.plan.end === null
+ ? null
+ : new Date(props.plan.end.getTime() + 24 * 60 * 60 * 1000);
+ const entries = weightQuery.data.filter(entry =>
+ entry.date >= props.plan.start && (endExclusive === null || entry.date < endExclusive));
+
+ // A single reading has no development to show
+ if (entries.length < 2) {
+ return null;
+ }
+
+ return <>
+ {t('weight')}
+
+ >;
+};
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index acb5d917a..1e45bb4e1 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -18,7 +18,10 @@ import { FilterType } from "../widgets/FilterButtons";
*/
const bodyWeightCategoryQueryOptions = {
queryKey: [QueryKey.BODY_WEIGHT_CATEGORY],
- queryFn: getBodyWeightCategory,
+ // Called through, not captured: this module sits in an import cycle
+ // between the weight, measurement and nutrition domains, where a binding
+ // read while the modules initialise can still be undefined
+ queryFn: () => getBodyWeightCategory(),
};
export function useBodyWeightCategoryQuery() {
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx
index 437a499ac..202ed6358 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Weight/screens/BodyWeight.test.tsx
@@ -4,10 +4,12 @@ import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weigh
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { BodyWeight } from "./BodyWeight";
-import { FilterType } from "../widgets/FilterButtons";
import type { Mock } from 'vitest';
vi.mock("@/components/Weight/api/weight");
+vi.mock('@/components/Nutrition/queries/plan', () => ({
+ useNutritionPlanPeriods: () => [],
+}));
vi.mock('@/components/User/queries/profile', () => ({
useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }),
}));
@@ -45,20 +47,13 @@ describe("Test BodyWeight component", () => {
// Assert - both weights are found in the document
expect(await screen.findByText("80")).toBeInTheDocument();
expect(await screen.findByText("90")).toBeInTheDocument();
- expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, 'lastYear');
+ // every entry is fetched, the range is cut client-side
+ expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, '');
});
- test('changes filter and updates displayed data', async () => {
+ test('picking a chart range does not refetch, and keeps every entry listed', async () => {
- // Mock the getWeights response based on the filter
- (getWeights as Mock).mockImplementation((categoryId: string, filter: FilterType) => {
- if (filter === 'lastYear') {
- return Promise.resolve(weightData);
- } else if (filter === 'lastMonth') {
- return Promise.resolve([]);
- }
- return Promise.resolve([]);
- });
+ (getWeights as Mock).mockImplementation(() => Promise.resolve(weightData));
render(
@@ -66,21 +61,16 @@ describe("Test BodyWeight component", () => {
);
- // Initially should display data for last year
expect(await screen.findByText("80")).toBeInTheDocument();
- expect(await screen.findByText("90")).toBeInTheDocument();
+ const fetches = (getWeights as Mock).mock.calls.length;
- // Change filter to 'lastMonth'
- const filterButton = screen.getByRole('button', { name: /lastMonth/i });
- fireEvent.click(filterButton);
+ fireEvent.click(screen.getByRole('button', { name: 'measurements.chartRangeAll' }));
- // Expect getWeights to be called with 'lastMonth'
+ // the range only decides how far back the chart goes: the entries are
+ // already there, and the table lists them whatever the range is
await waitFor(() => {
- expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, 'lastMonth');
+ expect(screen.getByText("80")).toBeInTheDocument();
});
-
- // Check that entries for last year are no longer in the document
- expect(screen.queryByText("80")).not.toBeInTheDocument();
- expect(screen.queryByText("90")).not.toBeInTheDocument();
+ expect((getWeights as Mock).mock.calls.length).toBe(fetches);
});
});
diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx
index 91cd709da..7517a2a91 100644
--- a/src/components/Weight/screens/BodyWeight.tsx
+++ b/src/components/Weight/screens/BodyWeight.tsx
@@ -1,4 +1,6 @@
import { Box, Stack } from "@mui/material";
+import { ChartRange, ChartRangeSelector, DEFAULT_CHART_RANGE } from "@/components/Measurements";
+import { useNutritionPlanPeriods } from "@/components/Nutrition";
import {
useBodyWeightCategoryQuery,
useBodyWeightQuery,
@@ -7,7 +9,6 @@ import {
import { WeightTable } from "@/components/Weight/widgets/Table";
import { WeightChart } from "@/components/Weight/widgets/WeightChart";
import { AddBodyWeightEntryFab } from "@/components/Weight/widgets/fab";
-import { FilterButtons, FilterType } from "@/components/Weight/widgets/FilterButtons";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty";
@@ -17,13 +18,13 @@ import { useTranslation } from "react-i18next";
export const BodyWeight = () => {
const [t] = useTranslation();
- const [filter, setFilter] = useState('lastYear');
- const weightyQuery = useBodyWeightQuery(filter);
+ const [range, setRange] = useState(DEFAULT_CHART_RANGE);
+ // The range is cut client-side, so the average can be computed over the
+ // full history before it is applied; the table lists every entry
+ const weightyQuery = useBodyWeightQuery('');
const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
- const handleFilterChange = (newFilter: FilterType) => {
- setFilter(newFilter);
- };
+ const planPeriods = useNutritionPlanPeriods();
if (weightyQuery.isLoading || categoryQuery.isLoading) {
return ;
@@ -35,13 +36,15 @@ export const BodyWeight = () => {
return
-
+
{weightyQuery.data!.length === 0 && }
{weightyQuery.data!.length !== 0 && <>
+ categoryUnit={categoryUnit}
+ range={range}
+ planPeriods={planPeriods} />
{
}
fab={ }
/>;
-};
\ No newline at end of file
+};
diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx
index 5c978f682..d15ebd31f 100644
--- a/src/components/Weight/widgets/WeightChart/index.tsx
+++ b/src/components/Weight/widgets/WeightChart/index.tsx
@@ -1,4 +1,13 @@
-import { MeasurementEntry, MeasurementSeriesChart, measurementSeries, OverallChange } from "@/components/Measurements";
+import {
+ ChartRange,
+ cutoffFor,
+ DEFAULT_CHART_RANGE,
+ MeasurementEntry,
+ measurementSeries,
+ MeasurementSeriesChart,
+ OverallChange,
+ PlanPeriod
+} from "@/components/Measurements";
import { WeightUnit } from "@/core/lib/weightUnit";
import React from "react";
import { useTranslation } from "react-i18next";
@@ -7,6 +16,8 @@ export interface WeightChartProps {
weights: MeasurementEntry[],
unit: WeightUnit,
categoryUnit: string,
+ range?: ChartRange,
+ planPeriods?: PlanPeriod[],
height?: number,
}
@@ -15,18 +26,26 @@ export interface WeightChartProps {
* the mean and the distance of each reading from the trend, which the weight
* screens showed before body weight became a measurement.
*/
-export const WeightChart = ({ weights, unit, categoryUnit, height = 300 }: WeightChartProps) => {
+export const WeightChart = (
+ { weights, unit, categoryUnit, range, planPeriods, height = 300 }: WeightChartProps,
+) => {
const [t] = useTranslation();
// Entries can be stored in mixed units, so every value is converted
// before anything is derived from it
- const series = measurementSeries(weights, unit, categoryUnit);
+ const series = measurementSeries(
+ weights,
+ unit,
+ categoryUnit,
+ cutoffFor(range ?? DEFAULT_CHART_RANGE),
+ );
return <>
From 99d4de7622e2e5526350f731a0fcbcb5326d185f Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 01:54:54 +0200
Subject: [PATCH 27/71] Drop the unused weight FilterButtons widget
---
src/components/Weight/api/weight.ts | 3 +-
src/components/Weight/index.ts | 2 +-
src/components/Weight/queries/index.ts | 2 +-
.../Weight/widgets/FilterButtons.test.tsx | 57 ---------------
.../Weight/widgets/FilterButtons.tsx | 69 -------------------
src/core/lib/date.ts | 2 +-
6 files changed, 5 insertions(+), 130 deletions(-)
delete mode 100644 src/components/Weight/widgets/FilterButtons.test.tsx
delete mode 100644 src/components/Weight/widgets/FilterButtons.tsx
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index 1ba7f0746..314fd3b78 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -10,7 +10,8 @@ import { calculatePastDate } from '@/core/lib/date';
import { makeHeader, makeUrl } from "@/core/lib/url";
import { ApiMeasurementCategoryType, ApiMeasurementEntryType } from '@/types';
import axios from 'axios';
-import { FilterType } from '../widgets/FilterButtons';
+
+export type FilterType = 'lastYear' | 'lastHalfYear' | 'lastMonth' | 'lastWeek' | '';
/*
* Fetch the user's official body weight category
diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts
index 27cc9b712..cef8b4ddf 100644
--- a/src/components/Weight/index.ts
+++ b/src/components/Weight/index.ts
@@ -10,4 +10,4 @@ export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard";
export { WeightChart } from "./widgets/WeightChart";
export { extraDataInUnit, weightUnitOf } from "./models/bodyWeight";
export { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
-export type { FilterType } from "./widgets/FilterButtons";
+export type { FilterType } from "./api/weight";
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index 1e45bb4e1..c52da01bb 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -3,6 +3,7 @@ import { MeasurementEntry } from "@/components/Measurements";
import {
createWeight,
deleteWeight,
+ FilterType,
getBodyWeightCategory,
getWeights,
updateWeight
@@ -10,7 +11,6 @@ import {
import { useProfileQuery } from "@/components/User";
import { QueryKey, } from "@/core/lib/consts";
import { WeightUnit } from "@/core/lib/weightUnit";
-import { FilterType } from "../widgets/FilterButtons";
/*
* The official body weight category basically never changes, resolve it once
diff --git a/src/components/Weight/widgets/FilterButtons.test.tsx b/src/components/Weight/widgets/FilterButtons.test.tsx
deleted file mode 100644
index 898827dcc..000000000
--- a/src/components/Weight/widgets/FilterButtons.test.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import { fireEvent, render, screen } from '@testing-library/react';
-import '@testing-library/jest-dom';
-import React from 'react';
-import { FilterButtons, FilterType } from './FilterButtons';
-
-describe('FilterButtons Component', () => {
- const onFilterChange = vi.fn();
-
- const renderComponent = (currentFilter: FilterType) => {
- render(
-
- );
- };
-
- afterEach(() => {
- onFilterChange.mockClear();
- });
-
- test('renders all filter buttons', () => {
- renderComponent('');
- const buttonLabels = ['all', 'lastYear', 'lastHalfYear', 'lastMonth', 'lastWeek'];
- buttonLabels.forEach(label => {
- expect(screen.getByText(label)).toBeInTheDocument();
- });
- });
-
- test('applies primary color and contained variant to the active filter button', () => {
- renderComponent('lastMonth');
- const activeButton = screen.getByText('lastMonth');
- expect(activeButton).toHaveClass('MuiButton-contained', 'MuiButton-colorPrimary');
- });
-
- test('calls onFilterChange with correct value when a button is clicked', () => {
- renderComponent('');
- const lastYearButton = screen.getByText('lastYear');
-
- fireEvent.click(lastYearButton);
- expect(onFilterChange).toHaveBeenCalledWith('lastYear');
- });
-
- test('does not trigger onFilterChange when clicking the currently active filter button', () => {
- renderComponent('lastYear');
- const lastYearButton = screen.getByText('lastYear');
-
- fireEvent.click(lastYearButton);
- expect(onFilterChange).not.toHaveBeenCalled();
- });
-
- test('displays correct default style for inactive filter buttons', () => {
- renderComponent('');
- const inactiveButton = screen.getByText('lastYear');
- expect(inactiveButton).toHaveClass('MuiButton-outlined');
- });
-});
diff --git a/src/components/Weight/widgets/FilterButtons.tsx b/src/components/Weight/widgets/FilterButtons.tsx
deleted file mode 100644
index c78360da7..000000000
--- a/src/components/Weight/widgets/FilterButtons.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { Button, ButtonGroup } from "@mui/material";
-import { useTheme } from '@mui/material/styles';
-import { useTranslation } from "react-i18next";
-
-export type FilterType = 'lastYear' | 'lastHalfYear' | 'lastMonth' | 'lastWeek' | '';
-
-export interface FilterButtonsProps {
- currentFilter: FilterType;
- onFilterChange: (newFilter: FilterType) => void;
-}
-
-export const FilterButtons = ({ currentFilter, onFilterChange }: FilterButtonsProps) => {
-
- const [t] = useTranslation();
-
- const theme = useTheme();
-
- // Won't call onFilterChange if the filter stays the same
- const handleFilterChange = (newFilter: FilterType) => {
- if (currentFilter !== newFilter) {
- onFilterChange(newFilter);
- }
- };
-
- return (
-
- handleFilterChange('')}
- color={currentFilter === '' ? 'primary' : 'inherit'}
- variant={currentFilter === '' ? 'contained' : 'outlined'}
- sx={{ fontFamily: theme.typography.fontFamily }}
- >
- {t('all')}
-
- handleFilterChange('lastYear')}
- color={currentFilter === 'lastYear' ? 'primary' : 'inherit'}
- variant={currentFilter === 'lastYear' ? 'contained' : 'outlined'}
- sx={{ fontFamily: theme.typography.fontFamily }}
- >
- {t('lastYear')}
-
- handleFilterChange('lastHalfYear')}
- color={currentFilter === 'lastHalfYear' ? 'primary' : 'inherit'}
- variant={currentFilter === 'lastHalfYear' ? 'contained' : 'outlined'}
- sx={{ fontFamily: theme.typography.fontFamily }}
- >
- {t('lastHalfYear')}
-
- handleFilterChange('lastMonth')}
- color={currentFilter === 'lastMonth' ? 'primary' : 'inherit'}
- variant={currentFilter === 'lastMonth' ? 'contained' : 'outlined'}
- sx={{ fontFamily: theme.typography.fontFamily }}
- >
- {t('lastMonth')}
-
- handleFilterChange('lastWeek')}
- color={currentFilter === 'lastWeek' ? 'primary' : 'inherit'}
- variant={currentFilter === 'lastWeek' ? 'contained' : 'outlined'}
- sx={{ fontFamily: theme.typography.fontFamily }}
- >
- {t('lastWeek')}
-
-
- );
-};
diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts
index 8cc414571..331dd0d09 100644
--- a/src/core/lib/date.ts
+++ b/src/core/lib/date.ts
@@ -1,4 +1,4 @@
-import { FilterType } from "@/components/Weight/widgets/FilterButtons";
+import { FilterType } from "@/components/Weight/api/weight";
import i18n from 'i18next';
import { DateTime, DateTimeFormatOptions } from "luxon";
From dbb00397234deeee9aca8ab9ec78b921eb739d82 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sat, 1 Aug 2026 13:04:27 +0200
Subject: [PATCH 28/71] Mirror the typed measurement category rules
---
public/locales/de/translation.json | 2 +
public/locales/en/translation.json | 2 +
public/locales/es/translation.json | 2 +
public/locales/fr/translation.json | 2 +
.../Measurements/models/Category.ts | 28 +++++++++++
.../widgets/CategoryForm.test.tsx | 49 ++++++++++++++++---
.../Measurements/widgets/CategoryForm.tsx | 18 +++++--
7 files changed, 90 insertions(+), 13 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index b1ddeeb4f..0044d2feb 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -264,6 +264,8 @@
"body_fat": "Körperfett",
"height": "Körpergröße",
"blood_pressure": "Blutdruck",
+ "blood_pressure_systolic": "Systolisch",
+ "blood_pressure_diastolic": "Diastolisch",
"heart_rate": "Herzfrequenz",
"resting_heart_rate": "Ruhepuls",
"steps": "Schritte",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index b7b16460e..600bfd8a9 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -348,6 +348,8 @@
"body_fat": "Body fat",
"height": "Height",
"blood_pressure": "Blood pressure",
+ "blood_pressure_systolic": "Systolic",
+ "blood_pressure_diastolic": "Diastolic",
"heart_rate": "Heart rate",
"resting_heart_rate": "Resting heart rate",
"steps": "Steps",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 0072a7742..00772c168 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -266,6 +266,8 @@
"body_fat": "Grasa corporal",
"height": "Altura",
"blood_pressure": "Presión arterial",
+ "blood_pressure_systolic": "Sistólica",
+ "blood_pressure_diastolic": "Diastólica",
"heart_rate": "Frecuencia cardíaca",
"resting_heart_rate": "Frecuencia cardíaca en reposo",
"steps": "Pasos",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index b476a8722..f7279ef6a 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -349,6 +349,8 @@
"body_fat": "Graisse corporelle",
"height": "Taille",
"blood_pressure": "Pression artérielle",
+ "blood_pressure_systolic": "Systolique",
+ "blood_pressure_diastolic": "Diastolique",
"heart_rate": "Fréquence cardiaque",
"resting_heart_rate": "Fréquence cardiaque au repos",
"steps": "Pas",
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 98ee63d81..e2cb9f7df 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -8,6 +8,8 @@ export const METRIC_TYPES = [
'body_fat',
'height',
'blood_pressure',
+ 'blood_pressure_systolic',
+ 'blood_pressure_diastolic',
'heart_rate',
'resting_heart_rate',
'steps',
@@ -50,6 +52,32 @@ export function isOfficialMetricType(type: MetricType): boolean {
return type === METRIC_TYPE_BODY_WEIGHT;
}
+/**
+ * The components of the multi-value metric types, in group order. Mirrors
+ * GROUP_COMPONENTS on the server, which is what creates these categories.
+ */
+export const GROUP_COMPONENTS: Partial> = {
+ // eslint-disable-next-line camelcase
+ blood_pressure: ['blood_pressure_systolic', 'blood_pressure_diastolic'],
+};
+
+/**
+ * A container type whose readings live in its components, e.g. blood pressure.
+ * A group category never carries entries of its own.
+ */
+export function isGroupMetricType(type: MetricType): boolean {
+ return type in GROUP_COMPONENTS;
+}
+
+/**
+ * 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
+ * never offered when creating a category.
+ */
+export function isComponentMetricType(type: MetricType): boolean {
+ return Object.values(GROUP_COMPONENTS).some(components => components.includes(type));
+}
+
export class MeasurementCategory {
entries: MeasurementEntry[] = [];
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index dcf7e62be..2099c73c9 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -105,7 +105,7 @@ describe("Test the CategoryForm component", () => {
expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm'));
});
- test('The body weight metric type is not offered', async () => {
+ test('The body weight and component metric types are not offered', async () => {
// Arrange
const user = userEvent.setup();
@@ -120,9 +120,11 @@ describe("Test the CategoryForm component", () => {
// Assert
expect(screen.getByRole('option', { name: 'measurements.metricTypes.steps' })).toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'measurements.metricTypes.body_weight' })).toBeNull();
+ expect(screen.queryByRole('option', { name: 'measurements.metricTypes.blood_pressure_systolic' })).toBeNull();
+ expect(screen.queryByRole('option', { name: 'measurements.metricTypes.blood_pressure_diastolic' })).toBeNull();
});
- test('Creating a category with a metric type and group', async () => {
+ test('Creating a category inside a group', async () => {
// Arrange
const user = userEvent.setup();
@@ -132,12 +134,9 @@ describe("Test the CategoryForm component", () => {
);
- await user.type(await screen.findByLabelText('name'), 'Systolic');
+ await user.type(await screen.findByLabelText('name'), 'Something');
await user.type(await screen.findByLabelText('unit'), 'mmHg');
- await user.click(screen.getByRole('combobox', { name: 'measurements.metricType' }));
- await user.click(screen.getByRole('option', { name: 'measurements.metricTypes.blood_pressure' }));
-
await user.click(screen.getByRole('combobox', { name: 'measurements.partOfGroup' }));
await user.click(screen.getByRole('option', { name: 'Blood pressure' }));
@@ -146,15 +145,49 @@ describe("Test the CategoryForm component", () => {
// Assert
expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(
null,
- 'Systolic',
+ 'Something',
'mmHg',
undefined,
- 'blood_pressure',
+ 'custom',
false,
TEST_GROUP_CATEGORY.id,
));
});
+ test('A typed category cannot be put into a group', async () => {
+ // Arrange
+ const user = userEvent.setup();
+
+ // Act
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('combobox', { name: 'measurements.metricType' }));
+ await user.click(screen.getByRole('option', { name: 'measurements.metricTypes.steps' }));
+
+ // Assert - the group selector is gone, a typed category stays top-level
+ expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull();
+ });
+
+ test('A group is not offered as a parent, it only holds its own components', async () => {
+ // Arrange
+ (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
+ data: [MeasurementCategory.clone(TEST_GROUP_CATEGORY, { metricType: 'blood_pressure' })]
+ }));
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert - no eligible parent left, so the selector is not rendered
+ expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull();
+ });
+
test('Only entry-free top-level categories are offered as parents', async () => {
// Arrange
const user = userEvent.setup();
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index 2a45ec027..7b058aab5 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -1,4 +1,6 @@
import {
+ isComponentMetricType,
+ isGroupMetricType,
isOfficialMetricType,
MeasurementCategory,
METRIC_TYPES,
@@ -27,18 +29,23 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
const useEditCategoryQuery = useEditMeasurementCategoryQuery(category?.id || '');
const categoryQuery = useMeasurementsCategoryQuery();
- // Official metric types are reserved for the server-managed categories
- const metricTypeChoices = METRIC_TYPES.filter(m => !isOfficialMetricType(m) || m === category?.metricType);
+ // Official metric types are reserved for the server-managed categories,
+ // components are a structural type: they only exist as the children of
+ // their group, which the server creates them with
+ const metricTypeChoices = METRIC_TYPES.filter(m =>
+ (!isOfficialMetricType(m) && !isComponentMetricType(m)) || m === category?.metricType);
// Multi-value groups, e.g. blood pressure. Mirrors the server rules: only
- // top-level, entry-free categories can be parents, and a category that
- // already has children cannot be nested. The current parent always stays
+ // top-level, entry-free categories can be parents, a category that already
+ // has children cannot be nested, a typed category stays top-level, and a
+ // group takes only its own components. The current parent always stays
// selectable so editing something else doesn't silently drop it.
const categories = categoryQuery.data ?? [];
const hasChildren = category?.id != null && categories.some(c => c.parentId === category.id);
const parentCandidates = categories.filter(c =>
c.parentId === null
&& c.id !== category?.id
+ && !isGroupMetricType(c.metricType)
&& (c.entries.length === 0 || c.id === category?.parentId)
);
// Match the backend column limits. We do NOT enforce a minimum length:
@@ -134,7 +141,8 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
)}
- {!hasChildren && parentCandidates.length > 0 &&
+ {!hasChildren && formik.values.metricType === 'custom'
+ && parentCandidates.length > 0 &&
Date: Sun, 2 Aug 2026 13:49:06 +0200
Subject: [PATCH 29/71] Mirror the sleep stage metric types
---
public/locales/de/translation.json | 7 ++++++-
public/locales/en/translation.json | 7 ++++++-
public/locales/es/translation.json | 7 ++++++-
public/locales/fr/translation.json | 7 ++++++-
.../Measurements/models/Category.test.ts | 21 ++++++++++++++++++-
.../Measurements/models/Category.ts | 20 +++++++++++++++++-
6 files changed, 63 insertions(+), 6 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 0044d2feb..f192bf93c 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -271,7 +271,12 @@
"steps": "Schritte",
"distance": "Distanz",
"energy": "Energie",
- "sleep": "Schlaf"
+ "sleep": "Schlaf",
+ "sleep_total": "Gesamtschlaf",
+ "sleep_light": "Leichtschlaf",
+ "sleep_deep": "Tiefschlaf",
+ "sleep_rem": "REM-Schlaf",
+ "sleep_awake": "Wach"
},
"indicatorRaw": "raw",
"indicatorAvg": "Durchschn.",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 600bfd8a9..f3d4038cc 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -355,7 +355,12 @@
"steps": "Steps",
"distance": "Distance",
"energy": "Energy",
- "sleep": "Sleep"
+ "sleep": "Sleep",
+ "sleep_total": "Total sleep",
+ "sleep_light": "Light sleep",
+ "sleep_deep": "Deep sleep",
+ "sleep_rem": "REM sleep",
+ "sleep_awake": "Awake"
},
"indicatorRaw": "raw",
"indicatorAvg": "avg",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 00772c168..5f5e7ae0c 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -273,7 +273,12 @@
"steps": "Pasos",
"distance": "Distancia",
"energy": "Energía",
- "sleep": "Sueño"
+ "sleep": "Sueño",
+ "sleep_total": "Sueño total",
+ "sleep_light": "Sueño ligero",
+ "sleep_deep": "Sueño profundo",
+ "sleep_rem": "Sueño REM",
+ "sleep_awake": "Despierto"
},
"indicatorRaw": "Bruto",
"indicatorAvg": "medio",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index f7279ef6a..c30961bd3 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -356,7 +356,12 @@
"steps": "Pas",
"distance": "Distance",
"energy": "Énergie",
- "sleep": "Sommeil"
+ "sleep": "Sommeil",
+ "sleep_total": "Sommeil total",
+ "sleep_light": "Sommeil léger",
+ "sleep_deep": "Sommeil profond",
+ "sleep_rem": "Sommeil paradoxal",
+ "sleep_awake": "Éveillé"
},
"indicatorRaw": "brut",
"indicatorAvg": "moy",
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 8b8de88f6..21fd0d781 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -1,4 +1,10 @@
-import { isSummedPerDay, MeasurementCategory, metricTypeFromApi } from "./Category";
+import {
+ isComponentMetricType,
+ isGroupMetricType,
+ isSummedPerDay,
+ MeasurementCategory,
+ metricTypeFromApi
+} from "./Category";
describe('MeasurementCategory', () => {
@@ -61,10 +67,23 @@ describe('MeasurementCategory', () => {
expect(isSummedPerDay('distance')).toBe(true);
expect(isSummedPerDay('energy')).toBe(true);
expect(isSummedPerDay('sleep')).toBe(true);
+ expect(isSummedPerDay('sleep_total')).toBe(true);
+ expect(isSummedPerDay('sleep_deep')).toBe(true);
expect(isSummedPerDay('custom')).toBe(false);
expect(isSummedPerDay('body_weight')).toBe(false);
expect(isSummedPerDay('heart_rate')).toBe(false);
expect(isSummedPerDay('blood_pressure')).toBe(false);
});
+
+ test('sleep is a group of stage components', () => {
+ expect(isGroupMetricType('sleep')).toBe(true);
+ expect(isComponentMetricType('sleep_total')).toBe(true);
+ expect(isComponentMetricType('sleep_awake')).toBe(true);
+
+ // The group itself is never a component, and a leaf is neither
+ expect(isComponentMetricType('sleep')).toBe(false);
+ expect(isGroupMetricType('sleep_deep')).toBe(false);
+ expect(isGroupMetricType('heart_rate')).toBe(false);
+ });
});
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index e2cb9f7df..b55125093 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -16,6 +16,11 @@ export const METRIC_TYPES = [
'distance',
'energy',
'sleep',
+ 'sleep_total',
+ 'sleep_light',
+ 'sleep_deep',
+ 'sleep_rem',
+ 'sleep_awake',
] as const;
export type MetricType = typeof METRIC_TYPES[number];
@@ -32,7 +37,15 @@ export function metricTypeFromApi(value: unknown): MetricType {
* they are summed per day and charted as bars instead of a line
*/
export function isSummedPerDay(type: MetricType): boolean {
- return type === 'steps' || type === 'distance' || type === 'energy' || type === 'sleep';
+ return type === 'steps'
+ || type === 'distance'
+ || type === 'energy'
+ || type === 'sleep'
+ || type === 'sleep_total'
+ || type === 'sleep_light'
+ || type === 'sleep_deep'
+ || type === 'sleep_rem'
+ || type === 'sleep_awake';
}
/**
@@ -59,6 +72,11 @@ export function isOfficialMetricType(type: MetricType): boolean {
export const GROUP_COMPONENTS: Partial> = {
// eslint-disable-next-line camelcase
blood_pressure: ['blood_pressure_systolic', 'blood_pressure_diastolic'],
+ // The total is a component of its own because a group carries no
+ // measurements. It is not the sum of the three stages next to it: platforms
+ // also report sleep without a stage breakdown, which counts towards the
+ // total and has no stage category to live in
+ sleep: ['sleep_total', 'sleep_light', 'sleep_deep', 'sleep_rem', 'sleep_awake'],
};
/**
From 682a3458b50acfb90913b46d043c3b5ef36222ac Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 14:14:47 +0200
Subject: [PATCH 30/71] Stack the sleep stages in the group chart
---
.../Measurements/charts/data.test.ts | 68 ++++++++++++-
src/components/Measurements/charts/data.ts | 73 ++++++++++++--
src/components/Measurements/charts/format.ts | 3 +-
.../Measurements/models/Category.ts | 9 ++
.../widgets/MeasurementChart.test.tsx | 18 +++-
.../Measurements/widgets/MeasurementChart.tsx | 98 ++++++++++++++++++-
6 files changed, 254 insertions(+), 15 deletions(-)
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 65b43ec19..8e57d93a0 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -1,4 +1,4 @@
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
aggregatePerDay,
@@ -8,9 +8,11 @@ import {
groupChart,
groupComponentSeries,
groupRangeEntries,
+ groupStackedEntries,
moving7dAverage,
overallChange,
- smoothedTrendline
+ smoothedTrendline,
+ stackableComponents
} from "@/components/Measurements/charts/data";
import { ChartPoint } from "@/components/Measurements/charts/series";
import { describe, expect, test } from 'vitest';
@@ -310,6 +312,68 @@ describe('groups', () => {
});
});
+describe('sleep group', () => {
+ /** A sleep group: the total plus two stages, all on the same night */
+ const sleep = (withStages: boolean = true) => {
+ const group = new MeasurementCategory('g-s', 'Sleep', 'min', [], 'sleep');
+ const child = (
+ id: string,
+ name: string,
+ type: MetricType,
+ order: number,
+ value: number | null,
+ ) => {
+ const category = new MeasurementCategory(id, name, 'min', [], type, false, 'g-s', order);
+ category.entries = value === null
+ ? []
+ : [new MeasurementEntry(`e-${id}`, id, day(2), value, '')];
+ return category;
+ };
+
+ group.children = [
+ child('total', 'Total sleep', 'sleep_total', 0, 480),
+ child('deep', 'Deep sleep', 'sleep_deep', 1, withStages ? 90 : null),
+ child('rem', 'REM sleep', 'sleep_rem', 2, withStages ? 60 : null),
+ ];
+ return group;
+ };
+
+ test('the roll-up component is left out of the stack', () => {
+ // Total sleep covers the stages, so stacking it would count the night
+ // twice
+ expect(stackableComponents(sleep()).map(c => c.metricType))
+ .toEqual(['sleep_deep', 'sleep_rem']);
+ });
+
+ test('stacked entries carry one value per component and day', () => {
+ const stacked = groupStackedEntries(stackableComponents(sleep()));
+
+ expect(stacked).toStrictEqual([{ date: day(2).getTime(), values: [90, 60] }]);
+ });
+
+ test('several entries of one day add up within their component', () => {
+ // A nap next to the night: the bar shows the day, not the segment
+ const group = sleep();
+ const deep = group.children[1];
+ deep.entries = [...deep.entries, new MeasurementEntry('e-nap', 'deep', day(2, 14), 20, '')];
+
+ expect(groupStackedEntries(stackableComponents(group))[0].values).toEqual([110, 60]);
+ });
+
+ test('a summed group stacks its components', () => {
+ const chart = groupChart(sleep());
+
+ expect(chart.kind).toBe('stacked');
+ expect(chart.kind === 'stacked' && chart.labels).toEqual(['Deep sleep', 'REM sleep']);
+ });
+
+ test('without stage data the group falls back to component lines', () => {
+ // Only the total reported, so there is nothing to stack. Falling
+ // through keeps the chart from going blank while data exists
+ expect(groupChart(sleep(false)).kind).toBe('components');
+ });
+});
+
describe('overallChange', () => {
test('is null for an empty series', () => {
expect(overallChange([])).toBeNull();
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 132aab4c8..40e165f83 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -1,4 +1,8 @@
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import {
+ isGroupTotalMetricType,
+ isSummedPerDay,
+ MeasurementCategory
+} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { pointsSince } from "@/components/Measurements/charts/range";
import { ChartPoint, ChartSeries, PlanPeriod } from "@/components/Measurements/charts/series";
@@ -300,20 +304,77 @@ export const measurementSeries = (
export const pointsOfRole = (series: ChartSeries[], role: ChartSeries['role']): ChartPoint[] =>
series.find(s => s.role === role)?.points ?? [];
+/**
+ * The components of a group that stack into one whole, i.e. everything but a
+ * roll-up component (see isGroupTotalMetricType)
+ */
+export const stackableComponents = (group: MeasurementCategory): MeasurementCategory[] =>
+ group.children.filter(child => !isGroupTotalMetricType(child.metricType));
+
+/** One stacked bar: a day, and what each component contributed to it */
+export interface StackedPoint {
+ date: number;
+ /** Runs parallel to the labels of the chart, a 0 where nothing was reported */
+ values: number[];
+}
+
+/**
+ * One stacked bar per day for the given components, stacked in the order they
+ * are given.
+ *
+ * Only days that any component reported are returned. Values are read through
+ * the unit helper, like everywhere else, so a component holding mixed units
+ * still stacks correctly.
+ */
+export const groupStackedEntries = (
+ components: MeasurementCategory[],
+ cutoff: Date | null = null,
+): StackedPoint[] => {
+ const byDay = new Map();
+ components.forEach((child, index) => {
+ for (const entry of child.entries) {
+ if (cutoff !== null && entry.date < cutoff) {
+ continue;
+ }
+ const date = entry.date;
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+ const values = byDay.get(day) ?? new Array(components.length).fill(0);
+ // A component can hold several entries for one day (a nap next to
+ // the night), and the bar shows the day, so they add up
+ values[index] += entry.valueIn(child.unit, child.unit);
+ byDay.set(day, values);
+ }
+ });
+
+ return [...byDay.entries()]
+ .map(([date, values]) => ({ date: date, values: values }))
+ .sort((a, b) => a.date - b.date);
+};
+
/**
* 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.
+ * Components that are parts of one whole (the sleep stages) stack into one bar
+ * per day. Two components that are the ends of a reading 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: 'stacked', points: StackedPoint[], labels: string[] }
| { kind: 'range', points: ChartPoint[] }
| { kind: 'components', series: ChartSeries[] };
export const groupChart = (group: MeasurementCategory, cutoff: Date | null = null): GroupChart => {
+ if (isSummedPerDay(group.metricType)) {
+ const components = stackableComponents(group);
+ const stacked = groupStackedEntries(components, cutoff);
+ if (stacked.length > 0) {
+ return { kind: 'stacked', points: stacked, labels: components.map(c => c.name) };
+ }
+ }
+
const ranges = group.children.length === 2 ? groupRangeEntries(group, cutoff) : [];
return ranges.length > 0
diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts
index 6b42b5842..0f52dc59d 100644
--- a/src/components/Measurements/charts/format.ts
+++ b/src/components/Measurements/charts/format.ts
@@ -1,9 +1,8 @@
-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 => {
+export const spansYears = (points: { date: number }[]): boolean => {
if (points.length === 0) {
return false;
}
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index b55125093..663c5f2a7 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -96,6 +96,15 @@ export function isComponentMetricType(type: MetricType): boolean {
return Object.values(GROUP_COMPONENTS).some(components => components.includes(type));
}
+/**
+ * The component that rolls its siblings up instead of being one part next to
+ * them. Total sleep already covers the stages beside it, so a stacked chart
+ * has to leave it out or it counts every night twice.
+ */
+export function isGroupTotalMetricType(type: MetricType): boolean {
+ return type === 'sleep_total';
+}
+
export class MeasurementCategory {
entries: MeasurementEntry[] = [];
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 43c8b6517..8bc467105 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,5 +1,5 @@
import { render } from '@testing-library/react';
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
import React from 'react';
@@ -50,4 +50,20 @@ describe('MeasurementChart', () => {
render( );
});
+
+ test('mounts a stacked chart for a sleep group', () => {
+ const group = new MeasurementCategory('g-s', 'Sleep', 'min', [], 'sleep');
+ const stage = (id: string, name: string, type: MetricType, value: number) => {
+ const category = new MeasurementCategory(id, name, 'min', [], type, false, 'g-s');
+ category.entries = [new MeasurementEntry(`d-${id}`, id, new Date(2023, 1, 2), value, '')];
+ return category;
+ };
+ group.children = [
+ stage('total', 'Total sleep', 'sleep_total', 480),
+ stage('deep', 'Deep sleep', 'sleep_deep', 90),
+ stage('rem', 'REM sleep', 'sleep_rem', 60),
+ ];
+
+ render( );
+ });
});
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index afd42bd50..d8379bc54 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -5,8 +5,10 @@ import {
chartPointsFor,
fillMissingDays,
groupChart,
- measurementSeries
+ measurementSeries,
+ StackedPoint
} from "@/components/Measurements/charts/data";
+import { componentColor, componentPalette } from "@/components/Measurements/charts/colors";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
import {
@@ -162,6 +164,86 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
;
};
+interface StackedTooltipProps {
+ active?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload?: any;
+ label?: string;
+ unit: string;
+}
+
+/** The whole bar with its parts: a single segment says little without the night it belongs to */
+const StackedTooltip = ({ active, payload, label, unit }: StackedTooltipProps) => {
+ const [, i18n] = useTranslation();
+
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const parts = 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 (
+
+ {dateToLocale(new Date(Number(label)))}
+ {valueWithUnit(total, unit, i18n.language)}
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {parts.map((entry: any) =>
+ {entry.dataKey}: {numberDecimalLocale(entry.value, i18n.language)}
+
)}
+
+ );
+};
+
+/**
+ * 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.
+ */
+const MeasurementStackedBarChart = (props: {
+ points: StackedPoint[],
+ labels: string[],
+ unit: string,
+}) => {
+ const [, i18n] = useTranslation();
+ 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]])),
+ }));
+
+ return
+
+
+
+ valueWithUnit(value, props.unit, i18n.language)} />
+ } />
+ {props.labels.map((label, index) => )}
+
+ ;
+};
+
const MeasurementLineChart = (props: {
category: MeasurementCategory,
cutoff: Date | null,
@@ -193,9 +275,17 @@ export const MeasurementChart = (props: {
if (props.category.isGroup) {
const chart = groupChart(props.category, cutoff);
- return chart.kind === 'range'
- ?
- : ;
+ switch (chart.kind) {
+ case 'stacked':
+ return ;
+ case 'range':
+ return ;
+ case 'components':
+ return ;
+ }
}
return isSummedPerDay(props.category.metricType)
From ec874bc6263cb8e8e6553b95ad41c2853f8cc0e3 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 14:40:14 +0200
Subject: [PATCH 31/71] Fetch only the range the charts show
---
.../Measurements/api/measurements.ts | 7 +++-
.../Measurements/charts/range.test.ts | 39 ++++++++++++++++++
src/components/Measurements/charts/range.ts | 40 ++++++++++++++++++-
src/components/Measurements/queries/index.ts | 14 ++++---
.../screens/MeasurementCategoryDetail.tsx | 8 ++--
.../screens/MeasurementCategoryOverview.tsx | 8 +++-
6 files changed, 103 insertions(+), 13 deletions(-)
create mode 100644 src/components/Measurements/charts/range.test.ts
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 754a2ae19..8a820ad45 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -76,7 +76,10 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
return categories.filter(c => c.parentId === null);
};
-export const getMeasurementCategory = async (id: string): Promise => {
+export const getMeasurementCategory = async (
+ id: string,
+ filtersetQueryEntries: object = {},
+): Promise => {
const { data: receivedCategories } = await axios.get(
makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }),
{ headers: makeHeader() },
@@ -96,7 +99,7 @@ export const getMeasurementCategory = async (id: string): Promise a.order - b.order);
await Promise.all([category, ...category.children].map(async (cat) => {
- cat.entries = await loadEntries(cat.id!);
+ cat.entries = await loadEntries(cat.id!, filtersetQueryEntries);
}));
return category;
diff --git a/src/components/Measurements/charts/range.test.ts b/src/components/Measurements/charts/range.test.ts
new file mode 100644
index 000000000..6549f782b
--- /dev/null
+++ b/src/components/Measurements/charts/range.test.ts
@@ -0,0 +1,39 @@
+import { entryFilterFor, fetchCutoffFor } from "@/components/Measurements/charts/range";
+import { describe, expect, test } from 'vitest';
+
+const noon = new Date(2026, 5, 15, 12, 30);
+
+describe('fetchCutoffFor', () => {
+ test('fetches a week beyond the cutoff, for the moving average', () => {
+ // The first days in range average the days before them, so those have
+ // to be fetched as well. Rounding to midnight also makes the bound
+ // immune to the hour the clock change shifts cutoffFor by
+ expect(fetchCutoffFor('last3Months', noon)).toStrictEqual(new Date(2026, 2, 10));
+ expect(fetchCutoffFor('lastYear', noon)).toStrictEqual(new Date(2025, 5, 8));
+ });
+
+ 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
+ const morning = new Date(2026, 5, 15, 6, 0);
+ const evening = new Date(2026, 5, 15, 23, 59);
+
+ expect(fetchCutoffFor('last3Months', morning))
+ .toStrictEqual(fetchCutoffFor('last3Months', evening));
+ });
+
+ test('the full history is fetched whole', () => {
+ expect(fetchCutoffFor('all', noon)).toBeNull();
+ });
+});
+
+describe('entryFilterFor', () => {
+ test('filters the entries by the fetch cutoff', () => {
+ expect(entryFilterFor('last3Months', noon))
+ .toStrictEqual({ "date__gte": new Date(2026, 2, 10).toISOString() });
+ });
+
+ test('the full history needs no filter', () => {
+ expect(entryFilterFor('all', noon)).toStrictEqual({});
+ });
+});
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index 035891026..aa8e82bd9 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -11,6 +11,8 @@ export type ChartRange = typeof CHART_RANGES[number];
export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
+const DAY_MS = 24 * 60 * 60 * 1000;
+
const DAYS: Record = {
last3Months: 90,
lastYear: 365,
@@ -21,7 +23,43 @@ const DAYS: Record = {
export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => {
const days = DAYS[range];
- return days === null ? null : new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
+ return days === null ? null : new Date(now.getTime() - days * DAY_MS);
+};
+
+/**
+ * Days fetched beyond the cutoff, so the moving average of the first days in
+ * range averages the days before them instead of starting over at the cutoff.
+ * Matches AVERAGE_WINDOW_DAYS in charts/data.
+ */
+const AVERAGE_LEAD_DAYS = 7;
+
+/**
+ * Oldest entry to fetch for a range, null for the full history.
+ *
+ * Rounded down to midnight, and deliberately so: this ends up in a query key,
+ * and a bound derived from the current instant would differ on every render
+ * and refetch forever.
+ */
+export const fetchCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => {
+ const cutoff = cutoffFor(range, now);
+ if (cutoff === null) {
+ return null;
+ }
+
+ const lead = new Date(cutoff.getTime() - AVERAGE_LEAD_DAYS * DAY_MS);
+
+ return new Date(lead.getFullYear(), lead.getMonth(), lead.getDate());
+};
+
+/**
+ * Entry filter that fetches only what a range needs, empty for the full
+ * history. The server has an index on (category, date), so this is cheaper
+ * than fetching everything and filtering here.
+ */
+export const entryFilterFor = (range: ChartRange, now: Date = new Date()): object => {
+ const cutoff = fetchCutoffFor(range, now);
+
+ return cutoff === null ? {} : { "date__gte": cutoff.toISOString() };
};
/** The points from the cutoff on; a null cutoff covers the full history */
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index f6e04ad74..f4ffda31a 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -13,13 +13,16 @@ import {
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { QueryKey } from "@/core/lib/consts";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) {
return useQuery({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, JSON.stringify(options || {})],
- queryFn: () => getMeasurementCategories(options)
+ queryFn: () => getMeasurementCategories(options),
+ // Widening the range refetches, and the charts would otherwise drop
+ // back to the loading placeholder while the longer history arrives
+ placeholderData: keepPreviousData,
});
}
@@ -81,10 +84,11 @@ export const useReorderMeasurementCategoriesQuery = () => {
});
};
-export function useMeasurementsQuery(id: string) {
+export function useMeasurementsQuery(id: string, filtersetQueryEntries: object = {}) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENTS, id],
- queryFn: () => getMeasurementCategory(id)
+ queryKey: [QueryKey.MEASUREMENTS, id, JSON.stringify(filtersetQueryEntries)],
+ queryFn: () => getMeasurementCategory(id, filtersetQueryEntries),
+ placeholderData: keepPreviousData,
});
}
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 531499359..cc8bcbcab 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -6,7 +6,7 @@ import { useMeasurementsQuery } from "@/components/Measurements/queries";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
-import { ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
+import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
import { AddMeasurementEntryFab } from "@/components/Measurements/widgets/fab";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
@@ -20,10 +20,12 @@ export const MeasurementCategoryDetail = () => {
return Please pass a category id.
;
}
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const categoryQuery = useMeasurementsQuery(categoryId);
// eslint-disable-next-line react-hooks/rules-of-hooks
const [range, setRange] = React.useState(DEFAULT_CHART_RANGE);
+ // Fetch what the range shows, rather than the whole history. The grid
+ // below lists the same entries, so it follows the range too
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const categoryQuery = useMeasurementsQuery(categoryId, entryFilterFor(range));
// eslint-disable-next-line react-hooks/rules-of-hooks
const planPeriods = useNutritionPlanPeriods(
correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index d276cfc21..8718d737b 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
-import { ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
+import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } 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";
@@ -53,12 +53,16 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
};
export const MeasurementCategoryOverview = () => {
- const categoryQuery = useMeasurementsCategoryQuery();
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);
+ // Fetch what the range shows, rather than the whole history: this page
+ // charts three months by default, and a synced account holds years
+ const categoryQuery = useMeasurementsCategoryQuery({
+ filtersetQueryEntries: entryFilterFor(range),
+ });
return categoryQuery.isLoading
?
From a11efb052d198556dccc97213456bf28f955d59c Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 18:42:16 +0200
Subject: [PATCH 32/71] Mirror the per-metric value limits from the server
---
src/components/Measurements/index.ts | 7 +-
.../Measurements/models/Category.test.ts | 19 +++++
.../Measurements/models/Category.ts | 69 +++++++++++++++++++
.../widgets/CategoryDetailDataGrid.tsx | 11 ++-
.../Measurements/widgets/EntryForm.tsx | 35 +++++++---
.../Weight/forms/WeightForm.test.tsx | 8 +--
src/components/Weight/forms/WeightForm.tsx | 8 +--
src/components/Weight/widgets/Table/index.tsx | 6 +-
src/core/lib/weightUnit.ts | 8 ---
9 files changed, 139 insertions(+), 32 deletions(-)
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index cd0b119a8..a7bae4dbb 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -8,7 +8,12 @@ export { MeasurementCategoryDetail } from "./screens/MeasurementCategoryDetail";
export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOverview";
// Models
-export { correlatesWithNutrition, MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "./models/Category";
+export {
+ correlatesWithNutrition,
+ limitsFor,
+ MeasurementCategory,
+ METRIC_TYPE_BODY_WEIGHT
+} from "./models/Category";
export { MeasurementEntry } from "./models/Entry";
// API endpoints
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 21fd0d781..1a9583696 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -2,6 +2,8 @@ import {
isComponentMetricType,
isGroupMetricType,
isSummedPerDay,
+ limitsFor,
+ MEASUREMENT_SCHEMA_MAX_VALUE,
MeasurementCategory,
metricTypeFromApi
} from "./Category";
@@ -76,6 +78,23 @@ describe('MeasurementCategory', () => {
expect(isSummedPerDay('blood_pressure')).toBe(false);
});
+ test('value limits are per unit for body weight only', () => {
+ expect(limitsFor('body_weight', 'kg').max).toBe(350);
+ expect(limitsFor('body_weight', 'lb').max).toBe(770);
+
+ // every other type has one unit, so the argument changes nothing
+ expect(limitsFor('heart_rate', 'bpm').max).toBe(limitsFor('heart_rate').max);
+ });
+
+ test('value limits of the components differ from each other', () => {
+ expect(limitsFor('blood_pressure_systolic').max).toBe(250);
+ expect(limitsFor('blood_pressure_diastolic').max).toBe(150);
+ });
+
+ test('an untyped category is only bounded by the column itself', () => {
+ expect(limitsFor('custom')).toEqual({ min: 0, max: MEASUREMENT_SCHEMA_MAX_VALUE });
+ });
+
test('sleep is a group of stage components', () => {
expect(isGroupMetricType('sleep')).toBe(true);
expect(isComponentMetricType('sleep_total')).toBe(true);
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 663c5f2a7..990fd3840 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -1,5 +1,6 @@
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { Adapter } from "@/core/lib/Adapter";
+import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
/** Semantic category types, the values mirror the Django MetricType choices */
export const METRIC_TYPES = [
@@ -87,6 +88,74 @@ export function isGroupMetricType(type: MetricType): boolean {
return type in GROUP_COMPONENTS;
}
+/**
+ * Largest value the server's column can hold (numeric(8, 2)). It is what a
+ * category without a metric type is bounded by, since nothing about a free-form
+ * category says more.
+ */
+export const MEASUREMENT_SCHEMA_MAX_VALUE = 999999.99;
+
+/**
+ * The range a measurement value of one metric type may be in. min/max are what
+ * the API enforces, a value outside them comes back as a 400; softMin/softMax
+ * are the everyday range, meant for warnings and chart axes, and are enforced
+ * nowhere.
+ */
+export interface MetricLimits {
+ min: number;
+ max: number;
+ softMin?: number;
+ softMax?: number;
+}
+
+/**
+ * The bounds per metric type, in the unit the type is stored in.
+ *
+ * MUST stay identical to METRIC_LIMITS on the server. Bounds may be widened
+ * over releases, never tightened: a client that still knows the wider one would
+ * write values the server then rejects permanently.
+ */
+/* eslint-disable camelcase */
+const METRIC_LIMITS: Partial> = {
+ body_fat: { min: 2, max: 60, softMin: 5, softMax: 50 },
+ 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 },
+ heart_rate: { min: 30, max: 250, softMin: 40, softMax: 200 },
+ resting_heart_rate: { min: 30, max: 120, softMin: 40, 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 },
+ energy: { min: 0, max: 10000, softMin: 0, softMax: 2000 },
+ // Sleep is stored in minutes, so the upper bound is not a rarity but
+ // arithmetic: a day has 1440 of them
+ sleep_total: { min: 0, max: 1440, softMin: 180, softMax: 720 },
+ sleep_light: { min: 0, max: 1440, softMin: 0, softMax: 720 },
+ sleep_deep: { min: 0, max: 1440, softMin: 0, softMax: 720 },
+ sleep_rem: { min: 0, max: 1440, softMin: 0, softMax: 720 },
+ sleep_awake: { min: 0, max: 1440, softMin: 0, softMax: 720 },
+};
+/* eslint-enable camelcase */
+
+/** Body weight is the only metric whose values come in more than one unit */
+const BODY_WEIGHT_LIMITS: Record = {
+ kg: { min: 20, max: 350, softMin: 30, softMax: 300 },
+ lb: { min: 44, max: 770, softMin: 66, softMax: 661 },
+};
+
+/**
+ * The range a value in a category of this metric type may be in. Free-form
+ * categories, and the group containers that carry no entries at all, are only
+ * bounded by the column itself.
+ */
+export function limitsFor(type: MetricType, unit?: string): MetricLimits {
+ if (type === METRIC_TYPE_BODY_WEIGHT) {
+ return BODY_WEIGHT_LIMITS[isWeightUnit(unit) ? unit : 'kg'];
+ }
+
+ return METRIC_LIMITS[type] ?? { min: 0, max: MEASUREMENT_SCHEMA_MAX_VALUE };
+}
+
/**
* 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/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index fa094623d..f6f14e8c7 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -1,6 +1,6 @@
import { processTimeSeries } from "@/core/lib/timeSeries";
import { valueWithUnit } from "@/components/Measurements/charts/format";
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { PAGINATION_OPTIONS } from "@/core/lib/consts";
@@ -16,6 +16,7 @@ import {
GridActionsCellItem,
GridColDef,
GridEventListener,
+ GridPreProcessEditCellProps,
GridRowEditStopReasons,
GridRowId,
GridRowModel,
@@ -109,6 +110,14 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
valueFormatter: (value?: number) => value == null
? ''
: valueWithUnit(value, props.category.unit, i18n.language),
+ // A value outside the bounds of the metric type is refused by the
+ // API, so the row cannot be saved with one either
+ preProcessEditCellProps: (params: GridPreProcessEditCellProps) => {
+ const value = Number(params.props.value);
+ const { min, max } = limitsFor(props.category.metricType, props.category.unit);
+
+ return { ...params.props, error: isNaN(value) || value < min || value > max };
+ },
},
{
field: 'date',
diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx
index ff9707592..3aa965cda 100644
--- a/src/components/Measurements/widgets/EntryForm.tsx
+++ b/src/components/Measurements/widgets/EntryForm.tsx
@@ -2,7 +2,7 @@ import { Button, Stack, TextField } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
useAddGroupEntriesQuery,
@@ -31,12 +31,19 @@ export const EntryForm = ({ entry, closeFn, categoryId }: EntryFormProps) => {
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
+ const category = categoryQuery.data;
+ const limits = limitsFor(
+ category?.metricType ?? 'custom',
+ entry && category ? entry.unitOrFallback(category.unit) : category?.unit,
+ );
const validationSchema = yup.object({
value: yup
.number()
.required(t('forms.fieldRequired'))
- .min(0, t('forms.minValue', { value: '0' }))
- .max(1000, t('forms.maxValue', { value: '1000' })),
+ .min(limits.min, t('forms.minValue', { value: String(limits.min) }))
+ .max(limits.max, t('forms.maxValue', { value: String(limits.max) })),
date: yup
.date()
.required(t('forms.fieldRequired')),
@@ -142,14 +149,20 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
date: yup
.date()
.required(t('forms.fieldRequired')),
- values: yup.object(Object.fromEntries(group.children.map(child => [
- child.id!,
- yup
- .number()
- .required(t('forms.fieldRequired'))
- .min(0, t('forms.minValue', { value: '0' }))
- .max(1000, t('forms.maxValue', { value: '1000' })),
- ]))),
+ // Each component is bounded by its own type: systolic and diastolic
+ // do not share a range
+ values: yup.object(Object.fromEntries(group.children.map(child => {
+ const limits = limitsFor(child.metricType, child.unit);
+
+ return [
+ child.id!,
+ yup
+ .number()
+ .required(t('forms.fieldRequired'))
+ .min(limits.min, t('forms.minValue', { value: String(limits.min) }))
+ .max(limits.max, t('forms.maxValue', { value: String(limits.max) })),
+ ];
+ }))),
});
return (
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx
index e1b5e1313..79370ced1 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Weight/forms/WeightForm.test.tsx
@@ -169,9 +169,9 @@ describe("Test WeightForm component", () => {
);
const weightInput = await screen.findByLabelText('weight');
- // Act + Assert: 320 is over the 300 kg maximum...
+ // Act + Assert: 400 is over the kg maximum...
await user.clear(weightInput);
- await user.type(weightInput, '320');
+ await user.type(weightInput, '400');
await user.tab();
await waitFor(() => expect(weightInput).toHaveAttribute('aria-invalid', 'true'));
@@ -184,9 +184,9 @@ describe("Test WeightForm component", () => {
await waitFor(() => expect(mutateAddMock).toHaveBeenCalled());
const submitted = mutateAddMock.mock.calls[0][0] as MeasurementEntry;
expect(submitted.extraData.unit).toBe('lb');
- expect(Number(submitted.value)).toBe(320);
+ expect(Number(submitted.value)).toBe(400);
- // Act + Assert: 35 lb is below the lb minimum of 66
+ // Act + Assert: 35 lb is below the lb minimum
await user.clear(weightInput);
await user.type(weightInput, '35');
await user.tab();
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index afdf34545..b361f7e6a 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -1,7 +1,7 @@
import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
-import { MeasurementEntry } from "@/components/Measurements";
+import { limitsFor, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
import { extraDataInUnit, weightUnitOf } from "@/components/Weight/models/bodyWeight";
import {
useAddWeightEntryQuery,
@@ -10,7 +10,7 @@ import {
useEditWeightEntryQuery
} from "@/components/Weight/queries";
import { useProfileQuery } from "@/components/User";
-import { weightBounds, WeightUnit } from "@/core/lib/weightUnit";
+import { WeightUnit } from "@/core/lib/weightUnit";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { Form, Formik } from "formik";
import { DateTime } from "luxon";
@@ -34,8 +34,8 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
const [dateValue, setDateValue] = useState(weightEntry ? DateTime.fromJSDate(weightEntry.date) : DateTime.now);
const [t, i18n] = useTranslation();
- const lb = weightBounds('lb');
- const kg = weightBounds('kg');
+ const lb = limitsFor(METRIC_TYPE_BODY_WEIGHT, 'lb');
+ const kg = limitsFor(METRIC_TYPE_BODY_WEIGHT, 'kg');
const validationSchema = yup.object({
unit: yup.string().oneOf(['kg', 'lb']),
weight: yup
diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx
index 77415c3f6..819f6d481 100644
--- a/src/components/Weight/widgets/Table/index.tsx
+++ b/src/components/Weight/widgets/Table/index.tsx
@@ -16,12 +16,12 @@ import {
GridRowModesModel,
GridRowsProp,
} from "@mui/x-data-grid";
-import { MeasurementEntry } from "@/components/Measurements";
+import { limitsFor, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
import { extraDataInUnit } from "@/components/Weight/models/bodyWeight";
import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab";
import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
import { processTimeSeries } from "@/core/lib/timeSeries";
-import { weightBounds, WeightUnit } from "@/core/lib/weightUnit";
+import { WeightUnit } from "@/core/lib/weightUnit";
import { DateTime } from "luxon";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
@@ -92,7 +92,7 @@ export const WeightTable = ({ weights, unit, categoryUnit }: WeightTableProps) =
// the typed value is in the display unit the column header shows;
// throwing keeps the row in edit mode so it can be corrected
const weight = Number(newRow.weight);
- const { min, max } = weightBounds(unit);
+ const { min, max } = limitsFor(METRIC_TYPE_BODY_WEIGHT, unit);
if (weight < min) {
throw new Error(t('forms.minValue', { value: `${min} ${t(`server.${unit}`)}` }));
}
diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts
index 464da16b6..c89e03ccb 100644
--- a/src/core/lib/weightUnit.ts
+++ b/src/core/lib/weightUnit.ts
@@ -23,11 +23,3 @@ export function convertWeight(value: number, from: WeightUnit, to: WeightUnit):
return Math.round(converted * 100) / 100;
}
-
-/*
- * Plausibility bounds for body weight entries in the given unit:
- * 30 - 300 kg, or the same range expressed in lb
- */
-export function weightBounds(unit: WeightUnit): { min: number, max: number } {
- return unit === 'lb' ? { min: 66, max: 661 } : { min: 30, max: 300 };
-}
From 34178db91be361d01bcd25b811f3703894b990c8 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 19:11:11 +0200
Subject: [PATCH 33/71] Fetch body weight through the measurement loader
---
.../Calendar/Components/CalendarComponent.tsx | 7 ++-
src/components/Dashboard/WeightCard.tsx | 4 +-
.../Measurements/api/measurements.ts | 7 +--
src/components/Measurements/index.ts | 8 +++-
.../screens/MeasurementCategoryDetail.tsx | 15 ++++++-
.../Nutrition/screens/BmiCalculator.tsx | 5 ++-
.../widgets/charts/PlanWeightChart.tsx | 5 ++-
src/components/Weight/api/weight.test.ts | 45 +++++++++++++++++++
src/components/Weight/api/weight.ts | 37 +++++++--------
src/components/Weight/index.ts | 1 -
src/components/Weight/queries/index.ts | 19 +++++---
.../Weight/screens/BodyWeight.test.tsx | 19 ++++----
src/components/Weight/screens/BodyWeight.tsx | 15 +++++--
src/core/lib/date.test.ts | 30 +------------
src/core/lib/date.ts | 32 -------------
15 files changed, 136 insertions(+), 113 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index f11960f0e..d44045b68 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -35,7 +35,12 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const isStandalone = props.isStandalone ?? true;
- const weightsQuery = useBodyWeightQuery();
+ // 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 sessionQuery = useSessionsQuery({
filtersetQuerySessions: {
"date__gte": dateToYYYYMMDD(startOfMonth),
diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx
index 0601608e2..0d12f94b3 100644
--- a/src/components/Dashboard/WeightCard.tsx
+++ b/src/components/Dashboard/WeightCard.tsx
@@ -1,7 +1,7 @@
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
-import { MeasurementEntry } from "@/components/Measurements";
+import { entryFilterFor, MeasurementEntry } from "@/components/Measurements";
import {
useBodyWeightCategoryQuery,
useBodyWeightQuery,
@@ -20,7 +20,7 @@ import { DashboardCard } from "./DashboardCard";
export const WeightCard = () => {
const [t] = useTranslation();
- const weightyQuery = useBodyWeightQuery("lastYear");
+ const weightyQuery = useBodyWeightQuery(entryFilterFor('lastYear'));
if (weightyQuery.isLoading) {
return ;
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 8a820ad45..e83f66e71 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -14,7 +14,8 @@ export type MeasurementQueryOptions = {
filtersetQueryEntries?: object,
}
-const loadEntries = async (categoryId: string, filtersetQuery: object = {}): Promise => {
+/** Every entry of a category, over all pages */
+export const getMeasurementEntries = async (categoryId: string, filtersetQuery: object = {}): Promise => {
const out: MeasurementEntry[] = [];
const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
query: {
@@ -56,7 +57,7 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
// Load entries for each category
await Promise.all(categories.map(async (category) => {
- category.entries = await loadEntries(category.id!, filtersetQueryEntries);
+ category.entries = await getMeasurementEntries(category.id!, filtersetQueryEntries);
}));
// Multi-value groups: attach the children to their parent, only the
@@ -99,7 +100,7 @@ export const getMeasurementCategory = async (
category.children.sort((a, b) => a.order - b.order);
await Promise.all([category, ...category.children].map(async (cat) => {
- cat.entries = await loadEntries(cat.id!, filtersetQueryEntries);
+ cat.entries = await getMeasurementEntries(cat.id!, filtersetQueryEntries);
}));
return category;
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index a7bae4dbb..7a41c401a 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -17,7 +17,11 @@ export {
export { MeasurementEntry } from "./models/Entry";
// API endpoints
-export { API_MEASUREMENTS_CATEGORY_PATH, API_MEASUREMENTS_ENTRY_PATH } from "./api/measurements";
+export {
+ API_MEASUREMENTS_CATEGORY_PATH,
+ API_MEASUREMENTS_ENTRY_PATH,
+ getMeasurementEntries
+} from "./api/measurements";
// Query hooks
export { useMeasurementsCategoryQuery } from "./queries";
@@ -26,7 +30,7 @@ export { useMeasurementsCategoryQuery } from "./queries";
export { componentColor, componentPalette } from "./charts/colors";
export { groupChart, measurementSeries } from "./charts/data";
export { valueWithUnit } from "./charts/format";
-export { CHART_RANGES, cutoffFor, DEFAULT_CHART_RANGE } from "./charts/range";
+export { CHART_RANGES, cutoffFor, DEFAULT_CHART_RANGE, entryFilterFor } from "./charts/range";
export type { ChartRange } from "./charts/range";
export type { PlanPeriod } from "./charts/series";
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index cc8bcbcab..b3e7418df 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -1,7 +1,7 @@
import { Stack, Typography } from "@mui/material";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
-import { correlatesWithNutrition } from "@/components/Measurements/models/Category";
+import { correlatesWithNutrition, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
import { useMeasurementsQuery } from "@/components/Measurements/queries";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
@@ -10,8 +10,10 @@ import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Me
import { AddMeasurementEntryFab } from "@/components/Measurements/widgets/fab";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
+import { makeLink, WgerLink } from "@/core/lib/url";
import React from "react";
-import { useParams } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { Navigate, useParams } from "react-router-dom";
export const MeasurementCategoryDetail = () => {
const params = useParams<{ categoryId: string }>();
@@ -30,11 +32,20 @@ export const MeasurementCategoryDetail = () => {
const planPeriods = useNutritionPlanPeriods(
correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
);
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ const [, i18n] = useTranslation();
if (categoryQuery.isLoading) {
return ;
}
+ // Body weight is presented on its own screens, which read and write it
+ // through their own query cache. Rendering it here as well would show a
+ // second view of the same rows and leave the other one stale after an edit
+ if (categoryQuery.data!.isOfficial && categoryQuery.data!.metricType === METRIC_TYPE_BODY_WEIGHT) {
+ return ;
+ }
+
return {
export const BmiCalculator = () => {
const [t] = useTranslation();
- const weightQuery = useBodyWeightQuery();
+ // Only the most recent entry is used to prefill the field; a year back is
+ // generous for that and keeps the query bounded
+ const weightQuery = useBodyWeightQuery(entryFilterFor('lastYear'));
const categoryQuery = useBodyWeightCategoryQuery();
const profileQuery = useProfileQuery();
// Entries without their own unit fall back to the one of the category
diff --git a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
index b66464ded..743f452ea 100644
--- a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
+++ b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
@@ -19,7 +19,10 @@ import { useTranslation } from "react-i18next";
*/
export const PlanWeightChart = (props: { plan: NutritionalPlan }) => {
const [t] = useTranslation();
- const weightQuery = useBodyWeightQuery('');
+ // The chart starts at the plan, so nothing before it has to be fetched.
+ // The upper end stays a client-side filter, because the plan's last day
+ // counts in full and a date bound would cut it at midnight
+ const weightQuery = useBodyWeightQuery({ "date__gte": props.plan.start.toISOString() });
const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts
index 0f29ff17e..9aa81f39e 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Weight/api/weight.test.ts
@@ -92,6 +92,51 @@ describe("weight service tests", () => {
]);
});
+ test('GET weight entries collects every page', async () => {
+
+ const page = (id: string, value: number, next: string | null) => ({
+ count: 2,
+ next: next,
+ previous: null,
+ results: [
+ {
+ id: id,
+ category: CATEGORY_UUID,
+ value: value,
+ date: '2021-12-10',
+ notes: '',
+ source: 'user',
+ extra_data: {}
+ },
+ ]
+ });
+
+ (axios.get as Mock)
+ .mockImplementationOnce(() => Promise.resolve({
+ data: page(ENTRY_UUID, 80, 'http://server/api/v2/measurement/?offset=1')
+ }))
+ .mockImplementationOnce(() => Promise.resolve({ data: page(ENTRY_UUID_2, 90, null) }));
+
+ const result = await getWeights(testBodyWeightCategory);
+
+ expect(axios.get).toHaveBeenCalledTimes(2);
+ expect(result.map(entry => entry.id)).toStrictEqual([ENTRY_UUID, ENTRY_UUID_2]);
+ });
+
+ test('GET weight entries passes the filterset on', async () => {
+
+ (axios.get as Mock).mockImplementation(() => Promise.resolve({
+ data: { count: 0, next: null, previous: null, results: [] }
+ }));
+
+ await getWeights(testBodyWeightCategory, { "date__gte": '2021-01-01T00:00:00.000Z' });
+
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining('date__gte=2021-01-01'),
+ expect.anything()
+ );
+ });
+
test('DELETE weight entry', async () => {
// Arrange
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index 314fd3b78..c71c584ef 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -1,18 +1,16 @@
import {
API_MEASUREMENTS_CATEGORY_PATH,
API_MEASUREMENTS_ENTRY_PATH,
+ getMeasurementEntries,
MeasurementCategory,
MeasurementEntry,
METRIC_TYPE_BODY_WEIGHT
} from "@/components/Measurements";
import { ResponseType } from "@/core/api/responseType";
-import { calculatePastDate } from '@/core/lib/date';
import { makeHeader, makeUrl } from "@/core/lib/url";
-import { ApiMeasurementCategoryType, ApiMeasurementEntryType } from '@/types';
+import { ApiMeasurementCategoryType } from '@/types';
import axios from 'axios';
-export type FilterType = 'lastYear' | 'lastHalfYear' | 'lastMonth' | 'lastWeek' | '';
-
/*
* Fetch the user's official body weight category
*
@@ -36,25 +34,20 @@ export const getBodyWeightCategory = async (): Promise => {
};
/*
- * Fetch weight entries based on filter value
+ * Fetch the body weight entries the filter selects, newest first
+ *
+ * Body weight is measurement data, so this reads through the measurement
+ * loader: it collects every page instead of stopping after the first, which is
+ * what a history fed by the health sync (~365 entries a year) needs.
*/
-export const getWeights = async (category: MeasurementCategory, filter: FilterType = ''): Promise => {
- const date__gte = calculatePastDate(filter);
-
- const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
- query: {
- category: category.id!,
- ordering: '-date',
- limit: 900,
- ...(date__gte && { date__gte })
- }
- });
- const { data } = await axios.get>(url, {
- headers: makeHeader(),
- });
-
- return data.results.map(entry => MeasurementEntry.fromJson(entry));
-};
+export const getWeights = async (
+ category: MeasurementCategory,
+ filtersetQueryEntries: object = {},
+): Promise => getMeasurementEntries(category.id!, {
+ // Consumers read the newest entry off the front (BMI, dashboard)
+ ordering: '-date',
+ ...filtersetQueryEntries,
+});
/*
* Delete a weight entry
diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts
index cef8b4ddf..9fd84fcaa 100644
--- a/src/components/Weight/index.ts
+++ b/src/components/Weight/index.ts
@@ -10,4 +10,3 @@ export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard";
export { WeightChart } from "./widgets/WeightChart";
export { extraDataInUnit, weightUnitOf } from "./models/bodyWeight";
export { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
-export type { FilterType } from "./api/weight";
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index c52da01bb..d87b5ff68 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -1,9 +1,8 @@
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { MeasurementEntry } from "@/components/Measurements";
import {
createWeight,
deleteWeight,
- FilterType,
getBodyWeightCategory,
getWeights,
updateWeight
@@ -38,15 +37,25 @@ export function useDisplayWeightUnit(): WeightUnit {
return profileQuery.data?.useMetric === false ? 'lb' : 'kg';
}
-export function useBodyWeightQuery(filter: FilterType = 'lastWeek') {
+/**
+ * Body weight entries, newest first.
+ *
+ * The filterset is the one the measurement queries take (`entryFilterFor` for
+ * a chart range, explicit date bounds otherwise), so a screen fetches what it
+ * shows instead of the whole history.
+ */
+export function useBodyWeightQuery(filtersetQueryEntries: object = {}) {
const queryClient = useQueryClient();
return useQuery({
- queryKey: [QueryKey.BODY_WEIGHT, filter],
+ queryKey: [QueryKey.BODY_WEIGHT, JSON.stringify(filtersetQueryEntries)],
queryFn: async () => {
const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
- return getWeights(category, filter);
+ return getWeights(category, filtersetQueryEntries);
},
+ // Widening the range refetches, and the chart would otherwise drop
+ // back to the loading placeholder while the longer history arrives
+ placeholderData: keepPreviousData,
});
}
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx
index 202ed6358..8f385a734 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Weight/screens/BodyWeight.test.tsx
@@ -1,5 +1,6 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements";
import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
@@ -47,11 +48,14 @@ describe("Test BodyWeight component", () => {
// Assert - both weights are found in the document
expect(await screen.findByText("80")).toBeInTheDocument();
expect(await screen.findByText("90")).toBeInTheDocument();
- // every entry is fetched, the range is cut client-side
- expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, '');
+ // only the entries the range shows are fetched
+ expect(getWeights).toHaveBeenCalledWith(
+ testBodyWeightCategory,
+ entryFilterFor(DEFAULT_CHART_RANGE),
+ );
});
- test('picking a chart range does not refetch, and keeps every entry listed', async () => {
+ test('picking a chart range fetches that range', async () => {
(getWeights as Mock).mockImplementation(() => Promise.resolve(weightData));
@@ -62,15 +66,14 @@ describe("Test BodyWeight component", () => {
);
expect(await screen.findByText("80")).toBeInTheDocument();
- const fetches = (getWeights as Mock).mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: 'measurements.chartRangeAll' }));
- // the range only decides how far back the chart goes: the entries are
- // already there, and the table lists them whatever the range is
+ // the full history has no lower bound, so the filterset is empty
await waitFor(() => {
- expect(screen.getByText("80")).toBeInTheDocument();
+ expect(getWeights).toHaveBeenLastCalledWith(testBodyWeightCategory, {});
});
- expect((getWeights as Mock).mock.calls.length).toBe(fetches);
+ // the entries stay on screen while the wider range is loading
+ expect(screen.getByText("80")).toBeInTheDocument();
});
});
diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx
index 7517a2a91..4a6135e67 100644
--- a/src/components/Weight/screens/BodyWeight.tsx
+++ b/src/components/Weight/screens/BodyWeight.tsx
@@ -1,5 +1,10 @@
import { Box, Stack } from "@mui/material";
-import { ChartRange, ChartRangeSelector, DEFAULT_CHART_RANGE } from "@/components/Measurements";
+import {
+ ChartRange,
+ ChartRangeSelector,
+ DEFAULT_CHART_RANGE,
+ entryFilterFor
+} from "@/components/Measurements";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import {
useBodyWeightCategoryQuery,
@@ -19,9 +24,11 @@ import { useTranslation } from "react-i18next";
export const BodyWeight = () => {
const [t] = useTranslation();
const [range, setRange] = useState(DEFAULT_CHART_RANGE);
- // The range is cut client-side, so the average can be computed over the
- // full history before it is applied; the table lists every entry
- const weightyQuery = useBodyWeightQuery('');
+ // 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
+ const weightyQuery = useBodyWeightQuery(entryFilterFor(range));
const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
const planPeriods = useNutritionPlanPeriods();
diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts
index 5b84d7f7f..0a711e866 100644
--- a/src/core/lib/date.test.ts
+++ b/src/core/lib/date.test.ts
@@ -1,4 +1,4 @@
-import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date";
+import { dateTimeToHHMM, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date";
/*
* All date helpers must behave the same in every timezone, so the whole suite
@@ -84,32 +84,4 @@ describe.each([
});
});
-
-
- describe('calculatePastDate', () => {
-
- it('should return undefined for empty string filter', () => {
- expect(calculatePastDate('', yyyymmddToDate('2023-08-14'))).toBeUndefined();
- });
-
- it('should return the correct date for lastWeek filter', () => {
- const result = calculatePastDate('lastWeek', yyyymmddToDate('2023-02-14'));
- expect(result).toStrictEqual('2023-02-07');
- });
-
- it('should return the correct date for lastMonth filter', () => {
- const result = calculatePastDate('lastMonth', yyyymmddToDate('2023-02-14'));
- expect(result).toStrictEqual('2023-01-14');
- });
-
- it('should return the correct date for lastHalfYear filter', () => {
- const result = calculatePastDate('lastHalfYear', yyyymmddToDate('2023-08-14'));
- expect(result).toStrictEqual('2023-02-14');
- });
-
- it('should return the correct date for lastYear filter', () => {
- const result = calculatePastDate('lastYear', yyyymmddToDate('2023-02-14'));
- expect(result).toStrictEqual('2022-02-14');
- });
- });
});
diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts
index 331dd0d09..f41ea2ad9 100644
--- a/src/core/lib/date.ts
+++ b/src/core/lib/date.ts
@@ -1,4 +1,3 @@
-import { FilterType } from "@/components/Weight/api/weight";
import i18n from 'i18next';
import { DateTime, DateTimeFormatOptions } from "luxon";
@@ -134,34 +133,3 @@ export function HHMMToDateTime(time: string | null) {
return dateTime;
}
-
-/*
- * Util function that calculates a date in the past based on a string filter
- * and returns it as a YYYY-MM-DD string for API queries.
- *
- * @param filter - A string representing the desired time period (e.g., 'lastWeek', 'lastMonth')
- * @param currentDate - (Optional) The current date to base calculations on. Defaults to `new Date()`.
- * This parameter allows for testing or custom date bases.
- * @returns - Date string in the format YYYY-MM-DD or undefined for no filtering
- */
-export function calculatePastDate(filter: FilterType, currentDate: Date = new Date()): string | undefined {
-
- // Dictionary for filters
- const filterMap: Record void) | undefined> = {
- lastWeek: () => currentDate.setDate(currentDate.getDate() - 7),
- lastMonth: () => currentDate.setMonth(currentDate.getMonth() - 1),
- lastHalfYear: () => currentDate.setMonth(currentDate.getMonth() - 6),
- lastYear: () => currentDate.setFullYear(currentDate.getFullYear() - 1),
- '': undefined
- };
-
- // Execute the corresponding function for the filter
- const applyFilter = filterMap[filter];
- if (applyFilter) {
- applyFilter();
- } else {
- return undefined;
- }
-
- return dateToYYYYMMDD(currentDate);
-}
\ No newline at end of file
From e99c2e4b19847f131843bb37aeb4d51ea0057b8b Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 19:24:15 +0200
Subject: [PATCH 34/71] Put body weight on the measurement API and cache layer
---
src/components/Measurements/index.ts | 7 +-
src/components/Measurements/queries/index.ts | 14 +++-
.../widgets/CategoryDetailDataGrid.test.tsx | 4 +-
.../widgets/CategoryDetailDataGrid.tsx | 4 +-
src/components/Weight/api/weight.test.ts | 73 +------------------
src/components/Weight/api/weight.ts | 38 +---------
.../Weight/forms/WeightForm.test.tsx | 19 ++---
src/components/Weight/forms/WeightForm.tsx | 19 ++---
src/components/Weight/queries/index.ts | 65 +++++------------
.../Weight/widgets/Table/index.test.tsx | 14 ++--
src/components/Weight/widgets/Table/index.tsx | 13 +++-
src/core/lib/consts.ts | 7 +-
12 files changed, 78 insertions(+), 199 deletions(-)
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 7a41c401a..b42fcadbf 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -24,7 +24,12 @@ export {
} from "./api/measurements";
// Query hooks
-export { useMeasurementsCategoryQuery } from "./queries";
+export {
+ useAddMeasurementEntryQuery,
+ useDeleteMeasurementEntryQuery,
+ useEditMeasurementEntryQuery,
+ useMeasurementsCategoryQuery
+} from "./queries";
// Charts
export { componentColor, componentPalette } from "./charts/colors";
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index f4ffda31a..d2ab4b521 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -141,13 +141,19 @@ export const useEditMeasurementEntryQuery = () => {
});
};
-export const useDeleteMeasurementsQuery = (/*id: number*/) => {
+export const useDeleteMeasurementEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteMeasurementEntry(id),
- onSuccess: () => queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS,]
- })
+ onSuccess: () => {
+ queryClient.invalidateQueries({
+ queryKey: [QueryKey.MEASUREMENTS,]
+ });
+ // The category lists carry the entries as well, like on add and edit
+ queryClient.invalidateQueries({
+ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
+ });
+ }
});
};
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 2f701a35f..1a62ed529 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -2,7 +2,7 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from '@testing-library/react';
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
+import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
@@ -18,7 +18,7 @@ describe('CategoryDetailDataGrid', () => {
beforeEach(() => {
(useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- (useDeleteMeasurementsQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
});
test('entries synced from a health app offer no edit or delete actions', async () => {
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index f6f14e8c7..cbcdd6268 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -2,7 +2,7 @@ import { processTimeSeries } from "@/core/lib/timeSeries";
import { valueWithUnit } from "@/components/Measurements/charts/format";
import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
+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";
@@ -46,7 +46,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
const [t, i18n] = useTranslation();
const data: GridRowsProp = convertEntriesToObj(props.category.entries);
const updateEntryQuery = useEditMeasurementEntryQuery();
- const deleteEntryQuery = useDeleteMeasurementsQuery();
+ const deleteEntryQuery = useDeleteMeasurementEntryQuery();
const [rowModesModel, setRowModesModel] = useState({});
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts
index 9aa81f39e..6c1fb38c2 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Weight/api/weight.test.ts
@@ -1,6 +1,6 @@
import axios from "axios";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
-import { createWeight, deleteWeight, getBodyWeightCategory, getWeights, updateWeight } from "./weight";
+import { getBodyWeightCategory, getWeights } from "./weight";
import type { Mock } from 'vitest';
vi.mock("axios");
@@ -137,75 +137,4 @@ describe("weight service tests", () => {
);
});
- test('DELETE weight entry', async () => {
-
- // Arrange
- (axios.delete as Mock).mockImplementation(() => Promise.resolve({ status: 204 }));
-
- // Act
- const result = await deleteWeight(ENTRY_UUID);
-
- // Assert
- expect(axios.delete).toHaveBeenCalledWith(
- expect.stringContaining(`measurement/${ENTRY_UUID}`),
- expect.anything()
- );
- expect(result).toEqual(204);
- });
-
- test('PATCH weight entry', async () => {
-
- // Arrange
- const weightEntry = makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'kg' });
- const weightResponse = {
- data: {
- id: ENTRY_UUID,
- category: CATEGORY_UUID,
- value: 80,
- date: '2021-12-10',
- notes: '',
- extra_data: { unit: 'kg' }
- }
- };
-
- // Act
- (axios.patch as Mock).mockImplementation(() => Promise.resolve(weightResponse));
- const result = await updateWeight(weightEntry);
-
- // Assert
- expect(axios.patch).toHaveBeenCalledTimes(1);
- const [url, body] = (axios.patch as Mock).mock.calls[0];
- expect(url).toContain(`measurement/${ENTRY_UUID}`);
- expect(body).toMatchObject({ value: 80, extra_data: { unit: 'kg' } });
- expect(result).toStrictEqual(makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'kg' }));
- });
-
- test('POST a new weight entry', async () => {
-
- // Arrange
- const weightEntry = makeWeightEntry(new Date('2021-12-10'), 80, { unit: 'lb' });
- const weightResponse = {
- data: {
- id: ENTRY_UUID,
- category: CATEGORY_UUID,
- value: 80,
- date: '2021-12-10',
- notes: '',
- extra_data: { unit: 'lb' }
- }
- };
-
- // Act
- (axios.post as Mock).mockImplementation(() => Promise.resolve(weightResponse));
- const result = await createWeight(weightEntry);
-
- // Assert
- expect(axios.post).toHaveBeenCalledTimes(1);
- const [, body] = (axios.post as Mock).mock.calls[0];
- expect(body).toMatchObject({ category: CATEGORY_UUID, value: 80, extra_data: { unit: 'lb' } });
- expect(result).toStrictEqual(
- makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, unit: 'lb' })
- );
- });
-
});
diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts
index c71c584ef..65924c2a7 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Weight/api/weight.ts
@@ -1,6 +1,5 @@
import {
API_MEASUREMENTS_CATEGORY_PATH,
- API_MEASUREMENTS_ENTRY_PATH,
getMeasurementEntries,
MeasurementCategory,
MeasurementEntry,
@@ -49,37 +48,6 @@ export const getWeights = async (
...filtersetQueryEntries,
});
-/*
- * Delete a weight entry
- */
-export const deleteWeight = async (id: string): Promise => {
- const response = await axios.delete(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: id }), {
- headers: makeHeader(),
- });
-
- return response.status;
-};
-
-/*
- * Update a weight entry
- */
-export const updateWeight = async (entry: MeasurementEntry): Promise => {
- const response = await axios.patch(makeUrl(API_MEASUREMENTS_ENTRY_PATH, { id: entry.id! }), entry.toJson(), {
- headers: makeHeader(),
- });
-
- return MeasurementEntry.fromJson(response.data);
-};
-
-/*
- * Add a new weight entry to the official body weight category
- */
-export const createWeight = async (entry: MeasurementEntry): Promise => {
- const response = await axios.post(
- makeUrl(API_MEASUREMENTS_ENTRY_PATH),
- entry.toJson(),
- { headers: makeHeader() },
- );
-
- return MeasurementEntry.fromJson(response.data);
-};
+// Writing a body weight entry is writing a measurement entry: the create,
+// update and delete calls of `api/measurements.ts` are used unchanged, there
+// is nothing body-weight-specific about them.
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx
index 79370ced1..2fda5ef74 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Weight/forms/WeightForm.test.tsx
@@ -4,18 +4,15 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
import userEvent from "@testing-library/user-event";
import { useProfileQuery } from "@/components/User";
import { WeightForm } from "@/components/Weight/forms/WeightForm";
-import {
- useAddWeightEntryQuery,
- useBodyWeightCategoryQuery,
- useDisplayWeightUnit,
- useEditWeightEntryQuery
-} from "@/components/Weight/queries";
+import { useAddMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
vi.mock("@/components/Weight/queries");
+vi.mock("@/components/Measurements/queries");
vi.mock("@/components/User/queries/profile");
const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001';
@@ -28,8 +25,8 @@ describe("Test WeightForm component", () => {
isLoading: false,
data: testBodyWeightCategory
}));
- (useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useAddMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
(useDisplayWeightUnit as Mock).mockReturnValue('kg');
(useProfileQuery as Mock).mockImplementation(() => ({ isLoading: false }));
});
@@ -76,7 +73,7 @@ describe("Test WeightForm component", () => {
// Arrange
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
const weightEntry = makeWeightEntry(new Date('2022-02-28'), 80, { id: ENTRY_UUID });
// Act
@@ -129,7 +126,7 @@ describe("Test WeightForm component", () => {
expect(submitButton).toBeInTheDocument();
await user.click(submitButton);
await waitFor(() => {
- expect(useAddWeightEntryQuery).toHaveBeenCalled();
+ expect(useAddMeasurementEntryQuery).toHaveBeenCalled();
});
});
@@ -161,7 +158,7 @@ describe("Test WeightForm component", () => {
// Arrange
const user = userEvent.setup();
const mutateAddMock = vi.fn();
- (useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateAddMock }));
+ (useAddMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateAddMock }));
render(
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index b361f7e6a..11579c219 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -1,14 +1,15 @@
import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
-import { limitsFor, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
-import { extraDataInUnit, weightUnitOf } from "@/components/Weight/models/bodyWeight";
import {
- useAddWeightEntryQuery,
- useBodyWeightCategoryQuery,
- useDisplayWeightUnit,
- useEditWeightEntryQuery
-} from "@/components/Weight/queries";
+ limitsFor,
+ MeasurementEntry,
+ METRIC_TYPE_BODY_WEIGHT,
+ useAddMeasurementEntryQuery,
+ useEditMeasurementEntryQuery
+} from "@/components/Measurements";
+import { extraDataInUnit, weightUnitOf } from "@/components/Weight/models/bodyWeight";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
import { useProfileQuery } from "@/components/User";
import { WeightUnit } from "@/core/lib/weightUnit";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
@@ -27,8 +28,8 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
const categoryQuery = useBodyWeightCategoryQuery();
const profileQuery = useProfileQuery();
- const addWeightQuery = useAddWeightEntryQuery();
- const editWeightQuery = useEditWeightEntryQuery();
+ const addWeightQuery = useAddMeasurementEntryQuery();
+ const editWeightQuery = useEditMeasurementEntryQuery();
const displayUnit = useDisplayWeightUnit();
const [dateValue, setDateValue] = useState(weightEntry ? DateTime.fromJSDate(weightEntry.date) : DateTime.now);
diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts
index d87b5ff68..acc192eb2 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Weight/queries/index.ts
@@ -1,22 +1,26 @@
-import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { MeasurementEntry } from "@/components/Measurements";
-import {
- createWeight,
- deleteWeight,
- getBodyWeightCategory,
- getWeights,
- updateWeight
-} from "@/components/Weight/api/weight";
+import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
import { useProfileQuery } from "@/components/User";
import { QueryKey, } from "@/core/lib/consts";
import { WeightUnit } from "@/core/lib/weightUnit";
+/**
+ * Cache key of the official body weight category, standing in for its id.
+ *
+ * Body weight rows are measurement rows, so the queries below live under the
+ * measurement keys: an entry written through the measurement mutations
+ * invalidates the weight views and the other way round. The id itself cannot
+ * be the key, because it is only known once the category query resolved, and a
+ * query key has to exist before that.
+ */
+const OFFICIAL_BODY_WEIGHT = 'official-body-weight';
+
/*
* The official body weight category basically never changes, resolve it once
* per session (ensureQueryData returns the cached result on later calls)
*/
const bodyWeightCategoryQueryOptions = {
- queryKey: [QueryKey.BODY_WEIGHT_CATEGORY],
+ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, OFFICIAL_BODY_WEIGHT],
// Called through, not captured: this module sits in an import cycle
// between the weight, measurement and nutrition domains, where a binding
// read while the modules initialise can still be undefined
@@ -43,12 +47,15 @@ export function useDisplayWeightUnit(): WeightUnit {
* The filterset is the one the measurement queries take (`entryFilterFor` for
* a chart range, explicit date bounds otherwise), so a screen fetches what it
* shows instead of the whole history.
+ *
+ * Writes go through the measurement entry mutations, which invalidate this key
+ * along with every other view of the same rows.
*/
export function useBodyWeightQuery(filtersetQueryEntries: object = {}) {
const queryClient = useQueryClient();
return useQuery({
- queryKey: [QueryKey.BODY_WEIGHT, JSON.stringify(filtersetQueryEntries)],
+ queryKey: [QueryKey.MEASUREMENTS, OFFICIAL_BODY_WEIGHT, JSON.stringify(filtersetQueryEntries)],
queryFn: async () => {
const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
return getWeights(category, filtersetQueryEntries);
@@ -58,39 +65,3 @@ export function useBodyWeightQuery(filtersetQueryEntries: object = {}) {
placeholderData: keepPreviousData,
});
}
-
-export const useDeleteWeightEntryQuery = () => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (id: string) => deleteWeight(id),
- onSuccess: () => queryClient.invalidateQueries({
- queryKey: [QueryKey.BODY_WEIGHT]
- })
- });
-};
-
-
-export const useAddWeightEntryQuery = () => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (weightEntry: MeasurementEntry) => createWeight(weightEntry),
- onSuccess: () => queryClient.invalidateQueries({
- queryKey: [QueryKey.BODY_WEIGHT,]
- })
- });
-};
-
-export const useEditWeightEntryQuery = () => {
- const queryClient = useQueryClient();
-
- return useMutation({
- mutationFn: (data: MeasurementEntry) => updateWeight(data),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [QueryKey.BODY_WEIGHT,]
- });
- }
- });
-};
diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx
index f28f088c3..805138614 100644
--- a/src/components/Weight/widgets/Table/index.test.tsx
+++ b/src/components/Weight/widgets/Table/index.test.tsx
@@ -3,13 +3,13 @@ import { makeWeightEntry } from "@/tests/weight/testData";
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
-import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
+import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { BrowserRouter } from "react-router-dom";
import { testQueryClient } from "@/tests/queryClient";
import type { Mock } from 'vitest';
import { WeightTable } from './index';
-vi.mock("@/components/Weight/queries");
+vi.mock("@/components/Measurements/queries");
const renderTable = (weights: MeasurementEntry[]) =>
render(
@@ -27,8 +27,8 @@ const ENTRY_UUID_3 = 'dddddddd-dddd-dddd-dddd-000000000003';
describe("Body weight table", () => {
beforeEach(() => {
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- (useDeleteWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
});
test('renders rows for all weight entries', async () => {
@@ -97,7 +97,7 @@ describe("Body weight table", () => {
test('saving a row without editing the weight keeps the stored value and unit', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
// stored as 90 lb, displayed as 40.82 kg
const weights: MeasurementEntry[] = [
makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
@@ -118,7 +118,7 @@ describe("Body weight table", () => {
test('editing the weight cell stamps the display unit', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
const weights: MeasurementEntry[] = [
makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
];
@@ -143,7 +143,7 @@ describe("Body weight table", () => {
test('implausible inline edits are rejected and the row stays editable', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
const weights: MeasurementEntry[] = [
makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
];
diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx
index 819f6d481..f1e983b8d 100644
--- a/src/components/Weight/widgets/Table/index.tsx
+++ b/src/components/Weight/widgets/Table/index.tsx
@@ -16,10 +16,15 @@ import {
GridRowModesModel,
GridRowsProp,
} from "@mui/x-data-grid";
-import { limitsFor, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements";
+import {
+ limitsFor,
+ MeasurementEntry,
+ METRIC_TYPE_BODY_WEIGHT,
+ useDeleteMeasurementEntryQuery,
+ useEditMeasurementEntryQuery
+} from "@/components/Measurements";
import { extraDataInUnit } from "@/components/Weight/models/bodyWeight";
import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab";
-import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries";
import { processTimeSeries } from "@/core/lib/timeSeries";
import { WeightUnit } from "@/core/lib/weightUnit";
import { DateTime } from "luxon";
@@ -48,8 +53,8 @@ const buildRows = (weights: MeasurementEntry[], unit: WeightUnit, categoryUnit:
export const WeightTable = ({ weights, unit, categoryUnit }: WeightTableProps) => {
const [t] = useTranslation();
const rows = buildRows(weights, unit, categoryUnit);
- const editEntryQuery = useEditWeightEntryQuery();
- const deleteEntryQuery = useDeleteWeightEntryQuery();
+ const editEntryQuery = useEditMeasurementEntryQuery();
+ const deleteEntryQuery = useDeleteMeasurementEntryQuery();
const [rowModesModel, setRowModesModel] = useState({});
const [editError, setEditError] = useState(null);
diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts
index dc3a914c0..b813014d1 100644
--- a/src/core/lib/consts.ts
+++ b/src/core/lib/consts.ts
@@ -44,10 +44,6 @@ export enum QueryKey {
NUTRITIONAL_PLAN_LAST = 'nutritional-plan-last',
INGREDIENT = 'ingredient',
- // Body weight
- BODY_WEIGHT = 'body-weight',
- BODY_WEIGHT_CATEGORY = 'body-weight-category',
-
// Profile
PROFILE = 'profile',
PERMISSION = 'permission',
@@ -62,7 +58,8 @@ export enum QueryKey {
EQUIPMENT = 'equipment',
MUSCLES = 'muscles',
- // Measurements
+ // Measurements (body weight is measurement data and shares these, see
+ // the body weight queries)
MEASUREMENTS = 'measurements',
MEASUREMENTS_CATEGORIES = 'measurements-categories',
From ca5d80c5aeb1544eedb189ab09c25dbd01e8ad8e Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 19:49:42 +0200
Subject: [PATCH 35/71] Show body weight in the measurement data grid
---
src/components/Measurements/index.ts | 1 +
src/components/Measurements/models/Entry.ts | 10 +
.../widgets/CategoryDetailDataGrid.test.tsx | 108 +++++++
.../widgets/CategoryDetailDataGrid.tsx | 95 +++++--
src/components/Weight/forms/WeightForm.tsx | 4 +-
src/components/Weight/index.ts | 2 +-
src/components/Weight/models/bodyWeight.ts | 16 +-
src/components/Weight/queries/index.test.tsx | 36 +++
.../Weight/screens/BodyWeight.test.tsx | 11 +-
src/components/Weight/screens/BodyWeight.tsx | 12 +-
.../Weight/widgets/Table/Fab/Fab.tsx | 35 ---
.../Weight/widgets/Table/index.test.tsx | 186 -------------
src/components/Weight/widgets/Table/index.tsx | 263 ------------------
.../Weight/widgets/Table/table.module.css | 3 -
.../Weight/widgets/Table/table.module.css.map | 1 -
.../Weight/widgets/Table/table.module.scss | 0
.../Weight/widgets/Table/table.mosule.css | 2 -
.../Weight/widgets/Table/table.mosule.css.map | 1 -
18 files changed, 242 insertions(+), 544 deletions(-)
create mode 100644 src/components/Weight/queries/index.test.tsx
delete mode 100644 src/components/Weight/widgets/Table/Fab/Fab.tsx
delete mode 100644 src/components/Weight/widgets/Table/index.test.tsx
delete mode 100644 src/components/Weight/widgets/Table/index.tsx
delete mode 100644 src/components/Weight/widgets/Table/table.module.css
delete mode 100644 src/components/Weight/widgets/Table/table.module.css.map
delete mode 100644 src/components/Weight/widgets/Table/table.module.scss
delete mode 100644 src/components/Weight/widgets/Table/table.mosule.css
delete mode 100644 src/components/Weight/widgets/Table/table.mosule.css.map
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index b42fcadbf..280286524 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -40,6 +40,7 @@ export type { ChartRange } from "./charts/range";
export type { PlanPeriod } from "./charts/series";
// Widgets
+export { CategoryDetailDataGrid } from "./widgets/CategoryDetailDataGrid";
export { CategoryForm } from "./widgets/CategoryForm";
export { ChartRangeSelector } from "./widgets/ChartRangeSelector";
export { MeasurementChart } from "./widgets/MeasurementChart";
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index a53493756..b2f9ef705 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -37,6 +37,16 @@ export class MeasurementEntry {
return this.convert(this.value, targetUnit, categoryUnit);
}
+ /**
+ * The entry's extra_data with the unit its value is in.
+ *
+ * The server replaces extra_data as a whole on update, so the keys we do
+ * not know about have to travel back with it.
+ */
+ extraDataInUnit(unit: string): Record {
+ return { ...this.extraData, unit: unit };
+ }
+
/**
* A number stored in extra_data next to the value, such as the bounds of a
* daily aggregate. They are written in the value's unit, so they have to
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 1a62ed529..4c1609285 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -1,11 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from '@testing-library/react';
+import userEvent from "@testing-library/user-event";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
+import { makeWeightEntry, testBodyWeightCategory } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/queries");
@@ -49,4 +51,110 @@ describe('CategoryDetailDataGrid', () => {
expect(within(syncedRow).queryByRole('menuitem', { name: /delete/i })).not.toBeInTheDocument();
expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument();
});
+
+ /*
+ * Body weight is the one category whose entries can be stored in a unit
+ * other than the one they are shown in
+ */
+ describe('with a display unit', () => {
+
+ const ENTRY_UUID_1 = 'dddddddd-dddd-dddd-dddd-000000000011';
+ const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000012';
+
+ const renderGrid = (entries: MeasurementEntry[]) => render(
+
+
+
+ );
+
+ // the grid formats numbers with the runner's locale, normalize the
+ // decimal separator
+ const cellText = (row: HTMLElement, field: string) =>
+ row.querySelector(`[data-field="${field}"]`)!.textContent!.replace(',', '.');
+
+ test('converts mixed units to the display unit, including the aggregations', async () => {
+ // 90 lb = 40.82 kg, entered a day after the 80 kg entry
+ renderGrid([
+ makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
+ makeWeightEntry(new Date('2021/12/11'), 90, { id: ENTRY_UUID_2, unit: 'lb' }),
+ ]);
+ await screen.findByText('80 kg');
+
+ const lbRow = document.querySelector(`[data-id="${ENTRY_UUID_2}"]`) as HTMLElement;
+ expect(cellText(lbRow, 'value')).toBe('40.82 kg');
+ // change and totalChange are computed on the converted values
+ expect(cellText(lbRow, 'totalChange')).toBe('-39.18');
+ });
+
+ test('saving a row without editing the value keeps the stored value and unit', async () => {
+ const user = userEvent.setup();
+ const mutateEditMock = vi.fn();
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ // stored as 90 lb, displayed as 40.82 kg
+ renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]);
+
+ await screen.findByText(/40[.,]82/);
+ await user.click(screen.getByRole('menuitem', { name: /edit/i }));
+ await user.click(screen.getByRole('menuitem', { name: /save/i }));
+
+ // the displayed conversion must not be written back to the entry
+ expect(mutateEditMock).toHaveBeenCalled();
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(90);
+ expect(submitted.extraData.unit).toBe('lb');
+ });
+
+ test('editing the value cell stamps the display unit', async () => {
+ const user = userEvent.setup();
+ const mutateEditMock = vi.fn();
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]);
+
+ await screen.findByText(/40[.,]82/);
+ await user.click(screen.getByRole('menuitem', { name: /edit/i }));
+
+ // the typed value is in the unit the grid shows, not the one the
+ // entry was stored in
+ const valueInput = screen.getByRole('spinbutton');
+ await user.clear(valueInput);
+ await user.type(valueInput, '41');
+ await user.click(screen.getByRole('menuitem', { name: /save/i }));
+
+ expect(mutateEditMock).toHaveBeenCalled();
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(41);
+ expect(submitted.extraData.unit).toBe('kg');
+ });
+
+ test('implausible inline edits are rejected and the row stays editable', async () => {
+ const user = userEvent.setup();
+ const mutateEditMock = vi.fn();
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ renderGrid([makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' })]);
+
+ await screen.findByText('80 kg');
+ await user.click(screen.getByRole('menuitem', { name: /edit/i }));
+
+ const valueInput = screen.getByRole('spinbutton');
+ await user.clear(valueInput);
+ await user.type(valueInput, '5000');
+ await user.click(screen.getByRole('menuitem', { name: /save/i }));
+
+ // nothing is saved, the error shows up and the cell stays editable
+ expect(mutateEditMock).not.toHaveBeenCalled();
+ expect(await screen.findByText('forms.maxValue')).toBeInTheDocument();
+ expect(screen.getByRole('spinbutton')).toBeInTheDocument();
+
+ // correcting the value saves normally
+ await user.clear(valueInput);
+ await user.type(valueInput, '90');
+ await user.click(screen.getByRole('menuitem', { name: /save/i }));
+ expect(mutateEditMock).toHaveBeenCalled();
+ const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
+ expect(Number(submitted.value)).toBe(90);
+ });
+ });
});
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index cbcdd6268..6aef19b04 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -10,13 +10,12 @@ import CloudSyncIcon from "@mui/icons-material/CloudSync";
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
import EditIcon from "@mui/icons-material/Edit";
import SaveIcon from "@mui/icons-material/Save";
-import { Box, Tooltip } from "@mui/material";
+import { Box, Snackbar, Tooltip } from "@mui/material";
import {
DataGrid,
GridActionsCellItem,
GridColDef,
GridEventListener,
- GridPreProcessEditCellProps,
GridRowEditStopReasons,
GridRowId,
GridRowModel,
@@ -28,11 +27,14 @@ import { DateTime } from "luxon";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
-const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp =>
- processTimeSeries(entries, e => e.value).map((row) => ({
+// Values are read through the unit helper, never off the raw column: a
+// category can hold entries in mixed units, and the change columns are
+// computed from the converted values
+const buildRows = (entries: MeasurementEntry[], unit: string, categoryUnit: string): GridRowsProp =>
+ processTimeSeries(entries, e => e.valueIn(unit, categoryUnit)).map((row) => ({
id: row.entry.id,
date: row.entry.date,
- value: row.entry.value,
+ value: row.entry.valueIn(unit, categoryUnit),
notes: row.entry.notes,
isEditable: row.entry.isEditable,
change: +row.change.toFixed(2),
@@ -41,13 +43,26 @@ const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp =>
}));
-export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) => {
+export const CategoryDetailDataGrid = (props: {
+ category: MeasurementCategory,
+ /** Rows to show, the category's own entries by default */
+ entries?: MeasurementEntry[],
+ /**
+ * Unit the values are shown and edited in, the category's own by default.
+ * Body weight is shown in the profile unit, since its entries can be
+ * stored in either; an edited value is then stamped with it.
+ */
+ displayUnit?: string,
+}) => {
const [t, i18n] = useTranslation();
- const data: GridRowsProp = convertEntriesToObj(props.category.entries);
+ const entries = props.entries ?? props.category.entries;
+ const unit = props.displayUnit ?? props.category.unit;
+ const data: GridRowsProp = buildRows(entries, unit, props.category.unit);
const updateEntryQuery = useEditMeasurementEntryQuery();
const deleteEntryQuery = useDeleteMeasurementEntryQuery();
const [rowModesModel, setRowModesModel] = useState({});
+ const [editError, setEditError] = useState(null);
const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => {
@@ -76,24 +91,52 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
};
- const processRowUpdate = async (newRow: GridRowModel) => {
+ const processRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => {
const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date);
- const entry = props.category.entries.find(e => e.id === newRow.id);
+ const entry = entries.find(e => e.id === newRow.id);
if (entry === undefined) {
throw new Error(`unknown entry id ${newRow.id}`);
}
+
+ // The grid shows the value converted into the display unit. Re-saving
+ // that conversion would silently overwrite the entry's stored value and
+ // unit, so both only change when the value cell was edited
+ if (Number(newRow.value) === Number(oldRow.value)) {
+ updateEntryQuery.mutate(MeasurementEntry.clone(entry, {
+ date: date,
+ notes: newRow.notes,
+ }));
+
+ return { ...newRow, isNew: false };
+ }
+
+ // A value outside the bounds of the metric type is refused by the API,
+ // so the row is not saved with one either; throwing keeps it in edit
+ // mode so it can be corrected
+ const value = Number(newRow.value);
+ const { min, max } = limitsFor(props.category.metricType, unit);
+ if (isNaN(value) || value < min) {
+ throw new Error(t('forms.minValue', { value: `${min} ${unit}` }));
+ }
+ if (value > max) {
+ throw new Error(t('forms.maxValue', { value: `${max} ${unit}` }));
+ }
+
updateEntryQuery.mutate(MeasurementEntry.clone(entry, {
date: date,
- value: newRow.value,
+ value: value,
notes: newRow.notes,
+ // The typed value is in the unit the grid shows, which for body
+ // weight is not necessarily the one it was stored in
+ ...(props.displayUnit ? { extraData: entry.extraDataInUnit(unit) } : {}),
}));
return { ...newRow, isNew: false };
};
const onProcessRowUpdateError = (error: unknown) => {
- console.error(error);
+ setEditError(error instanceof Error ? error.message : String(error));
};
const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
@@ -104,20 +147,13 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
{
field: 'value',
headerName: t('value'),
+ type: 'number',
// wide enough for a grouped number plus its unit
width: 120,
editable: true,
valueFormatter: (value?: number) => value == null
? ''
- : valueWithUnit(value, props.category.unit, i18n.language),
- // A value outside the bounds of the metric type is refused by the
- // API, so the row cannot be saved with one either
- preProcessEditCellProps: (params: GridPreProcessEditCellProps) => {
- const value = Number(params.props.value);
- const { min, max } = limitsFor(props.category.metricType, props.category.unit);
-
- return { ...params.props, error: isNaN(value) || value < min || value > max };
- },
+ : valueWithUnit(value, unit, i18n.language),
},
{
field: 'date',
@@ -191,13 +227,13 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
}
- label="Save"
+ label={t('save')}
onClick={handleSaveClick(id)}
/>,
}
- label="Cancel"
+ label={t('cancel')}
className="textPrimary"
onClick={handleCancelClick(id)}
color="inherit"
@@ -209,7 +245,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
}
- label="Edit"
+ label={t('edit')}
className="textPrimary"
onClick={handleEditClick(id)}
color="inherit"
@@ -217,7 +253,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
}
- label="Delete"
+ label={t('delete')}
onClick={handleDeleteClick(id)}
color="inherit"
/>,
@@ -227,7 +263,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory })
];
- return
+ return <>
- ;
+
+ setEditError(null)}
+ message={editError}
+ />
+ >;
};
\ No newline at end of file
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx
index 11579c219..79c3fd783 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Weight/forms/WeightForm.tsx
@@ -8,7 +8,7 @@ import {
useAddMeasurementEntryQuery,
useEditMeasurementEntryQuery
} from "@/components/Measurements";
-import { extraDataInUnit, weightUnitOf } from "@/components/Weight/models/bodyWeight";
+import { weightUnitOf } from "@/components/Weight/models/bodyWeight";
import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
import { useProfileQuery } from "@/components/User";
import { WeightUnit } from "@/core/lib/weightUnit";
@@ -77,7 +77,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
editWeightQuery.mutate(MeasurementEntry.clone(weightEntry, {
value: values.weight,
date: values.date,
- extraData: extraDataInUnit(weightEntry, values.unit),
+ extraData: weightEntry.extraDataInUnit(values.unit),
}));
// Create a new weight entry
diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts
index 9fd84fcaa..38ed16afc 100644
--- a/src/components/Weight/index.ts
+++ b/src/components/Weight/index.ts
@@ -8,5 +8,5 @@ export { BodyWeight } from "./screens/BodyWeight";
export { WeightForm } from "./forms/WeightForm";
export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard";
export { WeightChart } from "./widgets/WeightChart";
-export { extraDataInUnit, weightUnitOf } from "./models/bodyWeight";
+export { weightUnitOf } from "./models/bodyWeight";
export { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
diff --git a/src/components/Weight/models/bodyWeight.ts b/src/components/Weight/models/bodyWeight.ts
index 046be7a30..d75066151 100644
--- a/src/components/Weight/models/bodyWeight.ts
+++ b/src/components/Weight/models/bodyWeight.ts
@@ -3,9 +3,8 @@ import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
/**
* Body weight is stored as a measurement in the user's official body weight
- * category, so an entry is a plain MeasurementEntry. These two helpers hold
- * what is specific to it: its value is in one of the two units the app knows,
- * and that unit travels in extra_data.
+ * category, so an entry is a plain MeasurementEntry. What is specific to it is
+ * that its value is in one of the two units the app can convert between.
*/
/** The unit an entry's value is stored in, narrowed to what we can convert */
@@ -14,14 +13,3 @@ export const weightUnitOf = (entry: MeasurementEntry, categoryUnit: string): Wei
return isWeightUnit(stored) ? stored : 'kg';
};
-
-/**
- * The entry's extra_data with the unit its value is in.
- *
- * The server replaces extra_data as a whole on update, so the keys we do not
- * know about have to be sent back along with it.
- */
-export const extraDataInUnit = (
- entry: MeasurementEntry,
- unit: WeightUnit,
-): Record => ({ ...entry.extraData, unit: unit });
diff --git a/src/components/Weight/queries/index.test.tsx b/src/components/Weight/queries/index.test.tsx
new file mode 100644
index 000000000..0c99bd54c
--- /dev/null
+++ b/src/components/Weight/queries/index.test.tsx
@@ -0,0 +1,36 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { renderHook, waitFor } from '@testing-library/react';
+import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
+import { useBodyWeightQuery } from "@/components/Weight/queries";
+import { QueryKey } from "@/core/lib/consts";
+import { testBodyWeightCategory } from "@/tests/weight/testData";
+import React from "react";
+import type { Mock } from 'vitest';
+
+vi.mock("@/components/Weight/api/weight");
+
+describe("body weight queries", () => {
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ (getBodyWeightCategory as Mock).mockResolvedValue(testBodyWeightCategory);
+ (getWeights as Mock).mockResolvedValue([]);
+ });
+
+ test('an entry written anywhere invalidates the body weight view', async () => {
+
+ 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));
+ 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] });
+
+ await waitFor(() => expect(getWeights).toHaveBeenCalledTimes(2));
+ });
+});
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx
index 8f385a734..5ebece77a 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Weight/screens/BodyWeight.test.tsx
@@ -45,9 +45,10 @@ describe("Test BodyWeight component", () => {
);
- // Assert - both weights are found in the document
- expect(await screen.findByText("80")).toBeInTheDocument();
- expect(await screen.findByText("90")).toBeInTheDocument();
+ // Assert - both weights are found in the document, in the unit the
+ // grid shows them in
+ expect(await screen.findByText("80 kg")).toBeInTheDocument();
+ expect(await screen.findByText("90 kg")).toBeInTheDocument();
// only the entries the range shows are fetched
expect(getWeights).toHaveBeenCalledWith(
testBodyWeightCategory,
@@ -65,7 +66,7 @@ describe("Test BodyWeight component", () => {
);
- expect(await screen.findByText("80")).toBeInTheDocument();
+ expect(await screen.findByText("80 kg")).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'measurements.chartRangeAll' }));
@@ -74,6 +75,6 @@ describe("Test BodyWeight component", () => {
expect(getWeights).toHaveBeenLastCalledWith(testBodyWeightCategory, {});
});
// the entries stay on screen while the wider range is loading
- expect(screen.getByText("80")).toBeInTheDocument();
+ expect(screen.getByText("80 kg")).toBeInTheDocument();
});
});
diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx
index 4a6135e67..e0079903d 100644
--- a/src/components/Weight/screens/BodyWeight.tsx
+++ b/src/components/Weight/screens/BodyWeight.tsx
@@ -1,5 +1,6 @@
import { Box, Stack } from "@mui/material";
import {
+ CategoryDetailDataGrid,
ChartRange,
ChartRangeSelector,
DEFAULT_CHART_RANGE,
@@ -11,7 +12,6 @@ import {
useBodyWeightQuery,
useDisplayWeightUnit
} from "@/components/Weight/queries";
-import { WeightTable } from "@/components/Weight/widgets/Table";
import { WeightChart } from "@/components/Weight/widgets/WeightChart";
import { AddBodyWeightEntryFab } from "@/components/Weight/widgets/fab";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
@@ -53,10 +53,12 @@ export const BodyWeight = () => {
range={range}
planPeriods={planPeriods} />
-
+ {/* The entries are read by their own query here, the official
+ category is fetched without them */}
+
>}
}
diff --git a/src/components/Weight/widgets/Table/Fab/Fab.tsx b/src/components/Weight/widgets/Table/Fab/Fab.tsx
deleted file mode 100644
index 408bba3fa..000000000
--- a/src/components/Weight/widgets/Table/Fab/Fab.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import React from 'react';
-import { Fab } from '@mui/material';
-import AddIcon from '@mui/icons-material/Add';
-import { WeightForm } from "@/components/Weight/forms/WeightForm";
-import { WgerModal } from "@/core/ui/Modals/WgerModal";
-import { useTranslation } from "react-i18next";
-
-
-export const WeightEntryFab = () => {
-
- const [t] = useTranslation();
- const [openModal, setOpenModal] = React.useState(false);
- const handleOpenModal = () => setOpenModal(true);
- const handleCloseModal = () => setOpenModal(false);
-
- return (
-
-
`max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`,
- zIndex: 9,
- }}>
-
-
-
-
-
-
- );
-};
\ No newline at end of file
diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx
deleted file mode 100644
index 805138614..000000000
--- a/src/components/Weight/widgets/Table/index.test.tsx
+++ /dev/null
@@ -1,186 +0,0 @@
-import { MeasurementEntry } from "@/components/Measurements";
-import { makeWeightEntry } from "@/tests/weight/testData";
-import { QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from '@testing-library/react';
-import userEvent from "@testing-library/user-event";
-import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
-import { BrowserRouter } from "react-router-dom";
-import { testQueryClient } from "@/tests/queryClient";
-import type { Mock } from 'vitest';
-import { WeightTable } from './index';
-
-vi.mock("@/components/Measurements/queries");
-
-const renderTable = (weights: MeasurementEntry[]) =>
- render(
-
-
-
-
-
- );
-
-const ENTRY_UUID_1 = 'dddddddd-dddd-dddd-dddd-000000000001';
-const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000002';
-const ENTRY_UUID_3 = 'dddddddd-dddd-dddd-dddd-000000000003';
-
-describe("Body weight table", () => {
-
- beforeEach(() => {
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- });
-
- test('renders rows for all weight entries', async () => {
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 }),
- makeWeightEntry(new Date('2021/12/20'), 90, { id: ENTRY_UUID_2 }),
- ];
-
- renderTable(weights);
-
- expect(await screen.findByText('80')).toBeInTheDocument();
- expect(await screen.findByText('90')).toBeInTheDocument();
- });
-
- test('displays total change column correctly', async () => {
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 }),
- makeWeightEntry(new Date('2021/12/20'), 90, { id: ENTRY_UUID_2 }),
- makeWeightEntry(new Date('2021/12/25'), 85, { id: ENTRY_UUID_3 }),
- ];
-
- renderTable(weights);
- await screen.findByText('80');
-
- // DataGrid rows are sorted newest-first: 85 (total +5), 90 (+10), 80 (0)
- const rowIds: Record = { '80': ENTRY_UUID_1, '90': ENTRY_UUID_2, '85': ENTRY_UUID_3 };
- const expectedTotals: Record = { '85': '5', '90': '10', '80': '0' };
-
- for (const [weight, totalChange] of Object.entries(expectedTotals)) {
- const row = document.querySelector(`[data-id="${rowIds[weight]}"]`) as HTMLElement;
- expect(row).not.toBeNull();
- const cell = row.querySelector('[data-field="totalChange"]') as HTMLElement;
- expect(cell.textContent).toBe(totalChange);
- }
- });
-
- test('shows inline edit and delete actions per row', async () => {
- const weights: MeasurementEntry[] = [makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1 })];
- renderTable(weights);
-
- await screen.findByText('80');
- expect(screen.getByRole('menuitem', { name: /edit/i })).toBeInTheDocument();
- expect(screen.getByRole('menuitem', { name: /delete/i })).toBeInTheDocument();
- });
-
- test('converts mixed units to the display unit, including aggregations', async () => {
- // 90 lb = 40.82 kg, entered a day after the 80 kg entry
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
- makeWeightEntry(new Date('2021/12/11'), 90, { id: ENTRY_UUID_2, unit: 'lb' }),
- ];
-
- renderTable(weights);
- await screen.findByText('80');
-
- // the DataGrid formats numbers with the runner's locale, normalize the decimal separator
- const cellText = (row: HTMLElement, field: string) =>
- row.querySelector(`[data-field="${field}"]`)!.textContent!.replace(',', '.');
-
- const lbRow = document.querySelector(`[data-id="${ENTRY_UUID_2}"]`) as HTMLElement;
- expect(cellText(lbRow, 'weight')).toBe('40.82');
- // change and totalChange are computed on the converted values
- expect(cellText(lbRow, 'totalChange')).toBe('-39.18');
- });
-
- test('saving a row without editing the weight keeps the stored value and unit', async () => {
- const user = userEvent.setup();
- const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- // stored as 90 lb, displayed as 40.82 kg
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
- ];
-
- renderTable(weights);
- await screen.findByText(/40[.,]82/);
- await user.click(screen.getByRole('menuitem', { name: /edit/i }));
- await user.click(screen.getByRole('menuitem', { name: /save/i }));
-
- // the displayed conversion must not be written back to the entry
- expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
- expect(Number(submitted.value)).toBe(90);
- expect(submitted.extraData.unit).toBe('lb');
- });
-
- test('editing the weight cell stamps the display unit', async () => {
- const user = userEvent.setup();
- const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' }),
- ];
-
- renderTable(weights);
- await screen.findByText(/40[.,]82/);
- await user.click(screen.getByRole('menuitem', { name: /edit/i }));
-
- // the weight cell is a number input while the row is in edit mode;
- // the typed value is in the unit the column header shows (kg)
- const weightInput = screen.getByRole('spinbutton');
- await user.clear(weightInput);
- await user.type(weightInput, '41');
- await user.click(screen.getByRole('menuitem', { name: /save/i }));
-
- expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
- expect(Number(submitted.value)).toBe(41);
- expect(submitted.extraData.unit).toBe('kg');
- });
-
- test('implausible inline edits are rejected and the row stays editable', async () => {
- const user = userEvent.setup();
- const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }),
- ];
-
- renderTable(weights);
- await screen.findByText('80');
- await user.click(screen.getByRole('menuitem', { name: /edit/i }));
-
- const weightInput = screen.getByRole('spinbutton');
- await user.clear(weightInput);
- await user.type(weightInput, '5000');
- await user.click(screen.getByRole('menuitem', { name: /save/i }));
-
- // nothing is saved, the error shows up and the cell stays editable
- expect(mutateEditMock).not.toHaveBeenCalled();
- expect(await screen.findByText('forms.maxValue')).toBeInTheDocument();
- expect(screen.getByRole('spinbutton')).toBeInTheDocument();
-
- // correcting the value saves normally
- await user.clear(weightInput);
- await user.type(weightInput, '90');
- await user.click(screen.getByRole('menuitem', { name: /save/i }));
- expect(mutateEditMock).toHaveBeenCalled();
- const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
- expect(Number(submitted.value)).toBe(90);
- });
-
- test('entries synced from a health app offer no edit or delete actions', async () => {
- const weights: MeasurementEntry[] = [
- makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg', source: 'apple' }),
- ];
-
- renderTable(weights);
- await screen.findByText('80');
-
- expect(screen.queryByRole('menuitem', { name: /edit/i })).not.toBeInTheDocument();
- expect(screen.queryByRole('menuitem', { name: /delete/i })).not.toBeInTheDocument();
- expect(screen.getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument();
- });
-});
diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx
deleted file mode 100644
index f1e983b8d..000000000
--- a/src/components/Weight/widgets/Table/index.tsx
+++ /dev/null
@@ -1,263 +0,0 @@
-import CancelIcon from "@mui/icons-material/Close";
-import CloudSyncIcon from "@mui/icons-material/CloudSync";
-import DeleteIcon from "@mui/icons-material/DeleteOutlined";
-import EditIcon from "@mui/icons-material/Edit";
-import SaveIcon from "@mui/icons-material/Save";
-import { Box, Snackbar, Tooltip } from "@mui/material";
-import {
- DataGrid,
- GridActionsCellItem,
- GridColDef,
- GridEventListener,
- GridRowEditStopReasons,
- GridRowId,
- GridRowModel,
- GridRowModes,
- GridRowModesModel,
- GridRowsProp,
-} from "@mui/x-data-grid";
-import {
- limitsFor,
- MeasurementEntry,
- METRIC_TYPE_BODY_WEIGHT,
- useDeleteMeasurementEntryQuery,
- useEditMeasurementEntryQuery
-} from "@/components/Measurements";
-import { extraDataInUnit } from "@/components/Weight/models/bodyWeight";
-import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab";
-import { processTimeSeries } from "@/core/lib/timeSeries";
-import { WeightUnit } from "@/core/lib/weightUnit";
-import { DateTime } from "luxon";
-import React, { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { PAGINATION_OPTIONS } from "@/core/lib/consts";
-import { luxonDateTimeToLocale } from "@/core/lib/date";
-
-export interface WeightTableProps {
- weights: MeasurementEntry[];
- unit: WeightUnit;
- categoryUnit: string;
-}
-
-const buildRows = (weights: MeasurementEntry[], unit: WeightUnit, categoryUnit: string): GridRowsProp =>
- processTimeSeries(weights, e => e.valueIn(unit, categoryUnit)).map((row) => ({
- id: row.entry.id,
- date: row.entry.date,
- weight: row.entry.valueIn(unit, categoryUnit),
- isEditable: row.entry.isEditable,
- change: +row.change.toFixed(2),
- totalChange: +row.totalChange.toFixed(2),
- days: +row.days.toFixed(1),
- }));
-
-export const WeightTable = ({ weights, unit, categoryUnit }: WeightTableProps) => {
- const [t] = useTranslation();
- const rows = buildRows(weights, unit, categoryUnit);
- const editEntryQuery = useEditMeasurementEntryQuery();
- const deleteEntryQuery = useDeleteMeasurementEntryQuery();
- const [rowModesModel, setRowModesModel] = useState({});
- const [editError, setEditError] = useState(null);
-
- const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => {
- if (params.reason === GridRowEditStopReasons.rowFocusOut) {
- event.defaultMuiPrevented = true;
- }
- };
-
- const handleEditClick = (id: GridRowId) => () => {
- setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } });
- };
-
- const handleSaveClick = (id: GridRowId) => () => {
- setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } });
- };
-
- const handleDeleteClick = (id: GridRowId) => () => {
- deleteEntryQuery.mutate(String(id));
- };
-
- const handleCancelClick = (id: GridRowId) => () => {
- setRowModesModel({
- ...rowModesModel,
- [id]: { mode: GridRowModes.View, ignoreModifications: true },
- });
- };
-
- const processRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => {
- const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date);
- const entry = weights.find(w => w.id === newRow.id)!;
-
- // The grid shows the value converted to the display unit. Re-saving
- // that conversion would silently overwrite the entry's stored value
- // and unit, so both only change when the weight cell was edited: the
- // typed value is then stamped with the display unit the column shows
- if (Number(newRow.weight) === Number(oldRow.weight)) {
- editEntryQuery.mutate(MeasurementEntry.clone(entry, { date: date }));
- } else {
- // the typed value is in the display unit the column header shows;
- // throwing keeps the row in edit mode so it can be corrected
- const weight = Number(newRow.weight);
- const { min, max } = limitsFor(METRIC_TYPE_BODY_WEIGHT, unit);
- if (weight < min) {
- throw new Error(t('forms.minValue', { value: `${min} ${t(`server.${unit}`)}` }));
- }
- if (weight > max) {
- throw new Error(t('forms.maxValue', { value: `${max} ${t(`server.${unit}`)}` }));
- }
- editEntryQuery.mutate(MeasurementEntry.clone(entry, {
- date: date,
- value: weight,
- extraData: extraDataInUnit(entry, unit),
- }));
- }
- return newRow;
- };
-
- const onProcessRowUpdateError = (error: unknown) => {
- setEditError(error instanceof Error ? error.message : String(error));
- };
-
- const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => {
- setRowModesModel(newRowModesModel);
- };
-
- const columns: GridColDef[] = [
- {
- field: 'date',
- headerName: t('date'),
- type: 'dateTime',
- width: 160,
- editable: true,
- valueFormatter: (value?: Date) => {
- if (value == null) {
- return '';
- }
- return luxonDateTimeToLocale(DateTime.fromJSDate(value), undefined, DateTime.DATETIME_SHORT);
- },
- },
- {
- field: 'weight',
- headerName: `${t('weight')} (${t(`server.${unit}`)})`,
- type: 'number',
- width: 100,
- editable: true,
- },
- {
- field: 'change',
- headerName: t('difference'),
- type: 'number',
- width: 120,
- editable: false,
- },
- {
- field: 'totalChange',
- headerName: t('totalChange'),
- type: 'number',
- width: 140,
- editable: false,
- },
- {
- field: 'days',
- headerName: t('days'),
- type: 'number',
- width: 100,
- editable: false,
- },
- {
- field: 'actions',
- type: 'actions',
- headerName: t('actions'),
- width: 100,
- cellClassName: 'actions',
- getActions: ({ id, row }) => {
- // synced entries are managed by the source app, offer no actions
- if (!row.isEditable) {
- return [
- }
- label={t('syncedEntryInfo')}
- color="inherit"
- // a badge, not a button: disabled drops the click
- // affordance, the style keeps hover events flowing
- // so the tooltip still works
- disabled
- style={{ pointerEvents: 'auto', cursor: 'default' }}
- />,
- ];
- }
-
- const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit;
-
- if (isInEditMode) {
- return [
- }
- label={t('save')}
- onClick={handleSaveClick(id)}
- />,
- }
- label={t('cancel')}
- className="textPrimary"
- onClick={handleCancelClick(id)}
- color="inherit"
- />,
- ];
- }
-
- return [
- }
- label={t('edit')}
- className="textPrimary"
- onClick={handleEditClick(id)}
- color="inherit"
- />,
- }
- label={t('delete')}
- onClick={handleDeleteClick(id)}
- color="inherit"
- />,
- ];
- },
- },
- ];
-
- return (
- <>
-
- params.row.isEditable}
- rowModesModel={rowModesModel}
- onRowModesModelChange={handleRowModesModelChange}
- onRowEditStop={handleRowEditStop}
- processRowUpdate={processRowUpdate}
- onProcessRowUpdateError={onProcessRowUpdateError}
- />
-
- setEditError(null)}
- message={editError}
- />
-
- >
- );
-};
diff --git a/src/components/Weight/widgets/Table/table.module.css b/src/components/Weight/widgets/Table/table.module.css
deleted file mode 100644
index 24a897a5b..000000000
--- a/src/components/Weight/widgets/Table/table.module.css
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-/*# sourceMappingURL=table.module.css.map */
diff --git a/src/components/Weight/widgets/Table/table.module.css.map b/src/components/Weight/widgets/Table/table.module.css.map
deleted file mode 100644
index e610b88e8..000000000
--- a/src/components/Weight/widgets/Table/table.module.css.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.module.css"}
\ No newline at end of file
diff --git a/src/components/Weight/widgets/Table/table.module.scss b/src/components/Weight/widgets/Table/table.module.scss
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/components/Weight/widgets/Table/table.mosule.css b/src/components/Weight/widgets/Table/table.mosule.css
deleted file mode 100644
index 89069e79e..000000000
--- a/src/components/Weight/widgets/Table/table.mosule.css
+++ /dev/null
@@ -1,2 +0,0 @@
-
-/*# sourceMappingURL=table.mosule.css.map */
diff --git a/src/components/Weight/widgets/Table/table.mosule.css.map b/src/components/Weight/widgets/Table/table.mosule.css.map
deleted file mode 100644
index 0e61f5b3a..000000000
--- a/src/components/Weight/widgets/Table/table.mosule.css.map
+++ /dev/null
@@ -1 +0,0 @@
-{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.mosule.css"}
From 7c12e2a51b51f74680d6059a7e169f1237f36522 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 20:01:50 +0200
Subject: [PATCH 36/71] Fold the weight domain into the measurements one
---
.../Components/CalendarComponent.test.tsx | 4 +--
.../Calendar/Components/CalendarComponent.tsx | 7 ++--
.../Calendar/Components/Entries.tsx | 2 +-
src/components/Dashboard/WeightCard.test.tsx | 4 +--
src/components/Dashboard/WeightCard.tsx | 5 +--
.../api/bodyWeight.test.ts} | 2 +-
.../api/bodyWeight.ts} | 9 +++--
src/components/Measurements/index.ts | 14 ++++++++
.../models/bodyWeight.ts | 2 +-
.../queries/bodyWeight.test.tsx} | 6 ++--
.../queries/bodyWeight.ts} | 2 +-
.../screens/BodyWeight.test.tsx | 4 +--
.../screens/BodyWeight.tsx | 16 ++++-----
.../widgets/CategoryForm.test.tsx | 2 +-
.../Measurements/widgets/EntryForm.test.tsx | 2 +-
.../widgets/WeightChart.test.tsx} | 2 +-
.../widgets/WeightChart.tsx} | 16 ++++-----
.../widgets}/WeightForm.test.tsx | 6 ++--
.../widgets}/WeightForm.tsx | 11 +++----
.../widgets/WeightTableDashboard.test.tsx} | 2 +-
.../widgets/WeightTableDashboard.tsx} | 2 +-
src/components/Measurements/widgets/fab.tsx | 30 ++++++++++++++++-
.../Nutrition/screens/BmiCalculator.test.tsx | 2 +-
.../Nutrition/screens/BmiCalculator.tsx | 7 ++--
.../widgets/charts/PlanWeightChart.tsx | 2 +-
.../Nutrition/widgets/forms/PlanForm.test.tsx | 2 +-
src/components/Weight/index.ts | 12 -------
src/components/Weight/widgets/fab.tsx | 33 -------------------
src/pages/WeightOverview/index.tsx | 2 +-
29 files changed, 102 insertions(+), 108 deletions(-)
rename src/components/{Weight/api/weight.test.ts => Measurements/api/bodyWeight.test.ts} (98%)
rename src/components/{Weight/api/weight.ts => Measurements/api/bodyWeight.ts} (87%)
rename src/components/{Weight => Measurements}/models/bodyWeight.ts (88%)
rename src/components/{Weight/queries/index.test.tsx => Measurements/queries/bodyWeight.test.tsx} (89%)
rename src/components/{Weight/queries/index.ts => Measurements/queries/bodyWeight.ts} (98%)
rename src/components/{Weight => Measurements}/screens/BodyWeight.test.tsx (97%)
rename src/components/{Weight => Measurements}/screens/BodyWeight.tsx (82%)
rename src/components/{Weight/widgets/WeightChart/index.test.tsx => Measurements/widgets/WeightChart.test.tsx} (98%)
rename src/components/{Weight/widgets/WeightChart/index.tsx => Measurements/widgets/WeightChart.tsx} (71%)
rename src/components/{Weight/forms => Measurements/widgets}/WeightForm.test.tsx (97%)
rename src/components/{Weight/forms => Measurements/widgets}/WeightForm.tsx (94%)
rename src/components/{Weight/widgets/TableDashboard/TableDashboard.test.tsx => Measurements/widgets/WeightTableDashboard.test.tsx} (93%)
rename src/components/{Weight/widgets/TableDashboard/TableDashboard.tsx => Measurements/widgets/WeightTableDashboard.tsx} (96%)
delete mode 100644 src/components/Weight/index.ts
delete mode 100644 src/components/Weight/widgets/fab.tsx
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index 618bae980..ac5d34dfa 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -2,7 +2,7 @@ import { MeasurementCategory, MeasurementEntry } from "@/components/Measurements
import { getMeasurementCategories } from "@/components/Measurements/api/measurements";
import { getNutritionalDiaryEntries } from "@/components/Nutrition/api/nutritionalDiary";
import { getSessions } from "@/components/Routines/api/session";
-import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
+import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata";
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
@@ -21,7 +21,7 @@ import CalendarComponent from "./CalendarComponent";
vi.mock("@/components/Measurements/api/measurements");
vi.mock("@/components/Nutrition/api/nutritionalDiary");
vi.mock("@/components/Routines/api/session");
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock('@/components/User/queries/profile', () => ({
useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }),
}));
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index d44045b68..192128afd 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -2,10 +2,13 @@ import CalendarDayGrid from "@/components/Calendar/Components/CalendarDayGrid";
import CalendarHeader from "@/components/Calendar/Components/CalendarHeader";
import { CalendarMeasurement } from "@/components/Calendar/Helpers/CalendarMeasurement";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { MeasurementEntry, useMeasurementsCategoryQuery } from "@/components/Measurements";
+import {
+ MeasurementEntry,
+ useBodyWeightQuery,
+ useMeasurementsCategoryQuery
+} from "@/components/Measurements";
import { DiaryEntry, useNutritionDiaryQuery } from "@/components/Nutrition";
import { useSessionsQuery, WorkoutSession } from "@/components/Routines";
-import { useBodyWeightQuery } from "@/components/Weight";
import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date";
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
import { Box, Card, CardContent, CardHeader, useMediaQuery, useTheme } from '@mui/material';
diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx
index b090695fc..6d5193c82 100644
--- a/src/components/Calendar/Components/Entries.tsx
+++ b/src/components/Calendar/Components/Entries.tsx
@@ -12,7 +12,7 @@ import {
} from '@mui/material';
import React from 'react';
import { useTranslation } from "react-i18next";
-import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements";
import { dateToLocale } from "@/core/lib/date";
import { DayProps } from "./CalendarComponent";
diff --git a/src/components/Dashboard/WeightCard.test.tsx b/src/components/Dashboard/WeightCard.test.tsx
index 89dd1fa1c..9cf22f094 100644
--- a/src/components/Dashboard/WeightCard.test.tsx
+++ b/src/components/Dashboard/WeightCard.test.tsx
@@ -1,12 +1,12 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
-import { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Weight";
+import { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Measurements";
import { WeightCard } from "@/components/Dashboard/WeightCard";
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, testWeightEntries } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/queries");
+vi.mock("@/components/Measurements/queries/bodyWeight");
describe("test the WeightCard component", () => {
diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx
index 0d12f94b3..ea98ad47b 100644
--- a/src/components/Dashboard/WeightCard.tsx
+++ b/src/components/Dashboard/WeightCard.tsx
@@ -1,15 +1,16 @@
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
-import { entryFilterFor, MeasurementEntry } from "@/components/Measurements";
import {
+ entryFilterFor,
+ MeasurementEntry,
useBodyWeightCategoryQuery,
useBodyWeightQuery,
useDisplayWeightUnit,
WeightChart,
WeightForm,
WeightTableDashboard
-} from "@/components/Weight";
+} from "@/components/Measurements";
import { makeLink, WgerLink } from "@/core/lib/url";
import AddIcon from "@mui/icons-material/Add";
import { Box, Button, IconButton } from "@mui/material";
diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Measurements/api/bodyWeight.test.ts
similarity index 98%
rename from src/components/Weight/api/weight.test.ts
rename to src/components/Measurements/api/bodyWeight.test.ts
index 6c1fb38c2..5c4b8323b 100644
--- a/src/components/Weight/api/weight.test.ts
+++ b/src/components/Measurements/api/bodyWeight.test.ts
@@ -1,6 +1,6 @@
import axios from "axios";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
-import { getBodyWeightCategory, getWeights } from "./weight";
+import { getBodyWeightCategory, getWeights } from "./bodyWeight";
import type { Mock } from 'vitest';
vi.mock("axios");
diff --git a/src/components/Weight/api/weight.ts b/src/components/Measurements/api/bodyWeight.ts
similarity index 87%
rename from src/components/Weight/api/weight.ts
rename to src/components/Measurements/api/bodyWeight.ts
index 65924c2a7..5519f2daf 100644
--- a/src/components/Weight/api/weight.ts
+++ b/src/components/Measurements/api/bodyWeight.ts
@@ -1,10 +1,9 @@
import {
API_MEASUREMENTS_CATEGORY_PATH,
- getMeasurementEntries,
- MeasurementCategory,
- MeasurementEntry,
- METRIC_TYPE_BODY_WEIGHT
-} from "@/components/Measurements";
+ getMeasurementEntries
+} from "@/components/Measurements/api/measurements";
+import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { ResponseType } from "@/core/api/responseType";
import { makeHeader, makeUrl } from "@/core/lib/url";
import { ApiMeasurementCategoryType } from '@/types';
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 280286524..6350e3c14 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -3,7 +3,12 @@
*
* Other code may only import from `@/components/Measurements`, never from
* internal sub-paths.
+ *
+ * Body weight lives here too: it is the user's official body weight category,
+ * i.e. measurement data with its own screens (see the plan's locked decision
+ * #2), not a domain of its own.
*/
+export { BodyWeight } from "./screens/BodyWeight";
export { MeasurementCategoryDetail } from "./screens/MeasurementCategoryDetail";
export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOverview";
@@ -15,6 +20,7 @@ export {
METRIC_TYPE_BODY_WEIGHT
} from "./models/Category";
export { MeasurementEntry } from "./models/Entry";
+export { weightUnitOf } from "./models/bodyWeight";
// API endpoints
export {
@@ -30,6 +36,11 @@ export {
useEditMeasurementEntryQuery,
useMeasurementsCategoryQuery
} from "./queries";
+export {
+ useBodyWeightCategoryQuery,
+ useBodyWeightQuery,
+ useDisplayWeightUnit
+} from "./queries/bodyWeight";
// Charts
export { componentColor, componentPalette } from "./charts/colors";
@@ -46,3 +57,6 @@ export { ChartRangeSelector } from "./widgets/ChartRangeSelector";
export { MeasurementChart } from "./widgets/MeasurementChart";
export { MeasurementSeriesChart } from "./widgets/MeasurementSeriesChart";
export { OverallChange } from "./widgets/OverallChange";
+export { WeightChart } from "./widgets/WeightChart";
+export { WeightForm } from "./widgets/WeightForm";
+export { WeightTableDashboard } from "./widgets/WeightTableDashboard";
diff --git a/src/components/Weight/models/bodyWeight.ts b/src/components/Measurements/models/bodyWeight.ts
similarity index 88%
rename from src/components/Weight/models/bodyWeight.ts
rename to src/components/Measurements/models/bodyWeight.ts
index d75066151..963be62d1 100644
--- a/src/components/Weight/models/bodyWeight.ts
+++ b/src/components/Measurements/models/bodyWeight.ts
@@ -1,4 +1,4 @@
-import { MeasurementEntry } from "@/components/Measurements";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
/**
diff --git a/src/components/Weight/queries/index.test.tsx b/src/components/Measurements/queries/bodyWeight.test.tsx
similarity index 89%
rename from src/components/Weight/queries/index.test.tsx
rename to src/components/Measurements/queries/bodyWeight.test.tsx
index 0c99bd54c..3e78a9b55 100644
--- a/src/components/Weight/queries/index.test.tsx
+++ b/src/components/Measurements/queries/bodyWeight.test.tsx
@@ -1,13 +1,13 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook, waitFor } from '@testing-library/react';
-import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
-import { useBodyWeightQuery } from "@/components/Weight/queries";
+import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
+import { useBodyWeightQuery } from "@/components/Measurements/queries/bodyWeight";
import { QueryKey } from "@/core/lib/consts";
import { testBodyWeightCategory } from "@/tests/weight/testData";
import React from "react";
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
describe("body weight queries", () => {
diff --git a/src/components/Weight/queries/index.ts b/src/components/Measurements/queries/bodyWeight.ts
similarity index 98%
rename from src/components/Weight/queries/index.ts
rename to src/components/Measurements/queries/bodyWeight.ts
index acc192eb2..abb41c8a7 100644
--- a/src/components/Weight/queries/index.ts
+++ b/src/components/Measurements/queries/bodyWeight.ts
@@ -1,5 +1,5 @@
import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query";
-import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
+import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
import { useProfileQuery } from "@/components/User";
import { QueryKey, } from "@/core/lib/consts";
import { WeightUnit } from "@/core/lib/weightUnit";
diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Measurements/screens/BodyWeight.test.tsx
similarity index 97%
rename from src/components/Weight/screens/BodyWeight.test.tsx
rename to src/components/Measurements/screens/BodyWeight.test.tsx
index 5ebece77a..dda6c3709 100644
--- a/src/components/Weight/screens/BodyWeight.test.tsx
+++ b/src/components/Measurements/screens/BodyWeight.test.tsx
@@ -1,13 +1,13 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements";
-import { getBodyWeightCategory, getWeights } from "@/components/Weight/api/weight";
+import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import { BodyWeight } from "./BodyWeight";
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock('@/components/Nutrition/queries/plan', () => ({
useNutritionPlanPeriods: () => [],
}));
diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
similarity index 82%
rename from src/components/Weight/screens/BodyWeight.tsx
rename to src/components/Measurements/screens/BodyWeight.tsx
index e0079903d..004e6ea1b 100644
--- a/src/components/Weight/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -1,19 +1,15 @@
import { Box, Stack } from "@mui/material";
-import {
- CategoryDetailDataGrid,
- ChartRange,
- ChartRangeSelector,
- DEFAULT_CHART_RANGE,
- entryFilterFor
-} from "@/components/Measurements";
+import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
+import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
+import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import {
useBodyWeightCategoryQuery,
useBodyWeightQuery,
useDisplayWeightUnit
-} from "@/components/Weight/queries";
-import { WeightChart } from "@/components/Weight/widgets/WeightChart";
-import { AddBodyWeightEntryFab } from "@/components/Weight/widgets/fab";
+} from "@/components/Measurements/queries/bodyWeight";
+import { WeightChart } from "@/components/Measurements/widgets/WeightChart";
+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";
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 2099c73c9..6b771ada9 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -12,7 +12,7 @@ import React from 'react';
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock("@/components/Measurements/queries");
diff --git a/src/components/Measurements/widgets/EntryForm.test.tsx b/src/components/Measurements/widgets/EntryForm.test.tsx
index 7475fdf81..47cb8b9d1 100644
--- a/src/components/Measurements/widgets/EntryForm.test.tsx
+++ b/src/components/Measurements/widgets/EntryForm.test.tsx
@@ -18,7 +18,7 @@ import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/Ent
import i18n from "i18next";
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_ENTRIES_1 } from "@/tests/measurementsTestData";
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock("@/components/Measurements/queries");
diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Measurements/widgets/WeightChart.test.tsx
similarity index 98%
rename from src/components/Weight/widgets/WeightChart/index.test.tsx
rename to src/components/Measurements/widgets/WeightChart.test.tsx
index 104aaa282..0b07a08f6 100644
--- a/src/components/Weight/widgets/WeightChart/index.test.tsx
+++ b/src/components/Measurements/widgets/WeightChart.test.tsx
@@ -5,7 +5,7 @@ import { render } from '@testing-library/react';
import React from 'react';
import { describe, expect, test } from 'vitest';
import { testQueryClient } from "@/tests/queryClient";
-import { WeightChart } from "./index";
+import { WeightChart } from "./WeightChart";
// See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest
// Recharts only paints SVG content once a ResizeObserver entry reports real
diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Measurements/widgets/WeightChart.tsx
similarity index 71%
rename from src/components/Weight/widgets/WeightChart/index.tsx
rename to src/components/Measurements/widgets/WeightChart.tsx
index d15ebd31f..5cd2a86d5 100644
--- a/src/components/Weight/widgets/WeightChart/index.tsx
+++ b/src/components/Measurements/widgets/WeightChart.tsx
@@ -1,13 +1,9 @@
-import {
- ChartRange,
- cutoffFor,
- DEFAULT_CHART_RANGE,
- MeasurementEntry,
- measurementSeries,
- MeasurementSeriesChart,
- OverallChange,
- PlanPeriod
-} from "@/components/Measurements";
+import { measurementSeries } from "@/components/Measurements/charts/data";
+import { ChartRange, cutoffFor, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
+import { PlanPeriod } from "@/components/Measurements/charts/series";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
+import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
import { WeightUnit } from "@/core/lib/weightUnit";
import React from "react";
import { useTranslation } from "react-i18next";
diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Measurements/widgets/WeightForm.test.tsx
similarity index 97%
rename from src/components/Weight/forms/WeightForm.test.tsx
rename to src/components/Measurements/widgets/WeightForm.test.tsx
index 2fda5ef74..f352fd33e 100644
--- a/src/components/Weight/forms/WeightForm.test.tsx
+++ b/src/components/Measurements/widgets/WeightForm.test.tsx
@@ -3,15 +3,15 @@ import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import { useProfileQuery } from "@/components/User";
-import { WeightForm } from "@/components/Weight/forms/WeightForm";
+import { WeightForm } from "@/components/Measurements/widgets/WeightForm";
import { useAddMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
-import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements/queries/bodyWeight";
import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/queries");
+vi.mock("@/components/Measurements/queries/bodyWeight");
vi.mock("@/components/Measurements/queries");
vi.mock("@/components/User/queries/profile");
diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Measurements/widgets/WeightForm.tsx
similarity index 94%
rename from src/components/Weight/forms/WeightForm.tsx
rename to src/components/Measurements/widgets/WeightForm.tsx
index 79c3fd783..c99121454 100644
--- a/src/components/Weight/forms/WeightForm.tsx
+++ b/src/components/Measurements/widgets/WeightForm.tsx
@@ -1,15 +1,14 @@
import { Button, Stack, TextField, ToggleButton, ToggleButtonGroup } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
+import { limitsFor, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
- limitsFor,
- MeasurementEntry,
- METRIC_TYPE_BODY_WEIGHT,
useAddMeasurementEntryQuery,
useEditMeasurementEntryQuery
-} from "@/components/Measurements";
-import { weightUnitOf } from "@/components/Weight/models/bodyWeight";
-import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Weight/queries";
+} from "@/components/Measurements/queries";
+import { weightUnitOf } from "@/components/Measurements/models/bodyWeight";
+import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements/queries/bodyWeight";
import { useProfileQuery } from "@/components/User";
import { WeightUnit } from "@/core/lib/weightUnit";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx b/src/components/Measurements/widgets/WeightTableDashboard.test.tsx
similarity index 93%
rename from src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
rename to src/components/Measurements/widgets/WeightTableDashboard.test.tsx
index 398e68921..92d2c57cd 100644
--- a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx
+++ b/src/components/Measurements/widgets/WeightTableDashboard.test.tsx
@@ -2,7 +2,7 @@ import { MeasurementEntry } from "@/components/Measurements";
import { makeWeightEntry } from "@/tests/weight/testData";
import React from 'react';
import { render, screen } from '@testing-library/react';
-import { WeightTableDashboard } from '@/components/Weight/widgets/TableDashboard/TableDashboard';
+import { WeightTableDashboard } from '@/components/Measurements/widgets/WeightTableDashboard';
describe("Body weight test", () => {
test('renders without crashing', async () => {
diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx b/src/components/Measurements/widgets/WeightTableDashboard.tsx
similarity index 96%
rename from src/components/Weight/widgets/TableDashboard/TableDashboard.tsx
rename to src/components/Measurements/widgets/WeightTableDashboard.tsx
index a4a5d23c6..7e3080448 100644
--- a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx
+++ b/src/components/Measurements/widgets/WeightTableDashboard.tsx
@@ -1,6 +1,6 @@
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material';
import { styled } from '@mui/material/styles';
-import { MeasurementEntry } from "@/components/Measurements";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import React from 'react';
import { useTranslation } from "react-i18next";
import { dateTimeToLocale } from "@/core/lib/date";
diff --git a/src/components/Measurements/widgets/fab.tsx b/src/components/Measurements/widgets/fab.tsx
index 4c498df42..73e235abf 100644
--- a/src/components/Measurements/widgets/fab.tsx
+++ b/src/components/Measurements/widgets/fab.tsx
@@ -6,6 +6,7 @@ import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/EntryForm";
+import { WeightForm } from "@/components/Measurements/widgets/WeightForm";
export const AddMeasurementCategoryFab = () => {
const [t] = useTranslation();
@@ -61,4 +62,31 @@ export const AddMeasurementEntryFab = ({ category }: { category: MeasurementCate
: }
>);
-};
\ No newline at end of file
+};
+
+export const AddBodyWeightEntryFab = () => {
+ const [t] = useTranslation();
+ const [openModal, setOpenModal] = React.useState(false);
+ const handleOpenModal = () => setOpenModal(true);
+ const handleCloseModal = () => setOpenModal(false);
+
+ return (
+
+
`max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`,
+ zIndex: 9,
+ }}>
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/Nutrition/screens/BmiCalculator.test.tsx b/src/components/Nutrition/screens/BmiCalculator.test.tsx
index 49e3fb4a6..4549538c4 100644
--- a/src/components/Nutrition/screens/BmiCalculator.test.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.test.tsx
@@ -5,7 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import { testQueryClient } from "@/tests/queryClient";
// Mock the necessary React Query hooks
-vi.mock('@/components/Weight/queries', () => ({
+vi.mock('@/components/Measurements/queries/bodyWeight', () => ({
useBodyWeightQuery: () => ({
isLoading: false,
data: [{ weight: 55, unit: 'kg', valueIn: () => 55, date: new Date() }], // Provide mock weight data
diff --git a/src/components/Nutrition/screens/BmiCalculator.tsx b/src/components/Nutrition/screens/BmiCalculator.tsx
index 58a16073a..7d93e616a 100644
--- a/src/components/Nutrition/screens/BmiCalculator.tsx
+++ b/src/components/Nutrition/screens/BmiCalculator.tsx
@@ -1,7 +1,10 @@
import { Box, Stack, TextField, Typography } from "@mui/material";
import Grid from "@mui/material/Grid";
-import { entryFilterFor } from "@/components/Measurements";
-import { useBodyWeightCategoryQuery, useBodyWeightQuery } from "@/components/Weight";
+import {
+ entryFilterFor,
+ useBodyWeightCategoryQuery,
+ useBodyWeightQuery
+} from "@/components/Measurements";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import { useProfileQuery } from "@/components/User";
diff --git a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
index 743f452ea..87bccf41a 100644
--- a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
+++ b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
@@ -5,7 +5,7 @@ import {
useBodyWeightQuery,
useDisplayWeightUnit,
WeightChart
-} from "@/components/Weight";
+} from "@/components/Measurements";
import React from "react";
import { useTranslation } from "react-i18next";
diff --git a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
index 4d98847b1..1c23359eb 100644
--- a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
+++ b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
@@ -7,7 +7,7 @@ import userEvent from "@testing-library/user-event";
import React from 'react';
import type { Mock } from 'vitest';
-vi.mock("@/components/Weight/api/weight");
+vi.mock("@/components/Measurements/api/bodyWeight");
vi.mock("@/components/Nutrition/queries");
describe("Test the PlanForm component", () => {
diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts
deleted file mode 100644
index 38ed16afc..000000000
--- a/src/components/Weight/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Public surface of the Weight domain.
- *
- * Other code may only import from `@/components/Weight`, never from
- * internal sub-paths.
- */
-export { BodyWeight } from "./screens/BodyWeight";
-export { WeightForm } from "./forms/WeightForm";
-export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard";
-export { WeightChart } from "./widgets/WeightChart";
-export { weightUnitOf } from "./models/bodyWeight";
-export { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "./queries";
diff --git a/src/components/Weight/widgets/fab.tsx b/src/components/Weight/widgets/fab.tsx
deleted file mode 100644
index a70f33a12..000000000
--- a/src/components/Weight/widgets/fab.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import AddIcon from "@mui/icons-material/Add";
-import { Fab } from "@mui/material";
-import { WeightForm } from "@/components/Weight/forms/WeightForm";
-import { WgerModal } from "@/core/ui/Modals/WgerModal";
-import { useState } from "react";
-import { useTranslation } from "react-i18next";
-
-export const AddBodyWeightEntryFab = () => {
- const [t] = useTranslation();
- const [openModal, setOpenModal] = useState(false);
- const handleOpenModal = () => setOpenModal(true);
- const handleCloseModal = () => setOpenModal(false);
-
- return (
-
-
`max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`,
- zIndex: 9,
- }}>
-
-
-
-
-
-
- );
-};
diff --git a/src/pages/WeightOverview/index.tsx b/src/pages/WeightOverview/index.tsx
index 48b6d0eb7..ee0df45fa 100644
--- a/src/pages/WeightOverview/index.tsx
+++ b/src/pages/WeightOverview/index.tsx
@@ -1,4 +1,4 @@
-import { BodyWeight } from "@/components/Weight";
+import { BodyWeight } from "@/components/Measurements";
import React from 'react';
export const WeightOverview = () => {
From d2d3f6bdc9e2d8cc764a3a78a40c0f620be8ce90 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Sun, 2 Aug 2026 20:38:29 +0200
Subject: [PATCH 37/71] Show typed categories under their translated name
---
.../Calendar/Components/CalendarComponent.tsx | 10 +++++++--
src/components/Measurements/charts/data.ts | 13 +++++++----
src/components/Measurements/index.ts | 1 +
.../Measurements/models/Category.test.ts | 22 +++++++++++++++++++
.../Measurements/models/Category.ts | 18 +++++++++++++++
.../screens/MeasurementCategoryDetail.tsx | 12 ++++++----
.../screens/MeasurementCategoryOverview.tsx | 4 ++--
.../widgets/CategoryDetailDropdown.tsx | 4 ++--
.../widgets/CategoryReorderList.tsx | 8 +++++--
.../Measurements/widgets/EntryForm.test.tsx | 16 +++++++-------
.../Measurements/widgets/EntryForm.tsx | 8 +++++--
.../Measurements/widgets/MeasurementChart.tsx | 10 +++++++--
12 files changed, 98 insertions(+), 28 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 192128afd..0a32e0f67 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -3,6 +3,7 @@ import CalendarHeader from "@/components/Calendar/Components/CalendarHeader";
import { CalendarMeasurement } from "@/components/Calendar/Helpers/CalendarMeasurement";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import {
+ categoryDisplayName,
MeasurementEntry,
useBodyWeightQuery,
useMeasurementsCategoryQuery
@@ -85,7 +86,12 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const result: DayProps[] = [];
const measurements = measurementQuery.data?.flatMap(category =>
- category.entries.map(entry => new CalendarMeasurement(category.name, category.unit, entry.value, entry.date))
+ category.entries.map(entry => new CalendarMeasurement(
+ categoryDisplayName(category, t),
+ category.unit,
+ entry.value,
+ entry.date,
+ ))
) ?? [];
const firstDayOfMonth = new Date(year, month, 1);
@@ -128,7 +134,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
}
return result;
- }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, measurementQuery.data, nutritionDiaryQuery.data]);
+ }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, measurementQuery.data, nutritionDiaryQuery.data, t]);
const [selectedDay, setSelectedDay] = useState(days.find(day => isSameDay(day.date, currentDate)) || defaultDay);
const theme = useTheme();
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 40e165f83..9f3172ec7 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -255,11 +255,12 @@ export const groupRangeEntries = (
export const groupComponentSeries = (
group: MeasurementCategory,
cutoff: Date | null = null,
+ labelOf: (category: MeasurementCategory) => string = category => category.name,
): ChartSeries[] =>
group.children.map(child => ({
points: pointsSince(chartPointsFor(child.entries, child.unit, child.unit), cutoff),
role: 'component' as const,
- label: child.name,
+ label: labelOf(child),
}));
/**
@@ -366,12 +367,16 @@ export type GroupChart =
| { kind: 'range', points: ChartPoint[] }
| { kind: 'components', series: ChartSeries[] };
-export const groupChart = (group: MeasurementCategory, cutoff: Date | null = null): GroupChart => {
+export const groupChart = (
+ group: MeasurementCategory,
+ cutoff: Date | null = null,
+ labelOf: (category: MeasurementCategory) => string = category => category.name,
+): GroupChart => {
if (isSummedPerDay(group.metricType)) {
const components = stackableComponents(group);
const stacked = groupStackedEntries(components, cutoff);
if (stacked.length > 0) {
- return { kind: 'stacked', points: stacked, labels: components.map(c => c.name) };
+ return { kind: 'stacked', points: stacked, labels: components.map(labelOf) };
}
}
@@ -379,7 +384,7 @@ export const groupChart = (group: MeasurementCategory, cutoff: Date | null = nul
return ranges.length > 0
? { kind: 'range', points: ranges }
- : { kind: 'components', series: groupComponentSeries(group, cutoff) };
+ : { kind: 'components', series: groupComponentSeries(group, cutoff, labelOf) };
};
/**
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 6350e3c14..b39231749 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -14,6 +14,7 @@ export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOvervi
// Models
export {
+ categoryDisplayName,
correlatesWithNutrition,
limitsFor,
MeasurementCategory,
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 1a9583696..a30d56ab7 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -1,4 +1,5 @@
import {
+ categoryDisplayName,
isComponentMetricType,
isGroupMetricType,
isSummedPerDay,
@@ -105,4 +106,25 @@ describe('MeasurementCategory', () => {
expect(isGroupMetricType('sleep_deep')).toBe(false);
expect(isGroupMetricType('heart_rate')).toBe(false);
});
+
+ describe('categoryDisplayName', () => {
+
+ // the tests' t() returns the key it is given
+ const t = ((key: string) => key) as never;
+
+ test('a typed category is named after its metric type', () => {
+ const category = new MeasurementCategory(
+ 'c-1', 'Blutdruck', 'mmHg', undefined, 'blood_pressure_systolic',
+ );
+
+ expect(categoryDisplayName(category, t))
+ .toBe('measurements.metricTypes.blood_pressure_systolic');
+ });
+
+ test('a free-form category keeps the name the user gave it', () => {
+ const category = new MeasurementCategory('c-1', 'Bizeps', 'cm');
+
+ expect(categoryDisplayName(category, t)).toBe('Bizeps');
+ });
+ });
});
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 990fd3840..07418cd3a 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -1,4 +1,5 @@
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { TFunction } from "i18next";
import { Adapter } from "@/core/lib/Adapter";
import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
@@ -28,6 +29,23 @@ export type MetricType = typeof METRIC_TYPES[number];
/** Server-side MetricType value marking a category as holding body weight data */
export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight';
+/**
+ * Name to show the user for a category.
+ *
+ * A typed category is created by the server or by the health importer and
+ * carries an English name ("Systolic", "Deep sleep"), while its metric type
+ * already has a translated label. Only a free-form category holds a name the
+ * user picked themselves.
+ */
+export function categoryDisplayName(
+ category: { name: string, metricType: MetricType },
+ t: TFunction,
+): string {
+ return category.metricType === 'custom'
+ ? category.name
+ : t(`measurements.metricTypes.${category.metricType}`);
+}
+
/** Narrows a server value to a known metric type, unknown values fall back to 'custom' */
export function metricTypeFromApi(value: unknown): MetricType {
return METRIC_TYPES.includes(value as MetricType) ? value as MetricType : 'custom';
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index b3e7418df..4908542ba 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -1,7 +1,11 @@
import { Stack, Typography } from "@mui/material";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
-import { correlatesWithNutrition, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
+import {
+ categoryDisplayName,
+ correlatesWithNutrition,
+ METRIC_TYPE_BODY_WEIGHT
+} from "@/components/Measurements/models/Category";
import { useMeasurementsQuery } from "@/components/Measurements/queries";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
@@ -33,7 +37,7 @@ export const MeasurementCategoryDetail = () => {
correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
);
// eslint-disable-next-line react-hooks/rules-of-hooks
- const [, i18n] = useTranslation();
+ const [t, i18n] = useTranslation();
if (categoryQuery.isLoading) {
return ;
@@ -47,7 +51,7 @@ export const MeasurementCategoryDetail = () => {
}
return {
{categoryQuery.data!.isGroup
? categoryQuery.data!.children.map(child =>
- {child.name}
+ {categoryDisplayName(child, t)}
)
: }
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index 8718d737b..a3673b276 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -5,7 +5,7 @@ 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 { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
@@ -28,7 +28,7 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
return <>
-
+
diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
index 5792f712d..3ff72f99b 100644
--- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
@@ -2,7 +2,7 @@ import MenuIcon from '@mui/icons-material/Menu';
import { Button, Menu, MenuItem } from "@mui/material";
import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
import { useDeleteMeasurementCategoryQuery } from "@/components/Measurements/queries";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
import React from "react";
@@ -74,7 +74,7 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory })
{
+ const [t] = useTranslation();
// The list is kept locally so a drop is reflected immediately, the new
// order is persisted per drop like in the flutter app
@@ -46,7 +48,9 @@ export const CategoryReorderList = (props: { categories: MeasurementCategory[] }
-
+
)}
diff --git a/src/components/Measurements/widgets/EntryForm.test.tsx b/src/components/Measurements/widgets/EntryForm.test.tsx
index 47cb8b9d1..29474c5b9 100644
--- a/src/components/Measurements/widgets/EntryForm.test.tsx
+++ b/src/components/Measurements/widgets/EntryForm.test.tsx
@@ -152,10 +152,10 @@ describe("Test the GroupEntryForm component", () => {
const queryClient = new QueryClient();
let mutate = vi.fn();
- const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', undefined, 'blood_pressure');
group.children = [
- new MeasurementCategory('c-sys', 'Systolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1'),
- new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1'),
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', undefined, 'blood_pressure_systolic', false, 'g-1'),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', undefined, 'blood_pressure_diastolic', false, 'g-1'),
];
beforeEach(() => {
@@ -172,8 +172,8 @@ describe("Test the GroupEntryForm component", () => {
);
- expect(screen.getByLabelText('Systolic (mmHg)')).toBeInTheDocument();
- expect(screen.getByLabelText('Diastolic (mmHg)')).toBeInTheDocument();
+ expect(screen.getByLabelText('measurements.metricTypes.blood_pressure_systolic (mmHg)')).toBeInTheDocument();
+ expect(screen.getByLabelText('measurements.metricTypes.blood_pressure_diastolic (mmHg)')).toBeInTheDocument();
});
test('submits one entry per child with a shared date', async () => {
@@ -184,8 +184,8 @@ describe("Test the GroupEntryForm component", () => {
);
- await user.type(screen.getByLabelText('Systolic (mmHg)'), '120');
- await user.type(screen.getByLabelText('Diastolic (mmHg)'), '80');
+ await user.type(screen.getByLabelText('measurements.metricTypes.blood_pressure_systolic (mmHg)'), '120');
+ await user.type(screen.getByLabelText('measurements.metricTypes.blood_pressure_diastolic (mmHg)'), '80');
await user.click(screen.getByRole('button', { name: 'submit' }));
expect(mutate).toHaveBeenCalledTimes(1);
@@ -202,7 +202,7 @@ describe("Test the GroupEntryForm component", () => {
);
- await user.type(screen.getByLabelText('Systolic (mmHg)'), '120');
+ await user.type(screen.getByLabelText('measurements.metricTypes.blood_pressure_systolic (mmHg)'), '120');
await user.click(screen.getByRole('button', { name: 'submit' }));
expect(mutate).not.toHaveBeenCalled();
diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx
index 3aa965cda..dddd59acc 100644
--- a/src/components/Measurements/widgets/EntryForm.tsx
+++ b/src/components/Measurements/widgets/EntryForm.tsx
@@ -2,7 +2,11 @@ import { Button, Stack, TextField } from "@mui/material";
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category";
+import {
+ categoryDisplayName,
+ limitsFor,
+ MeasurementCategory
+} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
useAddGroupEntriesQuery,
@@ -209,7 +213,7 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
fullWidth
id={`values.${child.id}`}
type={"number"}
- label={`${child.name} (${child.unit || group.unit})`}
+ label={`${categoryDisplayName(child, t)} (${child.unit || group.unit})`}
error={
Boolean(formik.touched.values?.[child.id!])
&& Boolean(formik.errors.values?.[child.id!])
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index d8379bc54..d73b6bc89 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,5 +1,9 @@
import { Box, Paper } from "@mui/material";
-import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
+import {
+ categoryDisplayName,
+ isSummedPerDay,
+ MeasurementCategory
+} from "@/components/Measurements/models/Category";
import {
aggregatePerDay,
chartPointsFor,
@@ -270,10 +274,12 @@ export const MeasurementChart = (props: {
range?: ChartRange,
planPeriods?: PlanPeriod[],
}) => {
+ const [t] = useTranslation();
const cutoff = cutoffFor(props.range ?? DEFAULT_CHART_RANGE);
if (props.category.isGroup) {
- const chart = groupChart(props.category, cutoff);
+ // The components are labelled by their metric type, like everywhere else
+ const chart = groupChart(props.category, cutoff, c => categoryDisplayName(c, t));
switch (chart.kind) {
case 'stacked':
From 1198a6b712be1680abeafd4f80d8d6ebbf1c2870 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Mon, 3 Aug 2026 00:04:57 +0200
Subject: [PATCH 38/71] Let the user pick the chart of a measurement category
---
public/locales/de/translation.json | 7 +
public/locales/en/translation.json | 7 +
public/locales/es/translation.json | 7 +
public/locales/fr/translation.json | 7 +
.../Measurements/api/measurements.test.ts | 10 +-
.../Measurements/charts/data.test.ts | 106 +++++++++++
src/components/Measurements/charts/data.ts | 143 +++++++++++++++
.../Measurements/models/Category.test.ts | 71 +++++++-
.../Measurements/models/Category.ts | 71 +++++++-
.../Measurements/widgets/CategoryForm.tsx | 42 +++++
.../widgets/MeasurementChart.test.tsx | 26 ++-
.../Measurements/widgets/MeasurementChart.tsx | 169 +++++++++++++++++-
12 files changed, 659 insertions(+), 7 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index f192bf93c..ccedac278 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -256,6 +256,13 @@
"unitFormHelpText": "Die Einheit, in der die Kategorie gemessen wird, wie cm oder %",
"measurements": "Messungen",
"metricType": "Metrik-Typ",
+ "chartType": "Diagrammtyp",
+ "chartTypes": {
+ "auto": "Automatisch",
+ "line": "Linie",
+ "bar": "Balken",
+ "heatmap": "Heatmap"
+ },
"partOfGroup": "Teil der Gruppe",
"noGroup": "Keine Gruppe",
"metricTypes": {
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index f3d4038cc..e41ec17ff 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -340,6 +340,13 @@
"deleteInfo": "This will delete the category as well as all its entries",
"deleteInfoGroup": "This will delete the group as well as all its components and their entries",
"metricType": "Metric Type",
+ "chartType": "Chart type",
+ "chartTypes": {
+ "auto": "Automatic",
+ "line": "Line",
+ "bar": "Bars",
+ "heatmap": "Heatmap"
+ },
"partOfGroup": "Part of group",
"noGroup": "No group",
"metricTypes": {
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 5f5e7ae0c..acf185bbf 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -258,6 +258,13 @@
"deleteInfo": "Esto eliminará la categoría así como todas sus entradas",
"deleteInfoGroup": "Esto eliminará el grupo así como todos sus componentes y sus entradas",
"metricType": "Tipo de métrica",
+ "chartType": "Tipo de gráfico",
+ "chartTypes": {
+ "auto": "Automático",
+ "line": "Línea",
+ "bar": "Barras",
+ "heatmap": "Mapa de calor"
+ },
"partOfGroup": "Parte del grupo",
"noGroup": "Sin grupo",
"metricTypes": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index c30961bd3..b6e43a2ee 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -341,6 +341,13 @@
"deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées",
"deleteInfoGroup": "Ceci supprimera le groupe ainsi que tous ses composants et leurs entrées",
"metricType": "Type de métrique",
+ "chartType": "Type de graphique",
+ "chartTypes": {
+ "auto": "Automatique",
+ "line": "Ligne",
+ "bar": "Barres",
+ "heatmap": "Carte thermique"
+ },
"partOfGroup": "Fait partie du groupe",
"noGroup": "Aucun groupe",
"metricTypes": {
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index d1e5e55d6..9f805a69d 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -242,7 +242,14 @@ describe('measurement service tests', () => {
const [url, body] = (axios.post as Mock).mock.calls[0];
expect(url).toMatch(/\/api\/v2\/measurement-category\/$/);
- expect(body).toEqual({ name: "Body fat", unit: "%", metric_type: "custom", parent: null, order: 0 });
+ expect(body).toEqual({
+ name: "Body fat",
+ unit: "%",
+ metric_type: "custom",
+ chart_type: null,
+ parent: null,
+ order: 0
+ });
expect(result).toBeInstanceOf(MeasurementCategory);
expect(result.id).toBe(CATEGORY_UUID_2);
});
@@ -263,6 +270,7 @@ describe('measurement service tests', () => {
name: "Renamed",
unit: "%",
metric_type: "custom",
+ chart_type: null,
parent: null,
order: 0
});
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 8e57d93a0..85e571caf 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -2,9 +2,13 @@ import { MeasurementCategory, MetricType } from "@/components/Measurements/model
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
aggregatePerDay,
+ averagePerDay,
+ buildHeatmapGrid,
chartPointsFor,
downsample,
fillMissingDays,
+ heatmapDayAt,
+ HEATMAP_MAX_WEEKS,
groupChart,
groupComponentSeries,
groupRangeEntries,
@@ -215,6 +219,108 @@ describe('aggregatePerDay', () => {
});
});
+describe('averagePerDay', () => {
+ test('returns an empty array for no points', () => {
+ expect(averagePerDay([])).toEqual([]);
+ });
+
+ test('averages all samples of the same calendar day', () => {
+ const result = averagePerDay([point(day(1, 8), 80), point(day(1, 18), 82)]);
+
+ expect(result).toEqual([{ date: day(1).getTime(), value: 81 }]);
+ });
+
+ test('sorts the buckets chronologically', () => {
+ const result = averagePerDay([point(day(3), 30), point(day(1), 10), point(day(2), 20)]);
+
+ expect(result.map(r => r.value)).toEqual([10, 20, 30]);
+ });
+});
+
+describe('buildHeatmapGrid', () => {
+ // 2 March 2026 is a Monday, 18 March a Wednesday
+ const monday = new Date(2026, 2, 2);
+ const wednesday = new Date(2026, 2, 18);
+ const at = (date: Date, value: number): ChartPoint => ({ date: date.getTime(), value: value });
+
+ test('starts on the Monday of the oldest week and ends with today', () => {
+ const grid = buildHeatmapGrid([at(monday, 10), at(wednesday, 20)], HEATMAP_MAX_WEEKS, wednesday);
+
+ expect(grid.start).toEqual(monday.getTime());
+ expect(grid.weeks).toEqual(3);
+ expect(heatmapDayAt(grid, 2, 2)).toEqual(wednesday.getTime());
+ });
+
+ test('leaves days without a measurement empty rather than zero', () => {
+ const grid = buildHeatmapGrid([at(monday, 10)], HEATMAP_MAX_WEEKS, monday);
+
+ expect(grid.values.get(heatmapDayAt(grid, 0, 0))).toEqual(10);
+ expect(grid.values.get(heatmapDayAt(grid, 0, 1))).toBeUndefined();
+ });
+
+ test('runs up to today, so a stretch without measurements stays visible', () => {
+ const grid = buildHeatmapGrid([at(monday, 10)], HEATMAP_MAX_WEEKS, wednesday);
+
+ expect(grid.weeks).toEqual(3);
+ expect(grid.values.get(heatmapDayAt(grid, 2, 2))).toBeUndefined();
+ });
+
+ test('caps a long history at a year of week columns', () => {
+ const grid = buildHeatmapGrid(
+ [at(new Date(2020, 0, 1), 10), at(wednesday, 20)],
+ HEATMAP_MAX_WEEKS,
+ wednesday,
+ );
+
+ expect(grid.weeks).toEqual(HEATMAP_MAX_WEEKS);
+ expect(heatmapDayAt(grid, grid.weeks - 1, 2)).toEqual(wednesday.getTime());
+ });
+
+ test('anchors on the last measurement when the history ended long ago', () => {
+ // Anchoring on today would put the whole history outside the grid and
+ // draw an empty one
+ const grid = buildHeatmapGrid(
+ [at(monday, 10), at(wednesday, 20)],
+ HEATMAP_MAX_WEEKS,
+ new Date(2028, 0, 1),
+ );
+
+ expect(heatmapDayAt(grid, grid.weeks - 1, 2)).toEqual(wednesday.getTime());
+ expect(grid.values.get(wednesday.getTime())).toEqual(20);
+ });
+
+ test('takes the top of the colour scale only from the days it shows', () => {
+ // A spike outside the window would scale the colours of every visible
+ // cell without being visible itself, washing out the whole grid
+ const grid = buildHeatmapGrid(
+ [at(new Date(2024, 0, 3), 45000), at(wednesday, 8000)],
+ HEATMAP_MAX_WEEKS,
+ wednesday,
+ );
+
+ expect(grid.maxValue).toEqual(8000);
+ expect(grid.values.has(new Date(2024, 0, 3).getTime())).toBe(false);
+ });
+
+ test('takes the top of the colour scale from the largest value', () => {
+ const grid = buildHeatmapGrid(
+ [at(monday, 10), at(wednesday, 8000)],
+ HEATMAP_MAX_WEEKS,
+ wednesday,
+ );
+
+ expect(grid.maxValue).toEqual(8000);
+ });
+
+ test('is a full grid of the last year when there is nothing to show', () => {
+ const grid = buildHeatmapGrid([]);
+
+ expect(grid.weeks).toEqual(HEATMAP_MAX_WEEKS);
+ expect(grid.maxValue).toEqual(0);
+ expect(grid.values.size).toEqual(0);
+ });
+});
+
describe('fillMissingDays', () => {
test('returns an empty array for no data', () => {
expect(fillMissingDays([])).toEqual([]);
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 9f3172ec7..377a63ae1 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -184,6 +184,149 @@ export const aggregatePerDay = (points: ChartPoint[]): ChartPoint[] => {
.sort((a, b) => a.date - b.date);
};
+/**
+ * Averages points per local calendar day.
+ *
+ * The per-day counterpart of aggregatePerDay for the sample metrics (body
+ * weight, heart rate), where a day's readings are repeated measurements of the
+ * same thing and adding them up would be meaningless. Used by the charts that
+ * need exactly one value per day.
+ */
+export const averagePerDay = (points: ChartPoint[]): ChartPoint[] => {
+ const byDay = new Map();
+ for (const point of points) {
+ const date = new Date(point.date);
+ const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
+ const values = byDay.get(day);
+ if (values === undefined) {
+ byDay.set(day, [point.value]);
+ } else {
+ values.push(point.value);
+ }
+ }
+
+ return [...byDay.entries()]
+ .map(([date, values]) => ({
+ date: date,
+ value: values.reduce((sum, value) => sum + value, 0) / values.length,
+ }))
+ .sort((a, b) => a.date - b.date);
+};
+
+/** Days of the week, the grid of a heatmap has one row per weekday */
+export const DAYS_PER_WEEK = 7;
+
+/**
+ * Widest a heatmap gets, in week columns.
+ *
+ * A year is where the grid stops being readable: 53 columns already put the
+ * cells at a few pixels each, and a history of several years would be a wall
+ * rather than a chart. The range selector above the chart can go further
+ * (all-time), so the heatmap caps itself here; the month labels along the top
+ * say which span is actually drawn.
+ */
+export const HEATMAP_MAX_WEEKS = 53;
+
+/**
+ * A calendar heatmap laid out as a grid of week columns and weekday rows, with
+ * the values it draws.
+ *
+ * Days are addressed by their position in the grid, so nothing downstream has
+ * to do calendar arithmetic: column 0 row 0 is the start, which is always a
+ * Monday.
+ */
+export interface HeatmapGrid {
+ /** Monday of the first (oldest) week column */
+ start: number;
+ /** Number of week columns */
+ weeks: number;
+ /**
+ * Value of each day the grid shows that has one, keyed by local midnight of
+ * that day. Days outside the window are not part of this chart and are left
+ * out, see buildHeatmapGrid.
+ */
+ values: Map;
+ /**
+ * Largest value in the grid, the top of the colour scale. Zero for an empty
+ * grid, and for a history that holds nothing but zeroes.
+ */
+ maxValue: number;
+}
+
+// Days are shifted through the Date constructor rather than by adding
+// milliseconds: a day is not always 24 hours long, and an hour lost to a time
+// change would put the date in the neighbouring cell
+const dayOf = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate());
+const shiftDays = (date: Date, days: number): Date =>
+ new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
+// getDay() counts from Sunday, the week starts on Monday
+const mondayOf = (date: Date): Date => shiftDays(date, -((date.getDay() + 6) % 7));
+const daysBetween = (from: Date, to: Date): number => Math.round(
+ (Date.UTC(to.getFullYear(), to.getMonth(), to.getDate())
+ - Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) / DAY_MS
+);
+
+/** The day in column week, row weekday (0 = Monday), as a local-midnight timestamp */
+export const heatmapDayAt = (grid: HeatmapGrid, week: number, weekday: number): number =>
+ shiftDays(new Date(grid.start), week * DAYS_PER_WEEK + weekday).getTime();
+
+/**
+ * Lays per-day points out as a calendar grid, newest week last.
+ *
+ * Expects one point per calendar day (see aggregatePerDay and averagePerDay).
+ * The grid ends with the current week, so a stretch without measurements at the
+ * end stays visible as empty cells; only a history that ended longer ago than
+ * the grid is wide is anchored at its own last day instead, since an empty grid
+ * shows nothing at all.
+ */
+export const buildHeatmapGrid = (
+ days: ChartPoint[],
+ maxWeeks: number = HEATMAP_MAX_WEEKS,
+ today: Date = new Date(),
+): HeatmapGrid => {
+ const values = new Map(days.map(point => [dayOf(new Date(point.date)).getTime(), point.value]));
+ const now = dayOf(today);
+ const window = DAYS_PER_WEEK * (maxWeeks - 1);
+
+ if (values.size === 0) {
+ return {
+ start: shiftDays(mondayOf(now), -window).getTime(),
+ weeks: maxWeeks,
+ values: values,
+ maxValue: 0,
+ };
+ }
+
+ const timestamps = [...values.keys()];
+ const first = new Date(Math.min(...timestamps));
+ const last = new Date(Math.max(...timestamps));
+ const oldestVisible = shiftDays(mondayOf(now), -window);
+ const end = mondayOf(last) < oldestVisible ? last : now;
+
+ const endMonday = mondayOf(end);
+ const weeks = Math.min(
+ maxWeeks,
+ Math.floor(daysBetween(mondayOf(first), endMonday) / DAYS_PER_WEEK) + 1,
+ );
+ const start = shiftDays(endMonday, -DAYS_PER_WEEK * (weeks - 1));
+ const lastDay = shiftDays(start, DAYS_PER_WEEK * weeks - 1).getTime();
+
+ // Only the days the grid actually shows. A history longer than the grid is
+ // wide keeps its older days out of the window, and a spike among them would
+ // otherwise set the top of the colour scale without being visible itself,
+ // washing out every cell that is
+ const visible = new Map(
+ [...values.entries()].filter(([day]) => day >= start.getTime() && day <= lastDay)
+ );
+
+ return {
+ start: start.getTime(),
+ weeks: weeks,
+ values: visible,
+ maxValue: visible.size === 0 ? 0 : Math.max(...visible.values()),
+ };
+};
+
/**
* Fills gaps in a per-day series with zero-value days so a band axis keeps
* the spacing between bars proportional to time
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index a30d56ab7..e0cf28eea 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -1,4 +1,5 @@
import {
+ availableChartTypes,
categoryDisplayName,
isComponentMetricType,
isGroupMetricType,
@@ -6,7 +7,8 @@ import {
limitsFor,
MEASUREMENT_SCHEMA_MAX_VALUE,
MeasurementCategory,
- metricTypeFromApi
+ metricTypeFromApi,
+ resolveChartType
} from "./Category";
describe('MeasurementCategory', () => {
@@ -45,6 +47,7 @@ describe('MeasurementCategory', () => {
unit: 'steps',
metric_type: 'steps',
+ chart_type: null,
parent: null,
order: 2,
});
@@ -107,6 +110,72 @@ describe('MeasurementCategory', () => {
expect(isGroupMetricType('heart_rate')).toBe(false);
});
+ describe('chart type', () => {
+
+ test('fromJson reads the null the server sends as no override', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1',
+ name: 'Steps',
+ unit: 'steps',
+ metric_type: 'steps',
+ chart_type: null,
+ });
+
+ expect(category.chartType).toBe('auto');
+ });
+
+ test('fromJson falls back to auto for a type this release does not know', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1', name: 'Steps', unit: 'steps', chart_type: 'sunburst',
+ });
+
+ expect(category.chartType).toBe('auto');
+ });
+
+ test('toJson sends no override as null', () => {
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps');
+
+ expect(category.toJson().chart_type).toBeNull();
+ });
+
+ test('toJson sends the picked type', () => {
+ const category = new MeasurementCategory(
+ 'c-1', 'Steps', 'steps', undefined, 'steps', false, null, 0, 'heatmap',
+ );
+
+ expect(category.toJson().chart_type).toBe('heatmap');
+ });
+
+ test('clone carries the chart type over and can override it', () => {
+ const category = new MeasurementCategory(
+ 'c-1', 'Steps', 'steps', undefined, 'steps', false, null, 0, 'heatmap',
+ );
+
+ expect(MeasurementCategory.clone(category).chartType).toBe('heatmap');
+ expect(MeasurementCategory.clone(category, { chartType: 'auto' }).chartType)
+ .toBe('auto');
+ });
+
+ test('the offered types follow the metric type', () => {
+ expect(availableChartTypes('steps')).toEqual(['bar', 'heatmap']);
+ expect(availableChartTypes('custom')).toEqual(['line', 'heatmap']);
+
+ // a group is drawn by what its components are to each other
+ expect(availableChartTypes('blood_pressure')).toEqual([]);
+ });
+
+ test('a type that does not fit falls back to the derived chart', () => {
+ expect(resolveChartType('custom', 'bar')).toBe('line');
+ expect(resolveChartType('steps', 'line')).toBe('bar');
+ expect(resolveChartType('custom', 'auto')).toBe('line');
+ });
+
+ test('a type that fits is kept', () => {
+ expect(resolveChartType('custom', 'heatmap')).toBe('heatmap');
+ expect(resolveChartType('steps', 'bar')).toBe('bar');
+ });
+ });
+
describe('categoryDisplayName', () => {
// the tests' t() returns the key it is given
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 07418cd3a..40442bad0 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -29,6 +29,28 @@ export type MetricType = typeof METRIC_TYPES[number];
/** Server-side MetricType value marking a category as holding body weight data */
export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight';
+/**
+ * The chart a category is drawn as.
+ *
+ * The values mirror the Django ChartType choices, where the override is a
+ * nullable column: 'auto' is that null, i.e. "derive the chart from the metric
+ * type", which is what every category does unless the user picked something
+ * else. Only the shapes that are a matter of taste are offered; a floating bar
+ * (two components) and a stacked bar (a summed group) follow from what the
+ * group is and are not choices.
+ */
+export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap'] as const;
+export type ChartType = typeof CHART_TYPES[number];
+
+/**
+ * Narrows a server value to a known chart type. Null is the server's "no
+ * override"; an unrecognised value is one added after this release, and
+ * falling back to 'auto' is what keeps such a category readable here.
+ */
+export function chartTypeFromApi(value: unknown): ChartType {
+ return CHART_TYPES.includes(value as ChartType) ? value as ChartType : 'auto';
+}
+
/**
* Name to show the user for a category.
*
@@ -67,6 +89,45 @@ export function isSummedPerDay(type: MetricType): boolean {
|| type === 'sleep_awake';
}
+/**
+ * The chart a category of this metric type is drawn as when the user picked
+ * none.
+ *
+ * Summed types are one value per day and are drawn as that day's bar,
+ * everything else is a series of samples and gets the line chart. A group has
+ * no default here: its chart follows from what its components are to each
+ * other, see groupChart.
+ */
+export function defaultChartType(type: MetricType): ChartType {
+ return isSummedPerDay(type) ? 'bar' : 'line';
+}
+
+/**
+ * The chart types a category of this metric type may be drawn as, i.e. what
+ * the picker offers on top of 'auto'.
+ *
+ * The heatmap is the one alternative that fits every leaf type: it answers how
+ * regularly rather than how much, and it is the only chart of the set where a
+ * missing day is visible instead of being spanned by a line. A group is left
+ * out, its chart is structural rather than a preference.
+ */
+export function availableChartTypes(type: MetricType): ChartType[] {
+ return isGroupMetricType(type) ? [] : [defaultChartType(type), 'heatmap'];
+}
+
+/**
+ * The chart a category of this metric type is drawn as, given what the user
+ * picked.
+ *
+ * A pick that does not fit the type falls back to the derived default instead
+ * of being refused: the server stores the string without judging it, so this is
+ * also what keeps a category configured on another client from showing nothing
+ * here.
+ */
+export function resolveChartType(type: MetricType, picked: ChartType): ChartType {
+ return availableChartTypes(type).includes(picked) ? picked : defaultChartType(type);
+}
+
/**
* Metric types whose charts show nutrition plan periods for context. Custom
* categories are typically hand-kept body measurements (waist, biceps), so
@@ -212,6 +273,8 @@ export class MeasurementCategory {
public isOfficial: boolean = false,
public parentId: string | null = null,
public order: number = 0,
+ /** Chart the user picked, 'auto' (the server's null) for the derived one */
+ public chartType: ChartType = 'auto',
) {
if (entries) {
this.entries = entries;
@@ -222,7 +285,7 @@ export class MeasurementCategory {
return this.children.length > 0;
}
- static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory {
+ static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory {
const category = new MeasurementCategory(
overrides?.id ?? other.id,
overrides?.name ?? other.name,
@@ -234,6 +297,7 @@ export class MeasurementCategory {
// usual ?? fallback doesn't work
overrides !== undefined && 'parentId' in overrides ? overrides.parentId ?? null : other.parentId,
other.order,
+ overrides?.chartType ?? other.chartType,
);
category.children = other.children;
return category;
@@ -262,6 +326,7 @@ class MeasurementCategoryAdapter implements Adapter {
item.is_official,
item.parent ?? null,
item.order ?? 0,
+ chartTypeFromApi(item.chart_type),
);
}
@@ -272,6 +337,10 @@ class MeasurementCategoryAdapter implements Adapter {
unit: item.unit,
// eslint-disable-next-line camelcase
metric_type: item.metricType,
+ // The column is nullable, and null is what makes the server derive
+ // the chart from the metric type
+ // eslint-disable-next-line camelcase
+ chart_type: item.chartType === 'auto' ? null : item.chartType,
parent: item.parentId,
order: item.order,
};
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index 7b058aab5..f88f0b1ff 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -1,4 +1,6 @@
import {
+ availableChartTypes,
+ ChartType,
isComponentMetricType,
isGroupMetricType,
isOfficialMetricType,
@@ -22,6 +24,10 @@ interface CategoryFormProps {
closeFn?: () => void,
}
+/** What the chart type picker offers: no override, plus what the type allows */
+const chartTypeChoices = (metricType: MetricType): ChartType[] =>
+ ['auto', ...availableChartTypes(metricType)];
+
export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
const [t] = useTranslation();
@@ -69,6 +75,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
name: category ? category.name : "",
unit: category ? category.unit : "",
metricType: category ? category.metricType : 'custom' as MetricType,
+ chartType: category ? category.chartType : 'auto' as ChartType,
// the empty string stands in for "no group", MUI selects
// don't accept null values
parentId: category?.parentId ?? "",
@@ -83,6 +90,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
name: values.name,
unit: values.unit,
metricType: values.metricType,
+ chartType: values.chartType,
parentId: parentId,
}));
} else {
@@ -94,6 +102,8 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
values.metricType,
false,
parentId,
+ 0,
+ values.chartType,
));
}
@@ -134,6 +144,17 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
label={t('measurements.metricType')}
disabled={category?.isOfficial}
{...formik.getFieldProps('metricType')}
+ onChange={event => {
+ const metricType = event.target.value as MetricType;
+ formik.setFieldValue('metricType', metricType);
+ // Bars are no choice for a sample type and a
+ // line is none for a summed one, so a pick that
+ // the new type cannot be drawn as goes back to
+ // being derived
+ if (!chartTypeChoices(metricType).includes(formik.values.chartType)) {
+ formik.setFieldValue('chartType', 'auto');
+ }
+ }}
>
{metricTypeChoices.map(metricType =>
@@ -141,6 +162,27 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
)}
+ {/*
+ * Only the shapes that are a matter of taste are
+ * offered, and only those the metric type can be drawn
+ * as; a group gets no picker, its chart follows from
+ * what its components are
+ */}
+ {availableChartTypes(formik.values.metricType).length > 0 &&
+
+ {chartTypeChoices(formik.values.metricType).map(chartType =>
+
+ {t(`measurements.chartTypes.${chartType}`)}
+
+ )}
+
+ }
{!hasChildren && formik.values.metricType === 'custom'
&& parentCandidates.length > 0 &&
{
render( );
});
+ test('draws a heatmap when the category asks for one', () => {
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', [
+ entry('d-1', new Date(2023, 1, 1), 4000),
+ ], 'steps', false, null, 0, 'heatmap');
+
+ render( );
+
+ // Unlike the recharts charts, the grid is plain elements and does
+ // render in jsdom
+ expect(screen.getByRole('img')).toBeInTheDocument();
+ });
+
+ test('keeps the derived chart when the pick does not fit the metric type', () => {
+ // Bars are not offered for a sample type, and a pick that does not fit
+ // falls back to the derived chart instead of being drawn anyway
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm', [
+ entry('d-1', new Date(2023, 1, 1), 30),
+ ], 'custom', false, null, 0, 'bar');
+
+ render( );
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
test('mounts a stacked chart for a sleep group', () => {
const group = new MeasurementCategory('g-s', 'Sleep', 'min', [], 'sleep');
const stage = (id: string, name: string, type: MetricType, value: number) => {
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index d73b6bc89..c85ad767b 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,14 +1,19 @@
-import { Box, Paper } from "@mui/material";
+import { alpha, Box, Paper, Typography } from "@mui/material";
import {
categoryDisplayName,
isSummedPerDay,
- MeasurementCategory
+ MeasurementCategory,
+ resolveChartType
} from "@/components/Measurements/models/Category";
import {
aggregatePerDay,
+ averagePerDay,
+ buildHeatmapGrid,
chartPointsFor,
+ DAYS_PER_WEEK,
fillMissingDays,
groupChart,
+ heatmapDayAt,
measurementSeries,
StackedPoint
} from "@/components/Measurements/charts/data";
@@ -248,6 +253,144 @@ const MeasurementStackedBarChart = (props: {
;
};
+/** 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: {
category: MeasurementCategory,
cutoff: Date | null,
@@ -294,7 +437,27 @@ export const MeasurementChart = (props: {
}
}
- return isSummedPerDay(props.category.metricType)
+ const summed = isSummedPerDay(props.category.metricType);
+
+ // A pick that does not fit the metric type falls back to the derived chart,
+ // which is also what a category configured on another client gets here
+ if (resolveChartType(props.category.metricType, props.category.chartType) === 'heatmap') {
+ // The cells are days, so how a day's readings become one value has to
+ // be decided here: the summed types are a daily total, the sample types
+ // are repeated readings of the same thing and average. The points are
+ // deliberately not condensed on the way, which for a grid of days would
+ // collapse whole weeks into a single cell
+ const points = pointsSince(
+ chartPointsFor(props.category.entries, props.category.unit, props.category.unit),
+ cutoff,
+ );
+
+ return ;
+ }
+
+ return summed
?
:
Date: Mon, 3 Aug 2026 00:11:59 +0200
Subject: [PATCH 39/71] Do not offer a chart type where it has no effect
---
.../widgets/CategoryForm.test.tsx | 42 +++++++++++++++++++
.../Measurements/widgets/CategoryForm.tsx | 8 ++--
2 files changed, 47 insertions(+), 3 deletions(-)
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 6b771ada9..4350ede2a 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -231,4 +231,46 @@ describe("Test the CategoryForm component", () => {
// Assert
expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull();
});
+
+ test('A category with children gets no chart type picker', () => {
+
+ // Arrange: its chart follows from what its components are to each
+ // other, which is what groupChart decides; a pick would have no effect
+ const child = new MeasurementCategory(
+ 'cccccccc-cccc-cccc-cccc-000000000044',
+ 'Systolic',
+ 'mmHg',
+ undefined,
+ 'blood_pressure',
+ false,
+ TEST_GROUP_CATEGORY.id,
+ );
+ (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
+ data: [TEST_GROUP_CATEGORY, child]
+ }));
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.queryByRole('combobox', { name: 'measurements.chartType' })).toBeNull();
+ });
+
+ test('A leaf category gets the chart type picker', () => {
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.getByRole('combobox', { name: 'measurements.chartType' }))
+ .toBeInTheDocument();
+ });
});
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index f88f0b1ff..2d4f1b576 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -165,10 +165,12 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
{/*
* Only the shapes that are a matter of taste are
* offered, and only those the metric type can be drawn
- * as; a group gets no picker, its chart follows from
- * what its components are
+ * as. A group gets no picker at all, its chart follows
+ * from what its components are to each other; a
+ * category with children is one whatever its metric
+ * type says, which is also how the charts decide
*/}
- {availableChartTypes(formik.values.metricType).length > 0 &&
+ {!hasChildren && availableChartTypes(formik.values.metricType).length > 0 &&
Date: Mon, 3 Aug 2026 10:16:21 +0200
Subject: [PATCH 40/71] Do not offer a chart type where it has no effect
---
.../widgets/CategoryForm.test.tsx | 26 +++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 4350ede2a..2140e3ae5 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -260,6 +260,32 @@ describe("Test the CategoryForm component", () => {
expect(screen.queryByRole('combobox', { name: 'measurements.chartType' })).toBeNull();
});
+ test('Changing the metric type drops a chart type it cannot use', async () => {
+
+ // Arrange
+ const user = userEvent.setup();
+ const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1, {
+ chartType: 'line',
+ });
+
+ // Act
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('combobox', { name: 'measurements.metricType' }));
+ await user.click(screen.getByRole('option', { name: 'measurements.metricTypes.steps' }));
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert: steps are drawn as bars or as a heatmap, never as a line, so
+ // the picker falls back to automatic and that is what has to be saved
+ expect(mutate).toHaveBeenCalledWith(expect.objectContaining({
+ metricType: 'steps',
+ chartType: 'auto',
+ }));
+ });
+
test('A leaf category gets the chart type picker', () => {
// Act
From 5309aa13101fe9b4961cd9c8b3cca25eae95bb57 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Mon, 3 Aug 2026 11:44:18 +0200
Subject: [PATCH 41/71] Show the user when a measurement write is refused
---
src/components/Measurements/charts/data.ts | 4 +-
.../widgets/CategoryDetailDataGrid.test.tsx | 57 ++++++++++++++++---
.../widgets/CategoryDetailDataGrid.tsx | 16 +++++-
.../widgets/CategoryForm.test.tsx | 35 ++++++++++--
.../Measurements/widgets/CategoryForm.tsx | 16 +++---
.../Measurements/widgets/EntryForm.test.tsx | 4 +-
.../Measurements/widgets/EntryForm.tsx | 40 ++++++-------
.../Measurements/widgets/WeightForm.tsx | 13 +++--
8 files changed, 131 insertions(+), 54 deletions(-)
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 377a63ae1..130b8eef2 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -253,9 +253,7 @@ export interface HeatmapGrid {
maxValue: number;
}
-// Days are shifted through the Date constructor rather than by adding
-// milliseconds: a day is not always 24 hours long, and an hour lost to a time
-// change would put the date in the neighbouring cell
+// Calendar arithmetic, not milliseconds: a DST day is 23 or 25 hours long
const dayOf = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate());
const shiftDays = (date: Date, days: number): Date =>
new Date(date.getFullYear(), date.getMonth(), date.getDate() + days);
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 4c1609285..62c272f85 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -1,13 +1,13 @@
-import { QueryClientProvider } from "@tanstack/react-query";
-import { render, screen, within } from '@testing-library/react';
-import userEvent from "@testing-library/user-event";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
-import React from 'react';
import { testQueryClient } from "@/tests/queryClient";
import { makeWeightEntry, testBodyWeightCategory } from "@/tests/weight/testData";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { render, screen, within } from '@testing-library/react';
+import userEvent from "@testing-library/user-event";
+import React from 'react';
import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/queries");
@@ -19,8 +19,14 @@ const SYNCED_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000002';
describe('CategoryDetailDataGrid', () => {
beforeEach(() => {
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
- (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({
+ mutate: vi.fn(),
+ mutateAsync: vi.fn().mockResolvedValue(undefined)
+ }));
+ (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({
+ mutate: vi.fn(),
+ mutateAsync: vi.fn().mockResolvedValue(undefined)
+ }));
});
test('entries synced from a health app offer no edit or delete actions', async () => {
@@ -92,7 +98,10 @@ describe('CategoryDetailDataGrid', () => {
test('saving a row without editing the value keeps the stored value and unit', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({
+ mutate: mutateEditMock,
+ mutateAsync: mutateEditMock
+ }));
// stored as 90 lb, displayed as 40.82 kg
renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]);
@@ -110,7 +119,10 @@ describe('CategoryDetailDataGrid', () => {
test('editing the value cell stamps the display unit', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({
+ mutate: mutateEditMock,
+ mutateAsync: mutateEditMock
+ }));
renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]);
await screen.findByText(/40[.,]82/);
@@ -132,7 +144,10 @@ describe('CategoryDetailDataGrid', () => {
test('implausible inline edits are rejected and the row stays editable', async () => {
const user = userEvent.setup();
const mutateEditMock = vi.fn();
- (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({
+ mutate: mutateEditMock,
+ mutateAsync: mutateEditMock
+ }));
renderGrid([makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' })]);
await screen.findByText('80 kg');
@@ -156,5 +171,29 @@ describe('CategoryDetailDataGrid', () => {
const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry;
expect(Number(submitted.value)).toBe(90);
});
+
+ test('an edit the server refuses is shown instead of being kept', async () => {
+ const user = userEvent.setup();
+ const mutateEditMock = vi.fn().mockRejectedValue({
+ response: { data: { value: ['Value must be between 20 and 350'] } },
+ });
+ (useEditMeasurementEntryQuery as Mock).mockImplementation(
+ () => ({ mutate: vi.fn(), mutateAsync: mutateEditMock })
+ );
+ renderGrid([makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' })]);
+
+ await screen.findByText('80 kg');
+ await user.click(screen.getByRole('menuitem', { name: /edit/i }));
+
+ const valueInput = screen.getByRole('spinbutton');
+ await user.clear(valueInput);
+ await user.type(valueInput, '90');
+ await user.click(screen.getByRole('menuitem', { name: /save/i }));
+
+ expect(mutateEditMock).toHaveBeenCalled();
+ expect(
+ await screen.findByText('value: Value must be between 20 and 350')
+ ).toBeInTheDocument();
+ });
});
});
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index 6aef19b04..0b4f374f8 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -1,6 +1,7 @@
import { processTimeSeries } from "@/core/lib/timeSeries";
import { 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 { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries";
import { PAGINATION_OPTIONS } from "@/core/lib/consts";
@@ -91,7 +92,7 @@ export const CategoryDetailDataGrid = (props: {
};
- const processRowUpdate = (newRow: GridRowModel, oldRow: GridRowModel) => {
+ const processRowUpdate = async (newRow: GridRowModel, oldRow: GridRowModel) => {
const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date);
const entry = entries.find(e => e.id === newRow.id);
@@ -103,7 +104,7 @@ export const CategoryDetailDataGrid = (props: {
// that conversion would silently overwrite the entry's stored value and
// unit, so both only change when the value cell was edited
if (Number(newRow.value) === Number(oldRow.value)) {
- updateEntryQuery.mutate(MeasurementEntry.clone(entry, {
+ await updateEntryQuery.mutateAsync(MeasurementEntry.clone(entry, {
date: date,
notes: newRow.notes,
}));
@@ -123,7 +124,7 @@ export const CategoryDetailDataGrid = (props: {
throw new Error(t('forms.maxValue', { value: `${max} ${unit}` }));
}
- updateEntryQuery.mutate(MeasurementEntry.clone(entry, {
+ await updateEntryQuery.mutateAsync(MeasurementEntry.clone(entry, {
date: date,
value: value,
notes: newRow.notes,
@@ -135,7 +136,16 @@ export const CategoryDetailDataGrid = (props: {
return { ...newRow, isNew: false };
};
+ // Both the checks above and a write the server refused end up here, and the
+ // grid puts the row back to what it was
const onProcessRowUpdateError = (error: unknown) => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const response = (error as any)?.response?.data;
+ const validationErrors = collectValidationErrors(response);
+ if (validationErrors.length > 0) {
+ setEditError(validationErrors.join(', '));
+ return;
+ }
setEditError(error instanceof Error ? error.message : String(error));
};
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 2140e3ae5..bb3942342 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -81,7 +81,7 @@ describe("Test the CategoryForm component", () => {
expect(mutate).toHaveBeenCalledWith(MeasurementCategory.clone(
TEST_MEASUREMENT_CATEGORY_2,
{ name: "a better name", unit: 'K/m2' }
- ));
+ ), expect.anything());
});
test('Creating a new category', async () => {
@@ -102,7 +102,7 @@ describe("Test the CategoryForm component", () => {
// Assert
await user.click(submitButton);
- expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm'));
+ expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm'), expect.anything());
});
test('The body weight and component metric types are not offered', async () => {
@@ -151,7 +151,7 @@ describe("Test the CategoryForm component", () => {
'custom',
false,
TEST_GROUP_CATEGORY.id,
- ));
+ ), expect.anything());
});
test('A typed category cannot be put into a group', async () => {
@@ -232,6 +232,33 @@ describe("Test the CategoryForm component", () => {
expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull();
});
+ test('A rejected write keeps the form open and is shown', async () => {
+
+ // Arrange: the mutation reports the failure, as react-query does
+ const user = userEvent.setup();
+ const closeFn = vi.fn();
+ (useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({
+ mutate: mutate,
+ isError: true,
+ error: { message: 'Request failed', response: { data: { name: ['Already exists'] } } },
+ }));
+
+ // Act
+ render(
+
+
+
+ );
+ await user.type(await screen.findByLabelText('name'), 'calves');
+ await user.type(await screen.findByLabelText('unit'), 'cm');
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert: the form only closes from the success callback, which a
+ // failed mutation never runs
+ expect(closeFn).not.toHaveBeenCalled();
+ expect(screen.getByText('name: Already exists')).toBeInTheDocument();
+ });
+
test('A category with children gets no chart type picker', () => {
// Arrange: its chart follows from what its components are to each
@@ -283,7 +310,7 @@ describe("Test the CategoryForm component", () => {
expect(mutate).toHaveBeenCalledWith(expect.objectContaining({
metricType: 'steps',
chartType: 'auto',
- }));
+ }), expect.anything());
});
test('A leaf category gets the chart type picker', () => {
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index 2d4f1b576..1ec9ebd92 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -14,6 +14,7 @@ import {
useMeasurementsCategoryQuery
} from "@/components/Measurements/queries";
import { Button, MenuItem, Stack, TextField } from "@mui/material";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { Form, Formik } from "formik";
import React from 'react';
import { useTranslation } from "react-i18next";
@@ -83,6 +84,9 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
validationSchema={validationSchema}
onSubmit={async (values) => {
const parentId = values.parentId === "" ? null : values.parentId;
+ // The form closes only once the server took the category, so a
+ // rejected write is shown instead of disappearing with it
+ const options = { onSuccess: () => closeFn?.() };
// Edit existing category
if (category) {
@@ -92,7 +96,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
metricType: values.metricType,
chartType: values.chartType,
parentId: parentId,
- }));
+ }), options);
} else {
useAddCategoryQuery.mutate(new MeasurementCategory(
null,
@@ -104,13 +108,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
parentId,
0,
values.chartType,
- ));
- }
-
- // if closeFn is defined, close the modal (this form does not have to
- // be displayed in a modal)
- if (closeFn) {
- closeFn();
+ ), options);
}
}}
>
@@ -202,6 +200,8 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
)}
}
+
{t('submit')}
diff --git a/src/components/Measurements/widgets/EntryForm.test.tsx b/src/components/Measurements/widgets/EntryForm.test.tsx
index 29474c5b9..d9443aba8 100644
--- a/src/components/Measurements/widgets/EntryForm.test.tsx
+++ b/src/components/Measurements/widgets/EntryForm.test.tsx
@@ -84,7 +84,7 @@ describe("Test the EntryForm component", () => {
// Assert
expect(submitButton).toBeInTheDocument();
await user.click(submitButton);
- expect(mutate).toHaveBeenCalledWith(MeasurementEntry.clone(entry, { value: 25 }));
+ expect(mutate).toHaveBeenCalledWith(MeasurementEntry.clone(entry, { value: 25 }), expect.anything());
});
test('Creating a new entry', async () => {
@@ -113,7 +113,7 @@ describe("Test the EntryForm component", () => {
fakeNow,
42.42,
'The Shiba Inu is a breed of hunting dog from Japan.',
- ));
+ ), expect.anything());
vi.useRealTimers();
});
diff --git a/src/components/Measurements/widgets/EntryForm.tsx b/src/components/Measurements/widgets/EntryForm.tsx
index dddd59acc..1893f3695 100644
--- a/src/components/Measurements/widgets/EntryForm.tsx
+++ b/src/components/Measurements/widgets/EntryForm.tsx
@@ -8,6 +8,7 @@ import {
MeasurementCategory
} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import {
useAddGroupEntriesQuery,
useAddMeasurementEntryQuery,
@@ -66,18 +67,18 @@ export const EntryForm = ({ entry, closeFn, categoryId }: EntryFormProps) => {
}}
validationSchema={validationSchema}
onSubmit={async (values) => {
+ // The form closes only once the server took the entry, so a
+ // rejected write is shown instead of disappearing with it
+ const options = { onSuccess: () => closeFn?.() };
// Edit existing entry
if (entry) {
- useEditEntryQuery.mutate(MeasurementEntry.clone(entry, values));
+ useEditEntryQuery.mutate(MeasurementEntry.clone(entry, values), options);
} else {
- useAddEntryQuery.mutate(new MeasurementEntry(null, categoryId, values.date, values.value, values.notes));
- }
-
- // if closeFn is defined, close the modal (this form does not have to
- // be displayed in a modal)
- if (closeFn) {
- closeFn();
+ useAddEntryQuery.mutate(
+ new MeasurementEntry(null, categoryId, values.date, values.value, values.notes),
+ options
+ );
}
}}
>
@@ -120,6 +121,7 @@ export const EntryForm = ({ entry, closeFn, categoryId }: EntryFormProps) => {
helperText={formik.touched.notes && formik.errors.notes}
{...formik.getFieldProps('notes')}
/>
+
{t('submit')}
@@ -177,17 +179,16 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
}}
validationSchema={validationSchema}
onSubmit={async (values) => {
- addGroupEntriesQuery.mutate(group.children.map(child => new MeasurementEntry(
- null,
- child.id!,
- values.date,
- Number(values.values[child.id!]),
- '',
- )));
-
- if (closeFn) {
- closeFn();
- }
+ addGroupEntriesQuery.mutate(
+ group.children.map(child => new MeasurementEntry(
+ null,
+ child.id!,
+ values.date,
+ Number(values.values[child.id!]),
+ '',
+ )),
+ { onSuccess: () => closeFn?.() }
+ );
}}
>
{formik => (
@@ -226,6 +227,7 @@ export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => {
{...formik.getFieldProps(`values.${child.id}`)}
/>
)}
+
{t('submit')}
diff --git a/src/components/Measurements/widgets/WeightForm.tsx b/src/components/Measurements/widgets/WeightForm.tsx
index c99121454..f6c27d4e0 100644
--- a/src/components/Measurements/widgets/WeightForm.tsx
+++ b/src/components/Measurements/widgets/WeightForm.tsx
@@ -3,6 +3,7 @@ import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { limitsFor, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import {
useAddMeasurementEntryQuery,
useEditMeasurementEntryQuery
@@ -70,6 +71,9 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
}}
validationSchema={validationSchema}
onSubmit={async (values) => {
+ // The form closes only once the server took the entry, so a
+ // rejected write is shown instead of disappearing with it
+ const options = { onSuccess: () => closeFn?.() };
// Edit existing weight entry
if (weightEntry) {
@@ -77,7 +81,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
value: values.weight,
date: values.date,
extraData: weightEntry.extraDataInUnit(values.unit),
- }));
+ }), options);
// Create a new weight entry
} else {
@@ -89,11 +93,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
'',
'user',
{ unit: values.unit },
- ));
- }
-
- if (closeFn) {
- closeFn();
+ ), options);
}
}}
>
@@ -138,6 +138,7 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => {
}}
/>
+
{t('submit')}
From 9007633e1cab181eab7ed045194c0f4664c3924f Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Mon, 3 Aug 2026 12:04:29 +0200
Subject: [PATCH 42/71] Show the user when a write is refused, everywhere
---
src/components/Dashboard/NutritionCard.tsx | 2 ++
.../widgets/CategoryDetailDropdown.tsx | 2 ++
.../widgets/CategoryReorderList.test.tsx | 3 +-
.../widgets/CategoryReorderList.tsx | 11 +++++--
.../Nutrition/widgets/MealDetailDropdown.tsx | 3 ++
.../Nutrition/widgets/PlanDetailDropdown.tsx | 2 ++
.../Nutrition/widgets/forms/MealForm.test.tsx | 19 ++++++------
.../Nutrition/widgets/forms/MealForm.tsx | 14 +++++----
.../widgets/forms/MealItemForm.test.tsx | 17 ++++++-----
.../Nutrition/widgets/forms/MealItemForm.tsx | 23 ++++++++-------
.../forms/NutritionDiaryEntryForm.test.tsx | 29 ++++++++++---------
.../widgets/forms/NutritionDiaryEntryForm.tsx | 24 +++++++--------
.../Nutrition/widgets/forms/PlanForm.test.tsx | 9 +++---
.../Nutrition/widgets/forms/PlanForm.tsx | 15 +++++-----
.../Routines/widgets/DayDetails.tsx | 8 +++++
.../Routines/widgets/SlotDetails.tsx | 3 ++
.../Routines/widgets/forms/DayForm.tsx | 3 ++
.../Routines/widgets/forms/RoutineForm.tsx | 5 ++++
.../widgets/forms/RoutineTemplateForm.tsx | 2 ++
.../Routines/widgets/forms/SlotEntryForm.tsx | 8 +++--
.../Routines/widgets/forms/SlotForm.tsx | 2 ++
src/tests/mutationMock.ts | 19 ++++++++++++
22 files changed, 146 insertions(+), 77 deletions(-)
create mode 100644 src/tests/mutationMock.ts
diff --git a/src/components/Dashboard/NutritionCard.tsx b/src/components/Dashboard/NutritionCard.tsx
index 7adcfe72e..3bd6dd616 100644
--- a/src/components/Dashboard/NutritionCard.tsx
+++ b/src/components/Dashboard/NutritionCard.tsx
@@ -1,4 +1,5 @@
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { EmptyCard } from "@/components/Dashboard/EmptyCard";
import {
@@ -129,6 +130,7 @@ const MealListItem = (props: { meal: Meal; planId: string }) => {
return (
<>
+
{expandView ? : }
diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
index 3ff72f99b..180c2b7d2 100644
--- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx
@@ -1,6 +1,7 @@
import MenuIcon from '@mui/icons-material/Menu';
import { Button, Menu, MenuItem } from "@mui/material";
import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
import { useDeleteMeasurementCategoryQuery } from "@/components/Measurements/queries";
@@ -52,6 +53,7 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory })
return (
+
diff --git a/src/components/Measurements/widgets/CategoryReorderList.test.tsx b/src/components/Measurements/widgets/CategoryReorderList.test.tsx
index d1b00778d..e99886a34 100644
--- a/src/components/Measurements/widgets/CategoryReorderList.test.tsx
+++ b/src/components/Measurements/widgets/CategoryReorderList.test.tsx
@@ -59,7 +59,8 @@ describe("Test the CategoryReorderList component", () => {
// Assert
await waitFor(() => expect(mutateMock).toHaveBeenCalledWith(
- [TEST_MEASUREMENT_CATEGORY_2, TEST_MEASUREMENT_CATEGORY_1]
+ [TEST_MEASUREMENT_CATEGORY_2, TEST_MEASUREMENT_CATEGORY_1],
+ expect.anything()
));
});
});
diff --git a/src/components/Measurements/widgets/CategoryReorderList.tsx b/src/components/Measurements/widgets/CategoryReorderList.tsx
index 74c57b288..bf2330ac0 100644
--- a/src/components/Measurements/widgets/CategoryReorderList.tsx
+++ b/src/components/Measurements/widgets/CategoryReorderList.tsx
@@ -1,9 +1,10 @@
+import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
+import { useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd";
import DragHandleIcon from '@mui/icons-material/DragHandle';
import { List, ListItem, ListItemIcon, ListItemText } from "@mui/material";
import React, { useState } from "react";
-import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
-import { useReorderMeasurementCategoriesQuery } from "@/components/Measurements/queries";
import { useTranslation } from "react-i18next";
/**
@@ -29,11 +30,15 @@ export const CategoryReorderList = (props: { categories: MeasurementCategory[] }
const [moved] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, moved);
+ // Shown in the new order right away, and put back where it was if the
+ // server refuses: a silent revert on the next load looks like a bug
+ const previous = categories;
setCategories(reordered);
- reorderQuery.mutate(reordered);
+ reorderQuery.mutate(reordered, { onError: () => setCategories(previous) });
};
return
+
{(provided) => (
diff --git a/src/components/Nutrition/widgets/MealDetailDropdown.tsx b/src/components/Nutrition/widgets/MealDetailDropdown.tsx
index d239ff0cb..b3ae22dca 100644
--- a/src/components/Nutrition/widgets/MealDetailDropdown.tsx
+++ b/src/components/Nutrition/widgets/MealDetailDropdown.tsx
@@ -5,6 +5,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert";
import { Alert, IconButton, Menu, MenuItem, Snackbar } from "@mui/material";
import Tooltip from "@mui/material/Tooltip";
import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { Meal } from "@/components/Nutrition/models/meal";
import { useDeleteMealQuery } from "@/components/Nutrition/queries";
@@ -76,6 +77,8 @@ export const MealDetailDropdown = (props: {
return <>
+
+
{props.meal.isRealMeal && !props.onlyLogging &&
diff --git a/src/components/Nutrition/widgets/PlanDetailDropdown.tsx b/src/components/Nutrition/widgets/PlanDetailDropdown.tsx
index 4cca96180..0b535216e 100644
--- a/src/components/Nutrition/widgets/PlanDetailDropdown.tsx
+++ b/src/components/Nutrition/widgets/PlanDetailDropdown.tsx
@@ -1,6 +1,7 @@
import MenuIcon from '@mui/icons-material/Menu';
import { Button, Menu, MenuItem } from "@mui/material";
import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { WgerModal } from "@/core/ui/Modals/WgerModal";
import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan";
import { useDeleteNutritionalPlanQuery } from "@/components/Nutrition/queries";
@@ -53,6 +54,7 @@ export const PlanDetailDropdown = (props: { plan: NutritionalPlan }) => {
const handleCloseDeleteModal = () => setOpenDeleteModal(false);
return <>
+
diff --git a/src/components/Nutrition/widgets/forms/MealForm.test.tsx b/src/components/Nutrition/widgets/forms/MealForm.test.tsx
index 66c28a7e9..720afcc82 100644
--- a/src/components/Nutrition/widgets/forms/MealForm.test.tsx
+++ b/src/components/Nutrition/widgets/forms/MealForm.test.tsx
@@ -1,23 +1,24 @@
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
import { Meal } from "@/components/Nutrition/models/meal";
import { useAddMealQuery, useEditMealQuery } from "@/components/Nutrition/queries";
import { MealForm } from "@/components/Nutrition/widgets/forms/MealForm";
+import { mutateMock } from "@/tests/mutationMock";
import { TEST_MEAL_1 } from "@/tests/nutritionTestdata";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
import type { Mock } from 'vitest';
vi.mock('@/components/Nutrition/queries');
describe('Test the MealForm component', () => {
const queryClient = new QueryClient();
- let mutateAddMock = vi.fn();
- let mutateEditMock = vi.fn();
+ let mutateAddMock = mutateMock();
+ let mutateEditMock = mutateMock();
let closeFnMock = vi.fn();
beforeEach(() => {
- mutateAddMock = vi.fn();
- mutateEditMock = vi.fn();
+ mutateAddMock = mutateMock();
+ mutateEditMock = mutateMock();
closeFnMock = vi.fn();
(useEditMealQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
@@ -49,7 +50,7 @@ describe('Test the MealForm component', () => {
name: '2nd breakfast',
planId: 'aaaaaaaa-0000-0000-0000-000000000987',
time: expect.any(Date),
- }));
+ }), expect.anything());
});
test('an existing meal is correctly edited', async () => {
@@ -81,6 +82,6 @@ describe('Test the MealForm component', () => {
planId: 'aaaaaaaa-0000-0000-0000-000000000123',
time: TEST_MEAL_1.time
})
- );
+ , expect.anything());
});
});
diff --git a/src/components/Nutrition/widgets/forms/MealForm.tsx b/src/components/Nutrition/widgets/forms/MealForm.tsx
index f5f83ae1b..9ece9ed55 100644
--- a/src/components/Nutrition/widgets/forms/MealForm.tsx
+++ b/src/components/Nutrition/widgets/forms/MealForm.tsx
@@ -3,6 +3,7 @@ import { LocalizationProvider, TimePicker } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { Meal } from "@/components/Nutrition/models/meal";
import { useAddMealQuery, useEditMealQuery } from "@/components/Nutrition/queries";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { Form, Formik } from "formik";
import { DateTime } from "luxon";
import React from 'react';
@@ -45,10 +46,14 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => {
values.time = values.time.toJSDate();
}
+ // The dialog closes only once the server took the meal, so a
+ // rejected write is shown instead of disappearing with it
+ const options = { onSuccess: () => closeFn?.() };
+
if (meal) {
// Edit
const newMeal = Meal.clone(meal, { name: values.name, time: values.time });
- editMealQuery.mutate(newMeal);
+ editMealQuery.mutate(newMeal, options);
} else {
// Add
@@ -56,11 +61,7 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => {
planId: planId,
name: values.name,
time: values.time,
- }));
- }
-
- if (closeFn) {
- closeFn();
+ }), options);
}
}}
>
@@ -83,6 +84,7 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => {
onChange={(newValue) => formik.setFieldValue('time', newValue ? newValue.toJSDate() : null)}
/>
+
{closeFn !== undefined
&& closeFn()}>
diff --git a/src/components/Nutrition/widgets/forms/MealItemForm.test.tsx b/src/components/Nutrition/widgets/forms/MealItemForm.test.tsx
index 0203442bd..32595f6b5 100644
--- a/src/components/Nutrition/widgets/forms/MealItemForm.test.tsx
+++ b/src/components/Nutrition/widgets/forms/MealItemForm.test.tsx
@@ -1,10 +1,11 @@
+import { searchIngredient } from "@/components/Nutrition/api/ingredient";
import { Ingredient } from "@/components/Nutrition/models/Ingredient";
import { MealItem } from "@/components/Nutrition/models/mealItem";
import { useAddMealItemQuery, useEditMealItemQuery, useSearchIngredientQuery } from "@/components/Nutrition/queries";
import { MealItemForm } from "@/components/Nutrition/widgets/forms/MealItemForm";
import { SEARCH_DEBOUNCE_MS } from "@/components/Nutrition/widgets/IngredientAutocompleter";
-import { searchIngredient } from "@/components/Nutrition/api/ingredient";
import { TEST_INGREDIENT_1, TEST_INGREDIENT_2 } from "@/tests/ingredientTestdata";
+import { mutateMock } from "@/tests/mutationMock";
import { TEST_MEAL_ITEM_1, TEST_WEIGHT_UNIT_SLICE } from "@/tests/nutritionTestdata";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, render, screen, within } from "@testing-library/react";
@@ -46,13 +47,13 @@ async function fillInEntry(user: UserEvent) {
describe('Test the MealItemForm component', () => {
const queryClient = new QueryClient();
- let mutateAddMock = vi.fn();
- let mutateEditMock = vi.fn();
+ let mutateAddMock = mutateMock();
+ let mutateEditMock = mutateMock();
let closeFnMock = vi.fn();
beforeEach(() => {
- mutateAddMock = vi.fn();
- mutateEditMock = vi.fn();
+ mutateAddMock = mutateMock();
+ mutateEditMock = mutateMock();
closeFnMock = vi.fn();
(useEditMealItemQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
@@ -86,7 +87,7 @@ describe('Test the MealItemForm component', () => {
ingredientId: 101,
weightUnitId: null,
})
- );
+ , expect.anything());
});
test('An existing entry should be updated', async () => {
// Arrange
@@ -118,7 +119,7 @@ describe('Test the MealItemForm component', () => {
ingredient: TEST_INGREDIENT_2,
weightUnitId: null,
})
- );
+ , expect.anything());
});
test('Resetting the unit back to gram should clear the weight unit', async () => {
// Arrange
@@ -155,6 +156,6 @@ describe('Test the MealItemForm component', () => {
weightUnitId: null,
weightUnit: null,
})
- );
+ , expect.anything());
});
});
diff --git a/src/components/Nutrition/widgets/forms/MealItemForm.tsx b/src/components/Nutrition/widgets/forms/MealItemForm.tsx
index a40f68320..7ff718aa0 100644
--- a/src/components/Nutrition/widgets/forms/MealItemForm.tsx
+++ b/src/components/Nutrition/widgets/forms/MealItemForm.tsx
@@ -8,6 +8,7 @@ import {
useEditMealItemQuery,
} from "@/components/Nutrition/queries";
import { IngredientAutocompleter } from "@/components/Nutrition/widgets/IngredientAutocompleter";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { Form, Formik } from "formik";
import React, { useState } from 'react';
import { useTranslation } from "react-i18next";
@@ -30,14 +31,16 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp
const [selectedUnit, setSelectedUnit] = useState(item?.weightUnit ?? null);
const [weightUnits, setWeightUnits] = useState(item?.ingredient?.weightUnits ?? []);
+ // The dialog closes only once the server took the change, so a rejected
+ // write is shown instead of disappearing with it
+ const closeOnSuccess = { onSuccess: () => closeFn?.() };
+
const handleDelete = () => {
if (item) {
- deleteMealItemQuery.mutate(item.id!);
- }
-
- if (closeFn) {
- closeFn();
+ deleteMealItemQuery.mutate(item.id!, closeOnSuccess);
+ return;
}
+ closeFn?.();
};
const validationSchema = yup.object({
@@ -81,7 +84,7 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp
weightUnitId: selectedUnit?.id ?? null,
weightUnit: selectedUnit,
});
- editMealItemQuery.mutate(newMealItem);
+ editMealItemQuery.mutate(newMealItem, closeOnSuccess);
} else {
// Add
addMealItemQuery.mutate(new MealItem({
@@ -91,11 +94,7 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp
weightUnitId: selectedUnit?.id ?? null,
weightUnit: selectedUnit,
order: 1,
- }));
- }
-
- if (closeFn) {
- closeFn();
+ }), closeOnSuccess);
}
}}
>
@@ -148,6 +147,8 @@ export const MealItemForm = ({ planId, item, mealId, closeFn }: MealItemFormProp
{...formik.getFieldProps('amount')}
/>
+
+
{(closeFn !== undefined && item !== undefined)
&&
diff --git a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.test.tsx b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.test.tsx
index 72e5be04d..a7639785f 100644
--- a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.test.tsx
+++ b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.test.tsx
@@ -1,3 +1,4 @@
+import { searchIngredient } from "@/components/Nutrition/api/ingredient";
import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry";
import {
useAddDiaryEntryQuery,
@@ -6,8 +7,8 @@ import {
useSearchIngredientQuery
} from "@/components/Nutrition/queries";
import { NutritionDiaryEntryForm } from "@/components/Nutrition/widgets/forms/NutritionDiaryEntryForm";
-import { searchIngredient } from "@/components/Nutrition/api/ingredient";
import { TEST_INGREDIENT_1, TEST_INGREDIENT_2 } from "@/tests/ingredientTestdata";
+import { mutateMock } from "@/tests/mutationMock";
import { TEST_DIARY_ENTRY_1 } from "@/tests/nutritionDiaryTestdata";
import { TEST_MEAL_1, TEST_MEAL_2 } from "@/tests/nutritionTestdata";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -45,15 +46,15 @@ async function fillInEntry(user: UserEvent) {
describe('Test the NutritionDiaryEntryForm component', () => {
const queryClient = new QueryClient();
- let mutateAddMock = vi.fn();
- let mutateEditMock = vi.fn();
- let mutateDeleteMock = vi.fn();
+ let mutateAddMock = mutateMock();
+ let mutateEditMock = mutateMock();
+ let mutateDeleteMock = mutateMock();
let closeFnMock = vi.fn();
beforeEach(() => {
- mutateAddMock = vi.fn();
- mutateEditMock = vi.fn();
- mutateDeleteMock = vi.fn();
+ mutateAddMock = mutateMock();
+ mutateEditMock = mutateMock();
+ mutateDeleteMock = mutateMock();
closeFnMock = vi.fn();
(useEditDiaryEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock }));
@@ -90,7 +91,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
mealId: null,
weightUnitId: null,
})
- );
+ , expect.anything());
});
test('A new entry should be added - passing meal ID', async () => {
// Arrange
@@ -120,7 +121,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
weightUnitId: null,
})
- );
+ , expect.anything());
});
test('An existing diary entry should be edited', async () => {
@@ -148,7 +149,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
// The newly selected ingredient, not the entry's original one
ingredientId: 102,
})
- );
+ , expect.anything());
});
test('The form is prefilled with the entry data when editing', async () => {
@@ -178,7 +179,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
mealId: 'bbbbbbbb-0000-0000-0000-000000000078',
datetime: TEST_DIARY_ENTRY_1.datetime,
})
- );
+ , expect.anything());
});
test('Editing shows the entry\'s meal preselected when meals are passed', async () => {
@@ -200,7 +201,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
await user.click(screen.getByRole('button', { name: 'submit' }));
expect(mutateEditMock).toHaveBeenCalledWith(
expect.objectContaining({ mealId: 'bbbbbbbb-0000-0000-0000-000000000078' })
- );
+ , expect.anything());
});
test('An existing diary entry should be deleted', async () => {
@@ -220,7 +221,7 @@ describe('Test the NutritionDiaryEntryForm component', () => {
expect(mutateAddMock).not.toHaveBeenCalled();
expect(mutateEditMock).not.toHaveBeenCalled();
expect(closeFnMock).toHaveBeenCalled();
- expect(mutateDeleteMock).toHaveBeenCalledWith('dddddddd-0000-0000-0000-000000000042');
+ expect(mutateDeleteMock).toHaveBeenCalledWith('dddddddd-0000-0000-0000-000000000042', expect.anything());
});
test('An existing diary entry should be edited - passing a meal Id', async () => {
@@ -250,6 +251,6 @@ describe('Test the NutritionDiaryEntryForm component', () => {
ingredientId: 102,
weightUnitId: null,
})
- );
+ , expect.anything());
});
});
\ No newline at end of file
diff --git a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx
index d2a9d0ad4..e50f63ca8 100644
--- a/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx
+++ b/src/components/Nutrition/widgets/forms/NutritionDiaryEntryForm.tsx
@@ -11,6 +11,7 @@ import {
useEditDiaryEntryQuery
} from "@/components/Nutrition/queries";
import { IngredientAutocompleter } from "@/components/Nutrition/widgets/IngredientAutocompleter";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { Form, Formik } from "formik";
import { DateTime } from "luxon";
import React, { useState } from 'react';
@@ -60,14 +61,16 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn
.required(t('forms.fieldRequired')),
});
+ // The dialog closes only once the server took the change, so a rejected
+ // write is shown instead of disappearing with it
+ const closeOnSuccess = { onSuccess: () => closeFn?.() };
+
const handleDelete = () => {
if (entry) {
- deleteDiaryQuery.mutate(entry.id!);
- }
-
- if (closeFn) {
- closeFn();
+ deleteDiaryQuery.mutate(entry.id!, closeOnSuccess);
+ return;
}
+ closeFn?.();
};
const handleUnitChange = (value: string) => {
@@ -104,7 +107,7 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn
weightUnitId: selectedUnit?.id ?? null,
weightUnit: selectedUnit,
});
- editDiaryQuery.mutate(newDiaryEntry);
+ editDiaryQuery.mutate(newDiaryEntry, closeOnSuccess);
} else {
// Add
addDiaryQuery.mutate(new DiaryEntry({
@@ -115,12 +118,7 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn
mealId: selectedMeal,
weightUnitId: selectedUnit?.id ?? null,
weightUnit: selectedUnit,
- }));
- }
-
- // if closeFn is defined, close the modal (this form does not have to be displayed in one)
- if (closeFn) {
- closeFn();
+ }), closeOnSuccess);
}
}}
>
@@ -216,6 +214,8 @@ export const NutritionDiaryEntryForm = ({ planId, entry, mealId, meals, closeFn
}}
/>
+
+
{(closeFn !== undefined && entry !== undefined)
&&
diff --git a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
index 1c23359eb..7baaf301f 100644
--- a/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
+++ b/src/components/Nutrition/widgets/forms/PlanForm.test.tsx
@@ -1,5 +1,6 @@
import { useAddNutritionalPlanQuery, useEditNutritionalPlanQuery } from "@/components/Nutrition/queries";
import { PlanForm } from "@/components/Nutrition/widgets/forms/PlanForm";
+import { mutateMock } from "@/tests/mutationMock";
import { TEST_NUTRITIONAL_PLAN_1 } from "@/tests/nutritionTestdata";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
@@ -12,10 +13,10 @@ vi.mock("@/components/Nutrition/queries");
describe("Test the PlanForm component", () => {
const queryClient = new QueryClient();
- let mutate = vi.fn();
+ let mutate = mutateMock();
beforeEach(() => {
- mutate = vi.fn();
+ mutate = mutateMock();
(useEditNutritionalPlanQuery as Mock).mockImplementation(() => ({
mutate: mutate
@@ -71,7 +72,7 @@ describe("Test the PlanForm component", () => {
goalProtein: null,
onlyLogging: false,
})
- );
+ , expect.anything());
});
test('Creating a new plan', async () => {
@@ -99,6 +100,6 @@ describe("Test the PlanForm component", () => {
goalProtein: null,
goalFiber: null,
})
- );
+ , expect.anything());
});
});
diff --git a/src/components/Nutrition/widgets/forms/PlanForm.tsx b/src/components/Nutrition/widgets/forms/PlanForm.tsx
index 7c629cdbd..1b4507f76 100644
--- a/src/components/Nutrition/widgets/forms/PlanForm.tsx
+++ b/src/components/Nutrition/widgets/forms/PlanForm.tsx
@@ -13,6 +13,7 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
import { ENERGY_FACTOR } from "@/components/Nutrition/helpers/nutritionalValues";
+import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan";
import { useAddNutritionalPlanQuery, useEditNutritionalPlanQuery } from "@/components/Nutrition/queries";
import { Form, Formik } from "formik";
@@ -133,16 +134,15 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => {
});
+ // The dialog closes only once the server took the plan, so a
+ // rejected write is shown instead of disappearing with it
+ const options = { onSuccess: () => closeFn?.() };
+
if (plan) {
newPlan.id = plan.id!;
- editPlanQuery.mutate(newPlan);
+ editPlanQuery.mutate(newPlan, options);
} else {
- addPlanQuery.mutate(newPlan);
- }
-
- // if closeFn is defined, close the modal (this form does not have to be displayed in one)
- if (closeFn) {
- closeFn();
+ addPlanQuery.mutate(newPlan, options);
}
}}
>
@@ -364,6 +364,7 @@ export const PlanForm = ({ plan, closeFn }: PlanFormProps) => {
>}
+
+ {/* A refused reorder leaves the new order on screen until the next
+ load, so at least say that it did not stick */}
+
+
@@ -371,6 +376,9 @@ export const DayDetails = (props: {
return (<>
+
+
+
+
+
{!props.isGrouped && <>
diff --git a/src/components/Routines/widgets/forms/DayForm.tsx b/src/components/Routines/widgets/forms/DayForm.tsx
index 0fe2c7466..55d1026d4 100644
--- a/src/components/Routines/widgets/forms/DayForm.tsx
+++ b/src/components/Routines/widgets/forms/DayForm.tsx
@@ -14,6 +14,7 @@ import {
import LoadingButton from "@mui/material/Button";
import Grid from '@mui/material/Grid';
import { WgerTextField } from "@/core/forms/WgerTextField";
+import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError";
import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal";
import { Day, DayType } from "@/components/Routines/models/Day";
import { useDeleteDayQuery, useEditDayQuery } from "@/components/Routines/queries";
@@ -115,6 +116,8 @@ export const DayForm = (props: {
>
{(formik) => (
);
From 09915119e8cb255cb4c9ab73d2eb376cff9427d1 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Tue, 4 Aug 2026 22:07:22 +0200
Subject: [PATCH 46/71] Read durations in hours and minutes
---
.../Measurements/charts/format.test.ts | 70 ++++++++++++++++-
src/components/Measurements/charts/format.ts | 75 ++++++++++++++++++-
.../Measurements/widgets/MeasurementChart.tsx | 35 +++++++--
.../widgets/MeasurementSeriesChart.tsx | 21 +++++-
4 files changed, 188 insertions(+), 13 deletions(-)
diff --git a/src/components/Measurements/charts/format.test.ts b/src/components/Measurements/charts/format.test.ts
index 3d035802c..b28a2b18d 100644
--- a/src/components/Measurements/charts/format.test.ts
+++ b/src/components/Measurements/charts/format.test.ts
@@ -1,4 +1,11 @@
-import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
+import {
+ dateTick,
+ durationAxis,
+ hoursAndMinutes,
+ spansYears,
+ valueOnly,
+ valueWithUnit
+} from "@/components/Measurements/charts/format";
import { ChartPoint } from "@/components/Measurements/charts/series";
import { describe, expect, test } from 'vitest';
@@ -42,4 +49,65 @@ describe('valueWithUnit', () => {
test('formats the number for the locale', () => {
expect(valueWithUnit(1234.5, 'kcal', 'de')).toBe('1.234,5 kcal');
});
+
+ test('shows a value stored in minutes as hours and minutes', () => {
+ expect(valueWithUnit(452, 'min', 'de')).toBe('7:32 h');
+ });
+});
+
+describe('hoursAndMinutes', () => {
+ test('splits the minutes into hours and minutes', () => {
+ expect(hoursAndMinutes(452, 'en')).toBe('7:32');
+ });
+
+ test('pads the minutes so the values line up', () => {
+ expect(hoursAndMinutes(425, 'en')).toBe('7:05');
+ });
+
+ test('keeps a duration below an hour in the same shape', () => {
+ expect(hoursAndMinutes(45, 'en')).toBe('0:45');
+ });
+
+ test('rounds to whole minutes', () => {
+ expect(hoursAndMinutes(59.6, 'en')).toBe('1:00');
+ });
+
+ test('keeps the sign of a negative change', () => {
+ expect(hoursAndMinutes(-95, 'en')).toBe('-1:35');
+ });
+});
+
+describe('durationAxis', () => {
+ test('leaves the ticks to the library for every other unit', () => {
+ expect(durationAxis('kg', 60, 100)).toBeUndefined();
+ });
+
+ test('puts every tick on a whole hour', () => {
+ expect(durationAxis('min', 0, 300)?.ticks).toEqual([0, 60, 120, 180, 240, 300]);
+ });
+
+ test('widens the step until the ticks are few enough', () => {
+ expect(durationAxis('min', 0, 540)?.ticks).toEqual([0, 120, 240, 360, 480, 600]);
+ });
+
+ test('keeps the domain from cutting the values it was derived from', () => {
+ const axis = durationAxis('min', 0, 540);
+
+ expect(axis?.domain[1]).toBeGreaterThanOrEqual(540);
+ expect(axis?.domain[1]).toBe(axis?.ticks[axis.ticks.length - 1]);
+ });
+
+ test('starts at the hour below the data instead of at zero', () => {
+ expect(durationAxis('min', 385, 460)?.domain[0]).toBe(360);
+ });
+});
+
+describe('valueOnly', () => {
+ test('leaves the unit off', () => {
+ expect(valueOnly(42, 'cm', 'en')).toBe('42');
+ });
+
+ test('reads a duration as hours and minutes', () => {
+ expect(valueOnly(452, 'min', 'en')).toBe('7:32');
+ });
});
diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts
index 0f52dc59d..1971c462d 100644
--- a/src/components/Measurements/charts/format.ts
+++ b/src/components/Measurements/charts/format.ts
@@ -21,6 +21,79 @@ export const dateTick = (withYear: boolean) => (value: number): string =>
? { year: '2-digit', month: '2-digit', day: '2-digit' }
: { month: '2-digit', day: '2-digit' });
+/** The unit a duration is stored in, which is what the health platforms deliver */
+const MINUTES = 'min';
+
+/**
+ * A duration in minutes as hours and minutes, e.g. 452 as "7:32".
+ *
+ * Intl does the splitting, which gets the padding and the locale's own digits
+ * right (7:32 reads ۷:۳۲ in Persian). The sign is ours: a duration is only
+ * ever negative here as a change between two of them.
+ */
+export const hoursAndMinutes = (minutes: number, locale: string): string => {
+ const rounded = Math.round(minutes);
+ const absolute = Math.abs(rounded);
+
+ return (rounded < 0 ? '-' : '') + new Intl.DurationFormat(locale, {
+ style: 'digital',
+ secondsDisplay: 'auto',
+ }).format({ hours: Math.floor(absolute / 60), minutes: absolute % 60 });
+};
+
+/**
+ * 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.
+ */
+export const valueOnly = (value: number, unit: string, locale: string): string =>
+ unit === MINUTES ? hoursAndMinutes(value, locale) : numberDecimalLocale(value, locale);
+
+/**
+ * The unit as it is shown. A duration is stored in minutes but read in hours,
+ * and the symbol stays untranslated like every other category unit.
+ */
+export const unitLabel = (unit: string): string => unit === MINUTES ? 'h' : unit;
+
/** A measured value with its unit, both localised */
export const valueWithUnit = (value: number, unit: string, locale: string): string =>
- `${numberDecimalLocale(value, locale)} ${unit}`;
+ `${valueOnly(value, unit, locale)} ${unitLabel(unit)}`;
+
+/** Ticks a duration axis aims for, few enough that the labels stay apart */
+const DURATION_TICKS = 6;
+
+const MINUTES_PER_HOUR = 60;
+
+/**
+ * Domain and ticks of an axis of durations, undefined for every other unit,
+ * where the library picks them.
+ *
+ * A duration is read in hours, so a tick belongs on a whole one: an axis
+ * labelled 6:40, 8:20, 10:00 is arithmetically correct and unreadable. The
+ * step grows in whole hours until few enough ticks are left, and the bounds
+ * are widened to the hours around the data so no tick falls outside them.
+ */
+export const durationAxis = (
+ unit: string,
+ min: number,
+ max: number,
+): { domain: [number, number], ticks: number[] } | undefined => {
+ if (unit !== MINUTES) {
+ return undefined;
+ }
+
+ const from = Math.floor(min / MINUTES_PER_HOUR) * MINUTES_PER_HOUR;
+ const to = Math.ceil(max / MINUTES_PER_HOUR) * MINUTES_PER_HOUR;
+ const hours = Math.max(1, (to - from) / MINUTES_PER_HOUR);
+ const step = Math.ceil(hours / DURATION_TICKS) * MINUTES_PER_HOUR;
+
+ // The top follows the step rather than the data: a domain that ended below
+ // the last tick would cut the values it was derived from
+ const top = from + Math.ceil((to - from) / step) * step;
+
+ const ticks = [];
+ for (let tick = from; tick <= top; tick += step) {
+ ticks.push(tick);
+ }
+
+ return { domain: [from, top], ticks };
+};
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index c85ad767b..da7de6ffc 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -19,7 +19,13 @@ import {
} from "@/components/Measurements/charts/data";
import { componentColor, componentPalette } from "@/components/Measurements/charts/colors";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
-import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
+import {
+ dateTick,
+ durationAxis,
+ spansYears,
+ valueOnly,
+ valueWithUnit
+} from "@/components/Measurements/charts/format";
import {
ChartRange,
cutoffFor,
@@ -35,7 +41,6 @@ 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,
@@ -78,6 +83,8 @@ const MeasurementBarChart = (props: { category: MeasurementCategory, cutoff: Dat
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
@@ -96,7 +103,8 @@ const MeasurementBarChart = (props: { category: MeasurementCategory, cutoff: Dat
/>
valueWithUnit(value, props.category.unit, i18n.language)} />
)} />
@@ -130,7 +138,7 @@ const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
{dateToLocale(new Date(Number(label)))}
{/* a range is quoted as high over low, the way a blood pressure reading is written */}
- {numberDecimalLocale(high, i18n.language)}/
+ {valueOnly(high, unit, i18n.language)}/
{valueWithUnit(low, unit, i18n.language)}
@@ -148,6 +156,11 @@ const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
const [, i18n] = useTranslation();
const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
+ const axis = durationAxis(
+ props.unit,
+ Math.min(...props.points.map(point => point.min!)),
+ Math.max(...props.points.map(point => point.max!)),
+ );
return
@@ -161,7 +174,8 @@ const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string })
/>
valueWithUnit(value, props.unit, i18n.language)} />
} />
@@ -200,7 +214,7 @@ const StackedTooltip = ({ active, payload, label, unit }: StackedTooltipProps) =
{valueWithUnit(total, unit, i18n.language)}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{parts.map((entry: any) =>
- {entry.dataKey}: {numberDecimalLocale(entry.value, i18n.language)}
+ {entry.dataKey}: {valueOnly(entry.value, unit, i18n.language)}
)}
);
@@ -226,6 +240,12 @@ const MeasurementStackedBarChart = (props: {
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),
+ );
+ const axis = durationAxis(props.unit, 0, Math.max(...totals));
return
@@ -239,7 +259,8 @@ const MeasurementStackedBarChart = (props: {
/>
valueWithUnit(value, props.unit, i18n.language)} />
} />
diff --git a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
index b606529c7..3c7c06913 100644
--- a/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementSeriesChart.tsx
@@ -2,7 +2,13 @@ import { Box, Paper, Stack, Typography, useTheme } from "@mui/material";
import { componentPalette, seriesColor } from "@/components/Measurements/charts/colors";
import { clampPeriods, planNamesAt, pointsOfRole } from "@/components/Measurements/charts/data";
import { dotRadius, useChartWidth } from "@/components/Measurements/charts/density";
-import { dateTick, spansYears, valueWithUnit } from "@/components/Measurements/charts/format";
+import {
+ dateTick,
+ durationAxis,
+ spansYears,
+ valueOnly,
+ valueWithUnit
+} from "@/components/Measurements/charts/format";
import {
ChartPoint,
ChartSeries,
@@ -27,7 +33,6 @@ import {
YAxis
} from "recharts";
import { dateToLocale } from "@/core/lib/date";
-import { numberDecimalLocale } from "@/core/lib/numbers";
/** Opacity of the band drawn around a series of ranged points */
const BAND_OPACITY = 0.15;
@@ -99,7 +104,7 @@ const CustomTooltip = ({ active, payload, label, unit, planPeriods }: TooltipPro
{row.name}
{row.value !== undefined && `: ${valueWithUnit(row.value, unit, i18n.language)}`}
- {row.range !== undefined && ` (${numberDecimalLocale(row.range[0], i18n.language)}`
+ {row.range !== undefined && ` (${valueOnly(row.range[0], unit, i18n.language)}`
+ `–${valueWithUnit(row.range[1], unit, i18n.language)})`}
))}
@@ -258,6 +263,13 @@ export const MeasurementSeriesChart = (props: MeasurementSeriesChartProps) => {
: rawPoints.reduce((sum, point) => sum + point.value, 0) / rawPoints.length;
const currentTrend = trendPoints.at(-1)?.value ?? null;
+ // The bounds of a band reach past the line they wrap, so the axis follows
+ // every value that is drawn, not just the plotted ones
+ const drawn = props.series.flatMap(s => s.points).flatMap(
+ point => [point.value, point.min, point.max].filter(value => value !== undefined),
+ );
+ const axis = durationAxis(props.unit, Math.min(...drawn), Math.max(...drawn));
+
return
{props.showMean && mean !== null && {
tickCount={10}
/>
valueWithUnit(value, props.unit, i18n.language)} />
} />
From 44a61a6e2646da806336285634a8ece9dd042278 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Wed, 5 Aug 2026 18:17:27 +0200
Subject: [PATCH 47/71] Add a weekly change (delta) chart type
---
public/locales/de/translation.json | 3 +-
public/locales/en/translation.json | 3 +-
public/locales/es/translation.json | 3 +-
public/locales/fr/translation.json | 3 +-
src/components/Measurements/charts/colors.ts | 8 ++
.../Measurements/charts/data.test.ts | 77 ++++++++++-
src/components/Measurements/charts/data.ts | 44 ++++++
.../Measurements/models/Category.test.ts | 5 +-
.../Measurements/models/Category.ts | 13 +-
.../widgets/MeasurementChart.test.tsx | 23 ++++
.../Measurements/widgets/MeasurementChart.tsx | 102 +++++++++++++-
.../widgets/MetricPicker.test.tsx | 130 ++++++++++++++++++
.../Measurements/widgets/MetricPicker.tsx | 90 ++++++++++++
13 files changed, 487 insertions(+), 17 deletions(-)
create mode 100644 src/components/Measurements/widgets/MetricPicker.test.tsx
create mode 100644 src/components/Measurements/widgets/MetricPicker.tsx
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 1c4f633a9..040e54a68 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -261,7 +261,8 @@
"auto": "Automatisch",
"line": "Linie",
"bar": "Balken",
- "heatmap": "Heatmap"
+ "heatmap": "Heatmap",
+ "delta": "Veränderung"
},
"partOfGroup": "Teil der Gruppe",
"noGroup": "Keine Gruppe",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index d14c8ddfd..8cca6cca2 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -345,7 +345,8 @@
"auto": "Automatic",
"line": "Line",
"bar": "Bars",
- "heatmap": "Heatmap"
+ "heatmap": "Heatmap",
+ "delta": "Change"
},
"partOfGroup": "Part of group",
"noGroup": "No group",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 0d9a17588..2d73a5f51 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -263,7 +263,8 @@
"auto": "Automático",
"line": "Línea",
"bar": "Barras",
- "heatmap": "Mapa de calor"
+ "heatmap": "Mapa de calor",
+ "delta": "Variación"
},
"partOfGroup": "Parte del grupo",
"noGroup": "Sin grupo",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 324990355..30a716013 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -346,7 +346,8 @@
"auto": "Automatique",
"line": "Ligne",
"bar": "Barres",
- "heatmap": "Carte thermique"
+ "heatmap": "Carte thermique",
+ "delta": "Variation"
},
"partOfGroup": "Fait partie du groupe",
"noGroup": "Aucun groupe",
diff --git a/src/components/Measurements/charts/colors.ts b/src/components/Measurements/charts/colors.ts
index 04ba55c23..cd6e27552 100644
--- a/src/components/Measurements/charts/colors.ts
+++ b/src/components/Measurements/charts/colors.ts
@@ -12,6 +12,14 @@ export const componentPalette = (componentCount: number): string[] =>
export const componentColor = (palette: string[], index: number): string =>
palette[index % palette.length];
+/**
+ * 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.
+ */
+export const deltaColor = (theme: Theme, delta: number): string =>
+ delta < 0 ? theme.palette.info.main : theme.palette.secondary.main;
+
/**
* Colour of a series. Components are coloured by their position, the other
* roles have a fixed colour each.
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 85e571caf..9a28ce40e 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -16,7 +16,8 @@ import {
moving7dAverage,
overallChange,
smoothedTrendline,
- stackableComponents
+ stackableComponents,
+ weeklyDeltas
} from "@/components/Measurements/charts/data";
import { ChartPoint } from "@/components/Measurements/charts/series";
import { describe, expect, test } from 'vitest';
@@ -237,6 +238,80 @@ describe('averagePerDay', () => {
});
});
+describe('weeklyDeltas', () => {
+ // 5 January 2026 is a Monday
+ const week = (index: number, dayOfWeek: number = 0) => new Date(2026, 0, 5 + 7 * index + dayOfWeek);
+
+ test('returns an empty array for no points', () => {
+ expect(weeklyDeltas([])).toEqual([]);
+ });
+
+ test('has no bar for a single week, which has nothing to compare against', () => {
+ expect(weeklyDeltas([point(week(0), 80), point(week(0, 2), 81)])).toEqual([]);
+ });
+
+ test('subtracts the previous week, dated on the week it belongs to', () => {
+ const result = weeklyDeltas([point(week(0), 80), point(week(1), 79), point(week(2), 79.5)]);
+
+ expect(result.map(r => r.date)).toEqual([week(1).getTime(), week(2).getTime()]);
+ expect(result.map(r => r.value)).toEqual([-1, 0.5]);
+ });
+
+ test('compares the weeks by their average, not by single readings', () => {
+ // the low reading is an outlier within its week and must not decide the bar
+ const result = weeklyDeltas([
+ point(week(0), 80), point(week(0, 3), 82),
+ point(week(1), 75), point(week(1, 3), 87),
+ ]);
+
+ expect(result.map(r => r.value)).toEqual([0]);
+ });
+
+ test('sums the weeks of a metric that is read as a total', () => {
+ const result = weeklyDeltas([
+ point(week(0), 3000), point(week(0, 1), 4000),
+ point(week(1), 9000),
+ ], true);
+
+ expect(result.map(r => r.value)).toEqual([2000]);
+ });
+
+ test('takes the week after a gap against the last week that has readings', () => {
+ // one bar on the week that was measured, holding the whole change, so
+ // the bars still add up to the change across the range
+ const result = weeklyDeltas([point(week(0), 80), point(week(3), 77)]);
+
+ expect(result).toEqual([{ date: week(3).getTime(), value: -3 }]);
+ });
+
+ test('sorts unordered input by week first', () => {
+ const result = weeklyDeltas([point(week(1), 79), point(week(0), 80)]);
+
+ expect(result.map(r => r.value)).toEqual([-1]);
+ });
+
+ test('leaves the running week out of a summed metric', () => {
+ // its total is still growing and would read as a drop until Sunday
+ const result = weeklyDeltas(
+ [point(week(0), 7000), point(week(1), 3000)],
+ true,
+ week(1, 2),
+ );
+
+ expect(result).toEqual([]);
+ });
+
+ test('keeps the running week of an averaged metric', () => {
+ const result = weeklyDeltas(
+ [point(week(0), 80), point(week(1), 79)],
+ false,
+ week(1, 2),
+ );
+
+ expect(result.map(r => r.value)).toEqual([-1]);
+ });
+});
+
describe('buildHeatmapGrid', () => {
// 2 March 2026 is a Monday, 18 March a Wednesday
const monday = new Date(2026, 2, 2);
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 130b8eef2..5fe29a690 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -264,6 +264,50 @@ const daysBetween = (from: Date, to: Date): number => Math.round(
- Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) / DAY_MS
);
+/**
+ * The level a week is summarised at: its total for the summed metric types, its
+ * average for the sample ones, whose readings repeat the same measurement.
+ */
+const weekLevel = (values: number[], summed: boolean): number => {
+ const total = values.reduce((sum, value) => sum + value, 0);
+
+ return summed ? total : total / values.length;
+};
+
+/**
+ * Week-over-week change: one point per calendar week against the last week
+ * with readings, summarised (see weekLevel) before subtracting so no single
+ * reading decides a bar. The running week of a summed metric is left out,
+ * its total is still growing and would read as a drop until Sunday.
+ */
+export const weeklyDeltas = (
+ points: ChartPoint[],
+ summed: boolean = false,
+ today: Date = new Date(),
+): ChartPoint[] => {
+ const byWeek = new Map();
+ for (const point of points) {
+ const week = mondayOf(new Date(point.date)).getTime();
+ const values = byWeek.get(week);
+ if (values === undefined) {
+ byWeek.set(week, [point.value]);
+ } else {
+ values.push(point.value);
+ }
+ }
+
+ if (summed) {
+ byWeek.delete(mondayOf(today).getTime());
+ }
+
+ const weeks = [...byWeek.keys()].sort((a, b) => a - b);
+
+ return weeks.slice(1).map((week, index) => ({
+ date: week,
+ value: weekLevel(byWeek.get(week)!, summed) - weekLevel(byWeek.get(weeks[index])!, summed),
+ }));
+};
+
/** The day in column week, row weekday (0 = Monday), as a local-midnight timestamp */
export const heatmapDayAt = (grid: HeatmapGrid, week: number, weekday: number): number =>
shiftDays(new Date(grid.start), week * DAYS_PER_WEEK + weekday).getTime();
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index e0cf28eea..758f08819 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -157,8 +157,8 @@ describe('MeasurementCategory', () => {
});
test('the offered types follow the metric type', () => {
- expect(availableChartTypes('steps')).toEqual(['bar', 'heatmap']);
- expect(availableChartTypes('custom')).toEqual(['line', 'heatmap']);
+ expect(availableChartTypes('steps')).toEqual(['bar', 'heatmap', 'delta']);
+ expect(availableChartTypes('custom')).toEqual(['line', 'heatmap', 'delta']);
// a group is drawn by what its components are to each other
expect(availableChartTypes('blood_pressure')).toEqual([]);
@@ -173,6 +173,7 @@ describe('MeasurementCategory', () => {
test('a type that fits is kept', () => {
expect(resolveChartType('custom', 'heatmap')).toBe('heatmap');
expect(resolveChartType('steps', 'bar')).toBe('bar');
+ expect(resolveChartType('body_weight', 'delta')).toBe('delta');
});
});
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 86026e422..2aaf7a52b 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -39,7 +39,7 @@ export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight';
* (two components) and a stacked bar (a summed group) follow from what the
* group is and are not choices.
*/
-export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap'] as const;
+export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap', 'delta'] as const;
export type ChartType = typeof CHART_TYPES[number];
/**
@@ -106,13 +106,14 @@ export function defaultChartType(type: MetricType): ChartType {
* The chart types a category of this metric type may be drawn as, i.e. what
* the picker offers on top of 'auto'.
*
- * The heatmap is the one alternative that fits every leaf type: it answers how
- * regularly rather than how much, and it is the only chart of the set where a
- * missing day is visible instead of being spanned by a line. A group is left
- * out, its chart is structural rather than a preference.
+ * The two alternatives fit every leaf type: the heatmap answers how regularly
+ * rather than how much, and is the only chart of the set where a missing day is
+ * visible instead of being spanned by a line; the delta chart answers which way
+ * it is going, which a line only implies. A group is left out, its chart is
+ * structural rather than a preference.
*/
export function availableChartTypes(type: MetricType): ChartType[] {
- return isGroupMetricType(type) ? [] : [defaultChartType(type), 'heatmap'];
+ return isGroupMetricType(type) ? [] : [defaultChartType(type), 'heatmap', 'delta'];
}
/**
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 742800748..58ccc8c3e 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -63,6 +63,29 @@ describe('MeasurementChart', () => {
expect(screen.getByRole('img')).toBeInTheDocument();
});
+ 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', [
+ entry('d-1', new Date(2026, 0, 5), 30),
+ entry('d-2', new Date(2026, 0, 12), 31),
+ ], 'custom', false, null, 0, 'delta');
+
+ render( );
+
+ expect(screen.getByText(/overallChangeWeight/)).toBeInTheDocument();
+ });
+
+ test('a summed metric has no level to change, so no overall change', () => {
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', [
+ entry('d-1', new Date(2026, 0, 5), 4000),
+ entry('d-2', new Date(2026, 0, 12), 6000),
+ ], 'steps', false, null, 0, 'delta');
+
+ render( );
+
+ expect(screen.queryByText(/overallChangeWeight/)).not.toBeInTheDocument();
+ });
+
test('keeps the derived chart when the pick does not fit the metric type', () => {
// Bars are not offered for a sample type, and a pick that does not fit
// falls back to the derived chart instead of being drawn anyway
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index da7de6ffc..ae6cb8080 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -15,9 +15,11 @@ import {
groupChart,
heatmapDayAt,
measurementSeries,
- StackedPoint
+ moving7dAverage,
+ StackedPoint,
+ weeklyDeltas
} from "@/components/Measurements/charts/data";
-import { componentColor, componentPalette } from "@/components/Measurements/charts/colors";
+import { componentColor, componentPalette, deltaColor } from "@/components/Measurements/charts/colors";
import { MAX_BAR_WIDTH } from "@/components/Measurements/charts/density";
import {
dateTick,
@@ -38,7 +40,7 @@ import { MeasurementSeriesChart } from "@/components/Measurements/widgets/Measur
import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
import React from "react";
import { useTranslation } from "react-i18next";
-import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts";
+import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, Tooltip, XAxis, YAxis } from "recharts";
import { theme } from "@/theme";
import { dateToLocale } from "@/core/lib/date";
@@ -274,6 +276,80 @@ const MeasurementStackedBarChart = (props: {
;
};
+interface DeltaTooltipProps {
+ active?: boolean;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ payload?: any;
+ label?: string;
+ unit: string;
+}
+
+const DeltaTooltip = ({ active, payload, label, unit }: DeltaTooltipProps) => {
+ const [, i18n] = useTranslation();
+
+ if (!active || !payload?.length) {
+ return null;
+ }
+
+ const value = payload[0].value as number;
+
+ return (
+
+ {dateToLocale(new Date(Number(label)))}
+ {/* the plus is ours, only the minus comes out of the number format */}
+ {value > 0 ? '+' : ''}{valueWithUnit(value, unit, i18n.language)}
+
+ );
+};
+
+/**
+ * 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, i18n] = useTranslation();
+
+ if (props.points.length === 0) {
+ return ;
+ }
+
+ const values = props.points.map(point => point.value);
+ const axis = durationAxis(props.unit, Math.min(0, ...values), Math.max(0, ...values));
+
+ return
+
+
+
+ valueWithUnit(value, props.unit, i18n.language)} />
+ } />
+ {/* without the baseline a chart of only decreases reads as a normal one pointing down */}
+
+
+ {props.points.map(point =>
+ | )}
+
+
+ ;
+};
+
/** Widest a heatmap cell gets, and the room its weekday labels need */
const MAX_HEATMAP_CELL = 22;
const WEEKDAY_LABEL_WIDTH = 30;
@@ -462,7 +538,25 @@ export const MeasurementChart = (props: {
// A pick that does not fit the metric type falls back to the derived chart,
// which is also what a category configured on another client gets here
- if (resolveChartType(props.category.metricType, props.category.chartType) === 'heatmap') {
+ const resolved = resolveChartType(props.category.metricType, props.category.chartType);
+
+ if (resolved === 'delta') {
+ const all = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
+
+ return <>
+
+ {/* the one-number version of the bars above; a summed metric has no level to change */}
+ {!summed && }
+ >;
+ }
+
+ if (resolved === 'heatmap') {
// The cells are days, so how a day's readings become one value has to
// be decided here: the summed types are a daily total, the sample types
// are repeated readings of the same thing and average. The points are
diff --git a/src/components/Measurements/widgets/MetricPicker.test.tsx b/src/components/Measurements/widgets/MetricPicker.test.tsx
new file mode 100644
index 000000000..a48169863
--- /dev/null
+++ b/src/components/Measurements/widgets/MetricPicker.test.tsx
@@ -0,0 +1,130 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { render, screen } from '@testing-library/react';
+import userEvent from "@testing-library/user-event";
+import {
+ useAddMeasurementCategoryQuery,
+ useMeasurementsCategoryQuery
+} from "@/components/Measurements/queries";
+import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { NewCategoryPicker } from "@/components/Measurements/widgets/MetricPicker";
+import React from 'react';
+import { TEST_MEASUREMENT_CATEGORY_1 } from "@/tests/measurementsTestData";
+import type { Mock } from 'vitest';
+import { MemoryRouter, useLocation } from "react-router-dom";
+
+vi.mock("@/components/Measurements/api/bodyWeight");
+
+vi.mock("@/components/Measurements/queries");
+
+/** Renders where the picker navigated to */
+const LocationDisplay = () => {useLocation().pathname}
;
+
+describe("Test the NewCategoryPicker component", () => {
+ const queryClient = new QueryClient();
+ let mutate = vi.fn();
+
+ beforeEach(() => {
+ mutate = vi.fn();
+
+ (useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({ mutate: mutate }));
+ (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
+ data: [TEST_MEASUREMENT_CATEGORY_1]
+ }));
+ });
+
+ const renderPicker = (closeFn?: () => void) => render(
+
+
+
+
+
+
+ );
+
+ test('Neither body weight nor the components are offered', () => {
+ // Body weight is the server's category and a component comes with its
+ // group, so neither is something to start here
+
+ // Act
+ renderPicker();
+
+ // Assert
+ expect(screen.getByText('measurements.metricTypes.blood_pressure')).toBeInTheDocument();
+ expect(screen.queryByText('measurements.metricTypes.body_weight')).toBeNull();
+ expect(screen.queryByText('measurements.metricTypes.blood_pressure_systolic')).toBeNull();
+ expect(screen.queryByText('measurements.metricTypes.sleep_deep')).toBeNull();
+ });
+
+ test('Picking a metric creates the category with the name and unit of its type', async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const closeFn = vi.fn();
+
+ // Act
+ renderPicker(closeFn);
+ await user.click(screen.getByText('measurements.metricTypes.resting_heart_rate'));
+
+ // Assert
+ expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(
+ null,
+ 'Resting heart rate',
+ 'bpm',
+ undefined,
+ 'resting_heart_rate',
+ ), expect.anything());
+ });
+
+ test('The new category is opened once the server created it', async () => {
+ // Arrange: react-query hands the created category to onSuccess
+ const user = userEvent.setup();
+ const closeFn = vi.fn();
+ const created = new MeasurementCategory(
+ 'cccccccc-cccc-cccc-cccc-000000000099',
+ 'Distance',
+ 'km',
+ undefined,
+ 'distance',
+ );
+ mutate.mockImplementation((_category, options) => options.onSuccess(created));
+
+ // Act
+ renderPicker(closeFn);
+ await user.click(screen.getByText('measurements.metricTypes.distance'));
+
+ // Assert
+ expect(closeFn).toHaveBeenCalled();
+ expect(screen.getByTestId('location')).toHaveTextContent(
+ `/measurement/category/${created.id}`
+ );
+ });
+
+ test('A metric that already has a category cannot be picked again', () => {
+ // Arrange
+ (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
+ data: [MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1, {
+ metricType: 'heart_rate',
+ })]
+ }));
+
+ // Act
+ renderPicker();
+
+ // Assert
+ expect(screen.getByText('measurements.metricAlreadyTracked')).toBeInTheDocument();
+ expect(screen.getByText('measurements.metricTypes.heart_rate').closest('[role="button"]'))
+ .toHaveAttribute('aria-disabled', 'true');
+ });
+
+ test('A custom measurement leads into the form', async () => {
+ // Arrange
+ const user = userEvent.setup();
+
+ // Act
+ renderPicker();
+ await user.click(screen.getByText('measurements.customMeasurement'));
+
+ // Assert
+ expect(await screen.findByLabelText('name')).toBeInTheDocument();
+ expect(mutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/components/Measurements/widgets/MetricPicker.tsx b/src/components/Measurements/widgets/MetricPicker.tsx
new file mode 100644
index 000000000..1ae7a4108
--- /dev/null
+++ b/src/components/Measurements/widgets/MetricPicker.tsx
@@ -0,0 +1,90 @@
+import React from "react";
+import { Divider, List, ListItemButton, ListItemIcon, ListItemText } from "@mui/material";
+import StraightenIcon from "@mui/icons-material/Straighten";
+import { useTranslation } from "react-i18next";
+import {
+ defaultsForMetricType,
+ isPickableMetricType,
+ MeasurementCategory,
+ METRIC_TYPES,
+ MetricType
+} from "@/components/Measurements/models/Category";
+import {
+ useAddMeasurementCategoryQuery,
+ useMeasurementsCategoryQuery
+} from "@/components/Measurements/queries";
+import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
+import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
+import { makeLink, WgerLink } from "@/core/lib/url";
+import { useNavigate } from "react-router-dom";
+
+/**
+ * Starts a new measurement category: either one of the known metrics or a
+ * free-form one.
+ *
+ * A known metric needs no form, its name, unit and chart follow from the metric
+ * type. The type is also what the health import and the value limits hang off
+ * and cannot be changed afterwards, so it is picked here instead of being one
+ * field among others.
+ */
+export const NewCategoryPicker = ({ closeFn }: { closeFn?: () => void }) => {
+
+ const [t, i18n] = useTranslation();
+ const navigate = useNavigate();
+ const [isCustom, setIsCustom] = React.useState(false);
+ const categoryQuery = useMeasurementsCategoryQuery({ entries: 'none' });
+ const addCategoryQuery = useAddMeasurementCategoryQuery();
+
+ if (isCustom) {
+ return ;
+ }
+
+ if (categoryQuery.isLoading) {
+ return ;
+ }
+
+ const taken = new Set((categoryQuery.data ?? []).map(c => c.metricType));
+
+ return
+ {METRIC_TYPES.filter(isPickableMetricType).map((metricType: MetricType) => {
+ const defaults = defaultsForMetricType(metricType);
+ return addCategoryQuery.mutate(
+ new MeasurementCategory(null, defaults.name, defaults.unit, undefined, metricType),
+ {
+ // Straight to the new category: the overview is long
+ // enough that a row appearing somewhere in it does not
+ // read as "something happened"
+ onSuccess: category => {
+ closeFn?.();
+ navigate(makeLink(
+ WgerLink.MEASUREMENT_DETAIL,
+ i18n.language,
+ { id: category.id! },
+ ));
+ },
+ },
+ )}
+ >
+
+ ;
+ })}
+
+ setIsCustom(true)}>
+
+
+
+
+
+
;
+};
From 2297f8a691f997c478720754c5693e6d4193ea61 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Wed, 5 Aug 2026 18:33:10 +0200
Subject: [PATCH 48/71] Read duration deltas in hours and minutes
---
.../screens/MeasurementCategoryOverview.tsx | 3 ++-
.../widgets/CategoryDetailDataGrid.test.tsx | 25 +++++++++++++++++++
.../widgets/CategoryDetailDataGrid.tsx | 9 ++++++-
3 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index bb5bbbafb..a0729bdca 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -6,6 +6,7 @@ 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 { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
@@ -28,7 +29,7 @@ export const CategoryList = (props: { category: MeasurementCategory, range: Char
return <>
-
+
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 62c272f85..840836d9c 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -58,6 +58,31 @@ describe('CategoryDetailDataGrid', () => {
expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument();
});
+ test('a duration reads h:mm, in the value and in the change columns', async () => {
+ const category = new MeasurementCategory(
+ CATEGORY_UUID,
+ 'Total sleep',
+ 'min',
+ [
+ new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 480, '', 'user'),
+ new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 437, '', 'apple'),
+ ],
+ );
+
+ render(
+
+
+
+ );
+ await screen.findByText('8:00 h');
+
+ const laterRow = document.querySelector(`[data-id="${SYNCED_ENTRY_UUID}"]`) as HTMLElement;
+ const cell = (field: string) => laterRow.querySelector(`[data-field="${field}"]`)!.textContent;
+ expect(cell('value')).toBe('7:17 h');
+ expect(cell('change')).toBe('-0:43');
+ expect(cell('totalChange')).toBe('-0:43');
+ });
+
/*
* Body weight is the one category whose entries can be stored in a unit
* other than the one they are shown in
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index 0b4f374f8..a43bae3ab 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -1,5 +1,5 @@
import { processTimeSeries } from "@/core/lib/timeSeries";
-import { valueWithUnit } from "@/components/Measurements/charts/format";
+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";
@@ -184,6 +184,10 @@ export const CategoryDetailDataGrid = (props: {
type: 'number',
width: 120,
editable: false,
+ // a duration delta reads h:mm like the value it changes
+ valueFormatter: (value?: number) => value == null
+ ? ''
+ : valueOnly(value, unit, i18n.language),
},
{
field: 'totalChange',
@@ -191,6 +195,9 @@ export const CategoryDetailDataGrid = (props: {
type: 'number',
width: 140,
editable: false,
+ valueFormatter: (value?: number) => value == null
+ ? ''
+ : valueOnly(value, unit, i18n.language),
},
{
field: 'days',
From 4958cd556ba8bcedcf0dad067352cef960cb31a2 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Wed, 5 Aug 2026 19:22:17 +0200
Subject: [PATCH 49/71] Add distribution histogram chart type
---
public/locales/de/translation.json | 9 +-
public/locales/en/translation.json | 9 +-
public/locales/es/translation.json | 11 +-
public/locales/fr/translation.json | 11 +-
.../Measurements/charts/data.test.ts | 76 ++++++++
src/components/Measurements/charts/data.ts | 93 ++++++++++
.../Measurements/models/Category.test.ts | 27 ++-
.../Measurements/models/Category.ts | 61 ++++++-
.../widgets/MeasurementChart.test.tsx | 97 ++++++++++-
.../Measurements/widgets/MeasurementChart.tsx | 163 ++++++++++++++++++
10 files changed, 545 insertions(+), 12 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 040e54a68..11c26456c 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -262,8 +262,15 @@
"line": "Linie",
"bar": "Balken",
"heatmap": "Heatmap",
- "delta": "Veränderung"
+ "delta": "Veränderung",
+ "distribution": "Verteilung"
},
+ "distributionMedian": "Median",
+ "distributionLatest": "Aktuell",
+ "distributionEntryCount_one": "1 Eintrag",
+ "distributionEntryCount_other": "{{count}} Einträge",
+ "distributionDayCount_one": "1 Tag",
+ "distributionDayCount_other": "{{count}} Tage",
"partOfGroup": "Teil der Gruppe",
"noGroup": "Keine Gruppe",
"metricTypes": {
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 8cca6cca2..783dec4d5 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -346,8 +346,15 @@
"line": "Line",
"bar": "Bars",
"heatmap": "Heatmap",
- "delta": "Change"
+ "delta": "Change",
+ "distribution": "Distribution"
},
+ "distributionMedian": "Median",
+ "distributionLatest": "Latest",
+ "distributionEntryCount_one": "1 entry",
+ "distributionEntryCount_other": "{{count}} entries",
+ "distributionDayCount_one": "1 day",
+ "distributionDayCount_other": "{{count}} days",
"partOfGroup": "Part of group",
"noGroup": "No group",
"metricTypes": {
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 2d73a5f51..77613038c 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -264,8 +264,17 @@
"line": "Línea",
"bar": "Barras",
"heatmap": "Mapa de calor",
- "delta": "Variación"
+ "delta": "Variación",
+ "distribution": "Distribución"
},
+ "distributionMedian": "Mediana",
+ "distributionLatest": "Actual",
+ "distributionEntryCount_one": "1 entrada",
+ "distributionEntryCount_other": "{{count}} entradas",
+ "distributionEntryCount_many": "{{count}} entradas",
+ "distributionDayCount_one": "1 día",
+ "distributionDayCount_other": "{{count}} días",
+ "distributionDayCount_many": "{{count}} días",
"partOfGroup": "Parte del grupo",
"noGroup": "Sin grupo",
"metricTypes": {
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index 30a716013..d00343833 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -347,8 +347,17 @@
"line": "Ligne",
"bar": "Barres",
"heatmap": "Carte thermique",
- "delta": "Variation"
+ "delta": "Variation",
+ "distribution": "Répartition"
},
+ "distributionMedian": "Médiane",
+ "distributionLatest": "Actuel",
+ "distributionEntryCount_one": "1 entrée",
+ "distributionEntryCount_other": "{{count}} entrées",
+ "distributionEntryCount_many": "{{count}} entrées",
+ "distributionDayCount_one": "1 jour",
+ "distributionDayCount_other": "{{count}} jours",
+ "distributionDayCount_many": "{{count}} jours",
"partOfGroup": "Fait partie du groupe",
"noGroup": "Aucun groupe",
"metricTypes": {
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 9a28ce40e..a2cd2a209 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -4,6 +4,7 @@ import {
aggregatePerDay,
averagePerDay,
buildHeatmapGrid,
+ buildHistogram,
chartPointsFor,
downsample,
fillMissingDays,
@@ -14,6 +15,7 @@ import {
groupRangeEntries,
groupStackedEntries,
moving7dAverage,
+ niceBinWidth,
overallChange,
smoothedTrendline,
stackableComponents,
@@ -312,6 +314,80 @@ describe('weeklyDeltas', () => {
});
});
+describe('niceBinWidth', () => {
+ test('rounds the span split into ~20 bins up to 1, 2 or 5 times a power of ten', () => {
+ // span 14.6 / 20 = 0.73 -> 1, not an edge like 59.3-61.3
+ expect(niceBinWidth(59.3, 73.9)).toBe(1);
+ // span 30000 / 20 = 1500 -> 2000
+ expect(niceBinWidth(0, 30000)).toBe(2000);
+ // span 9 / 20 = 0.45 -> 0.5
+ expect(niceBinWidth(1, 10)).toBe(0.5);
+ });
+
+ test('a span of nothing still has a width', () => {
+ expect(niceBinWidth(80, 80)).toBe(1);
+ });
+});
+
+describe('buildHistogram', () => {
+ test('aligns the bin edges to round multiples of the width', () => {
+ const result = buildHistogram([point(day(1), 79.7), point(day(2), 82.3)], 0.5);
+
+ expect(result.firstEdge).toBe(79.5);
+ expect(result.firstEdge + result.counts.length * result.binWidth).toBe(82.5);
+ });
+
+ test('keeps empty bins between the occupied ones, a gap is information', () => {
+ const result = buildHistogram(
+ [point(day(1), 60), point(day(2), 61), point(day(3), 65)],
+ 2,
+ );
+
+ expect(result.counts).toEqual([2, 0, 1]);
+ });
+
+ test('takes the median of the values, odd and even', () => {
+ const odd = buildHistogram(
+ [point(day(1), 60), point(day(2), 62), point(day(3), 70)],
+ 2,
+ );
+ expect(odd.median).toBe(62);
+
+ const even = buildHistogram(
+ [point(day(1), 60), point(day(2), 63), point(day(3), 65), point(day(4), 70)],
+ 2,
+ );
+ expect(even.median).toBe(64);
+ });
+
+ test('the latest value follows the dates, not the array order', () => {
+ const result = buildHistogram(
+ [point(day(3), 70), point(day(5), 60), point(day(1), 65)],
+ 5,
+ );
+
+ expect(result.latest).toBe(60);
+ });
+
+ test('derives a width from the span when the type brings none', () => {
+ const result = buildHistogram([point(day(1), 59.3), point(day(2), 73.9)]);
+
+ expect(result.binWidth).toBe(1);
+ });
+
+ test('doubles the width until an outlier no longer stretches it into hundreds of bins', () => {
+ // 20 to 350 at 0.5 kg would be 661 bins; doubling keeps the edges round
+ const result = buildHistogram(
+ [point(day(1), 20), point(day(2), 80), point(day(3), 350)],
+ 0.5,
+ );
+
+ expect(result.binWidth).toBe(4);
+ expect(result.counts.length).toBeLessThanOrEqual(100);
+ expect(result.counts.reduce((sum, count) => sum + count, 0)).toBe(3);
+ });
+});
+
describe('buildHeatmapGrid', () => {
// 2 March 2026 is a Monday, 18 March a Wednesday
const monday = new Date(2026, 2, 2);
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 5fe29a690..253b91fe4 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -308,6 +308,99 @@ export const weeklyDeltas = (
}));
};
+/**
+ * Fewest values a distribution says anything about: below this a histogram is
+ * noise with gaps, and the chart falls back to the derived default. Same
+ * principle as a group whose readings are all unpaired falling back to lines:
+ * never an empty or misleading card.
+ */
+export const DISTRIBUTION_MIN_VALUES = 15;
+
+/**
+ * Widest a histogram gets, in bins. A single outlier (a lb reading stored into
+ * a kg category) would otherwise stretch a fixed-width histogram into hundreds
+ * of near-empty bins.
+ */
+const DISTRIBUTION_MAX_BINS = 100;
+
+/**
+ * A bin width for values nothing is known about: the span split into
+ * targetBins, rounded up to 1, 2 or 5 times a power of ten so the edges land
+ * on round numbers. For the typed metrics the maintained widths in binWidthFor
+ * are used instead.
+ */
+export const niceBinWidth = (min: number, max: number, targetBins: number = 20): number => {
+ const span = max - min;
+ if (span <= 0) {
+ return 1;
+ }
+
+ const raw = span / targetBins;
+ const magnitude = Math.pow(10, Math.floor(Math.log10(raw)));
+ const normalized = raw / magnitude;
+
+ return (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude;
+};
+
+/**
+ * A distribution: the values of a period binned by size instead of plotted
+ * over time, which is what shows the spread and the outliers.
+ */
+export interface Histogram {
+ /**
+ * Lower edge of the first bin, a multiple of binWidth so the edges land on
+ * round numbers (60-62, not 59.3-61.3)
+ */
+ firstEdge: number;
+ binWidth: number;
+ /**
+ * How many values each bin holds. Bins between the occupied ones are
+ * present with a zero: a gap in the distribution is worth seeing.
+ */
+ counts: number[];
+ /** Median of the binned values */
+ median: number;
+ /** The newest value, i.e. where in the distribution the user is today */
+ latest: number;
+}
+
+/**
+ * Bins the points into a histogram of binWidth-wide bins aligned to round
+ * boundaries; without a width (free-form categories) one is derived from the
+ * span, see niceBinWidth.
+ *
+ * What one value stands for (a reading, a daily total) is the caller's
+ * decision, the same split as for the heatmap: the summed types distribute
+ * their days, the sample types every reading.
+ */
+export const buildHistogram = (points: ChartPoint[], binWidth?: number): Histogram => {
+ const values = points.map(point => point.value).sort((a, b) => a - b);
+ const minValue = values[0];
+ const maxValue = values[values.length - 1];
+
+ let width = binWidth ?? niceBinWidth(minValue, maxValue);
+ // Doubling keeps the edges round, unlike recomputing a fitted width
+ while (Math.floor(maxValue / width) - Math.floor(minValue / width) >= DISTRIBUTION_MAX_BINS) {
+ width *= 2;
+ }
+
+ const firstBin = Math.floor(minValue / width);
+ const counts = new Array(Math.floor(maxValue / width) - firstBin + 1).fill(0);
+ for (const value of values) {
+ counts[Math.floor(value / width) - firstBin]++;
+ }
+
+ const middle = Math.floor(values.length / 2);
+
+ return {
+ firstEdge: firstBin * width,
+ binWidth: width,
+ counts: counts,
+ median: values.length % 2 === 1 ? values[middle] : (values[middle - 1] + values[middle]) / 2,
+ latest: points.reduce((a, b) => b.date > a.date ? b : a).value,
+ };
+};
+
/** The day in column week, row weekday (0 = Monday), as a local-midnight timestamp */
export const heatmapDayAt = (grid: HeatmapGrid, week: number, weekday: number): number =>
shiftDays(new Date(grid.start), week * DAYS_PER_WEEK + weekday).getTime();
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 758f08819..6dc800cf0 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -1,5 +1,6 @@
import {
availableChartTypes,
+ binWidthFor,
categoryDisplayName,
isComponentMetricType,
isGroupMetricType,
@@ -157,8 +158,10 @@ describe('MeasurementCategory', () => {
});
test('the offered types follow the metric type', () => {
- expect(availableChartTypes('steps')).toEqual(['bar', 'heatmap', 'delta']);
- expect(availableChartTypes('custom')).toEqual(['line', 'heatmap', 'delta']);
+ expect(availableChartTypes('steps'))
+ .toEqual(['bar', 'heatmap', 'delta', 'distribution']);
+ expect(availableChartTypes('custom'))
+ .toEqual(['line', 'heatmap', 'delta', 'distribution']);
// a group is drawn by what its components are to each other
expect(availableChartTypes('blood_pressure')).toEqual([]);
@@ -174,6 +177,26 @@ describe('MeasurementCategory', () => {
expect(resolveChartType('custom', 'heatmap')).toBe('heatmap');
expect(resolveChartType('steps', 'bar')).toBe('bar');
expect(resolveChartType('body_weight', 'delta')).toBe('delta');
+ expect(resolveChartType('resting_heart_rate', 'distribution')).toBe('distribution');
+ });
+ });
+
+ describe('binWidthFor', () => {
+
+ test('body weight follows the unit, like its limits do', () => {
+ expect(binWidthFor('body_weight', 'kg')).toBe(0.5);
+ expect(binWidthFor('body_weight', 'lb')).toBe(1);
+ });
+
+ test('the typed metrics carry a fixed width', () => {
+ expect(binWidthFor('resting_heart_rate')).toBe(1);
+ expect(binWidthFor('steps')).toBe(1000);
+ expect(binWidthFor('sleep_total')).toBe(30);
+ });
+
+ test('free-form categories and groups have none, theirs follows the data', () => {
+ expect(binWidthFor('custom')).toBeUndefined();
+ expect(binWidthFor('blood_pressure')).toBeUndefined();
});
});
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 2aaf7a52b..c184030ef 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -39,7 +39,7 @@ export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight';
* (two components) and a stacked bar (a summed group) follow from what the
* group is and are not choices.
*/
-export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap', 'delta'] as const;
+export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap', 'delta', 'distribution'] as const;
export type ChartType = typeof CHART_TYPES[number];
/**
@@ -106,14 +106,17 @@ export function defaultChartType(type: MetricType): ChartType {
* The chart types a category of this metric type may be drawn as, i.e. what
* the picker offers on top of 'auto'.
*
- * The two alternatives fit every leaf type: the heatmap answers how regularly
+ * The alternatives fit every leaf type: the heatmap answers how regularly
* rather than how much, and is the only chart of the set where a missing day is
* visible instead of being spanned by a line; the delta chart answers which way
- * it is going, which a line only implies. A group is left out, its chart is
- * structural rather than a preference.
+ * it is going, which a line only implies; the distribution answers what is
+ * normal and what is an outlier, which no chart over time shows. A group is
+ * left out, its chart is structural rather than a preference.
*/
export function availableChartTypes(type: MetricType): ChartType[] {
- return isGroupMetricType(type) ? [] : [defaultChartType(type), 'heatmap', 'delta'];
+ return isGroupMetricType(type)
+ ? []
+ : [defaultChartType(type), 'heatmap', 'delta', 'distribution'];
}
/**
@@ -281,6 +284,54 @@ export function limitsFor(type: MetricType, unit?: string): MetricLimits {
return METRIC_LIMITS[type] ?? { min: 0, max: MEASUREMENT_SCHEMA_MAX_VALUE };
}
+/**
+ * Width of one distribution-histogram bin per metric type, in the unit the
+ * type is stored in.
+ *
+ * Fixed per type rather than computed (Freedman-Diaconis and friends): a
+ * computed width changes with every range switch, which makes two looks at the
+ * same category incomparable, and it lands on edges like 0.73 kg where a
+ * maintained table lands on round ones.
+ *
+ * MUST stay identical to MetricType.binWidth in flutter, or the same category
+ * bins differently per client.
+ */
+/* eslint-disable camelcase */
+const BIN_WIDTHS: Partial> = {
+ body_fat: 0.5,
+ height: 1,
+ blood_pressure_systolic: 5,
+ blood_pressure_diastolic: 5,
+ heart_rate: 2,
+ resting_heart_rate: 1,
+ steps: 1000,
+ distance: 1,
+ energy: 100,
+ sleep_total: 30,
+ sleep_light: 15,
+ sleep_deep: 15,
+ sleep_rem: 15,
+ sleep_awake: 15,
+};
+/* eslint-enable camelcase */
+
+/** Body weight bins follow the unit, like its limits do */
+const BODY_WEIGHT_BIN_WIDTHS: Record = { kg: 0.5, lb: 1 };
+
+/**
+ * Width of one histogram bin for a category of this metric type, undefined for
+ * the types nothing is known about (free-form categories, and the groups,
+ * which are never drawn as a distribution): their width is derived from the
+ * data instead.
+ */
+export function binWidthFor(type: MetricType, unit?: string): number | undefined {
+ if (type === METRIC_TYPE_BODY_WEIGHT) {
+ return BODY_WEIGHT_BIN_WIDTHS[isWeightUnit(unit) ? unit : 'kg'];
+ }
+
+ return BIN_WIDTHS[type];
+}
+
/**
* 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/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 58ccc8c3e..e5063c63c 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
@@ -86,6 +86,101 @@ describe('MeasurementChart', () => {
expect(screen.queryByText(/overallChangeWeight/)).not.toBeInTheDocument();
});
+ test('draws a distribution histogram when the category asks for one', () => {
+ const category = new MeasurementCategory(
+ 'c-1', 'Biceps', 'cm',
+ Array.from(
+ { length: 20 },
+ (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30 + i % 3),
+ ),
+ 'custom', false, null, 0, 'distribution',
+ );
+
+ render( );
+
+ // Plain elements like the heatmap, so the bars render in jsdom
+ const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
+ expect(screen.getByText(/distributionMedian/)).toBeInTheDocument();
+
+ // Hovering a bin swaps the read-out to that bin's range and count
+ fireEvent.mouseEnter(chart.firstChild!.firstChild as Element);
+ expect(screen.getByText(/distributionEntryCount/)).toBeInTheDocument();
+ });
+
+ test('a summed distribution counts days and reads out as days', () => {
+ const category = new MeasurementCategory(
+ 'c-1', 'Steps', 'steps',
+ Array.from(
+ { length: 20 },
+ (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 4000 + 100 * (i % 5)),
+ ),
+ 'steps', false, null, 0, 'distribution',
+ );
+
+ render( );
+
+ const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
+ fireEvent.mouseEnter(chart.firstChild!.firstChild as Element);
+ expect(screen.getByText(/distributionDayCount/)).toBeInTheDocument();
+ });
+
+ test('a selection from before the data changed is dropped, not read out of range', () => {
+ // 20 distinct values spread over 20 bins, then the same category
+ // shrunk to a single bin while the last bin is still hovered
+ const wide = new MeasurementCategory(
+ 'c-1', 'Biceps', 'cm',
+ Array.from({ length: 20 }, (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30 + i)),
+ 'custom', false, null, 0, 'distribution',
+ );
+ const narrow = new MeasurementCategory(
+ 'c-1', 'Biceps', 'cm',
+ Array.from({ length: 20 }, (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30)),
+ 'custom', false, null, 0, 'distribution',
+ );
+
+ const { rerender } = render( );
+ const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
+ fireEvent.mouseEnter(chart.firstChild!.lastChild as Element);
+ expect(screen.getByText(/distributionEntryCount/)).toBeInTheDocument();
+
+ rerender( );
+
+ expect(screen.queryByText(/distributionEntryCount/)).not.toBeInTheDocument();
+ expect(screen.getByText(/distributionMedian/)).toBeInTheDocument();
+ });
+
+ test('too few values fall back to the derived chart instead of a noise histogram', () => {
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm', [
+ entry('d-1', new Date(2026, 0, 5), 30),
+ entry('d-2', new Date(2026, 0, 12), 31),
+ ], 'custom', false, null, 0, 'distribution');
+
+ render( );
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
+ test('a summed distribution measures its days, not its samples', () => {
+ // 30 samples on 4 days are 4 daily totals: not enough for a
+ // histogram, whatever the sample count says
+ const category = new MeasurementCategory(
+ 'c-1', 'Steps', 'steps',
+ Array.from(
+ { length: 30 },
+ (_, i) => entry(
+ `d-${i}`,
+ new Date(2026, 0, 1 + (i % 4), 8 + Math.floor(i / 4)),
+ 500,
+ ),
+ ),
+ 'steps', false, null, 0, 'distribution',
+ );
+
+ render( );
+
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+ });
+
test('keeps the derived chart when the pick does not fit the metric type', () => {
// Bars are not offered for a sample type, and a pick that does not fit
// falls back to the derived chart instead of being drawn anyway
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index ae6cb8080..517bbd1c2 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -1,5 +1,6 @@
import { alpha, Box, Paper, Typography } from "@mui/material";
import {
+ binWidthFor,
categoryDisplayName,
isSummedPerDay,
MeasurementCategory,
@@ -9,8 +10,10 @@ import {
aggregatePerDay,
averagePerDay,
buildHeatmapGrid,
+ buildHistogram,
chartPointsFor,
DAYS_PER_WEEK,
+ DISTRIBUTION_MIN_VALUES,
fillMissingDays,
groupChart,
heatmapDayAt,
@@ -350,6 +353,143 @@ const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string })
;
};
+/**
+ * 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: {
+ points: ChartPoint[],
+ unit: string,
+ binWidth?: number,
+ countsAreDays?: boolean,
+}) => {
+ const [t, i18n] = useTranslation();
+ const [selected, setSelected] = React.useState(null);
+
+ if (props.points.length === 0) {
+ return ;
+ }
+
+ const histogram = buildHistogram(props.points, 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;
@@ -556,6 +696,29 @@ export const MeasurementChart = (props: {
>;
}
+ if (resolved === 'distribution') {
+ // What is binned mirrors the heatmap's split: the summed types
+ // distribute their daily totals, the sample types every reading (three
+ // weigh-ins on one day are all part of the distribution). Deliberately
+ // not condensed on the way: bucket means would narrow the very spread
+ // the histogram exists to show
+ const points = pointsSince(
+ chartPointsFor(props.category.entries, props.category.unit, props.category.unit),
+ cutoff,
+ );
+ const values = summed ? aggregatePerDay(points) : points;
+
+ // A histogram of a handful of values is noise with gaps, so too few
+ // fall through to the derived default chart below
+ if (values.length >= DISTRIBUTION_MIN_VALUES) {
+ return ;
+ }
+ }
+
if (resolved === 'heatmap') {
// The cells are days, so how a day's readings become one value has to
// be decided here: the summed types are a daily total, the sample types
From a0a9d5baf42af830cf1f00287bfed6e24c01b05d Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Wed, 5 Aug 2026 21:29:37 +0200
Subject: [PATCH 50/71] Add per-category chart settings (trend, average window)
---
public/locales/de/translation.json | 9 ++
public/locales/en/translation.json | 9 ++
public/locales/es/translation.json | 10 ++
public/locales/fr/translation.json | 10 ++
src/components/Dashboard/WeightCard.tsx | 1 +
.../Measurements/api/measurements.test.ts | 2 +
.../Measurements/charts/data.test.ts | 22 ++--
src/components/Measurements/charts/data.ts | 23 ++--
.../Measurements/models/Category.test.ts | 49 +++++++-
.../Measurements/models/Category.ts | 71 +++++++++++
.../Measurements/screens/BodyWeight.tsx | 3 +-
.../widgets/CategoryForm.test.tsx | 117 +++++++++++++++++-
.../Measurements/widgets/CategoryForm.tsx | 92 +++++++++++++-
.../Measurements/widgets/MeasurementChart.tsx | 12 +-
.../Measurements/widgets/WeightChart.tsx | 7 +-
.../widgets/charts/PlanWeightChart.tsx | 1 +
16 files changed, 412 insertions(+), 26 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 11c26456c..0c569986f 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -265,6 +265,15 @@
"delta": "Veränderung",
"distribution": "Verteilung"
},
+ "chartTrend": "Trendlinie",
+ "trends": {
+ "reactive": "Reaktiv",
+ "balanced": "Ausgewogen",
+ "sluggish": "Geglättet"
+ },
+ "chartAverageWindow": "Durchschnitt über",
+ "chartAverageWindowDays_one": "1 Tag",
+ "chartAverageWindowDays_other": "{{count}} Tage",
"distributionMedian": "Median",
"distributionLatest": "Aktuell",
"distributionEntryCount_one": "1 Eintrag",
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index 783dec4d5..a4a3a3531 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -349,6 +349,15 @@
"delta": "Change",
"distribution": "Distribution"
},
+ "chartTrend": "Trend line",
+ "trends": {
+ "reactive": "Reactive",
+ "balanced": "Balanced",
+ "sluggish": "Smooth"
+ },
+ "chartAverageWindow": "Average over",
+ "chartAverageWindowDays_one": "1 day",
+ "chartAverageWindowDays_other": "{{count}} days",
"distributionMedian": "Median",
"distributionLatest": "Latest",
"distributionEntryCount_one": "1 entry",
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index 77613038c..eb320c925 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -267,6 +267,16 @@
"delta": "Variación",
"distribution": "Distribución"
},
+ "chartTrend": "Línea de tendencia",
+ "trends": {
+ "reactive": "Reactiva",
+ "balanced": "Equilibrada",
+ "sluggish": "Suavizada"
+ },
+ "chartAverageWindow": "Promedio de",
+ "chartAverageWindowDays_one": "1 día",
+ "chartAverageWindowDays_other": "{{count}} días",
+ "chartAverageWindowDays_many": "{{count}} días",
"distributionMedian": "Mediana",
"distributionLatest": "Actual",
"distributionEntryCount_one": "1 entrada",
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index d00343833..df06d0a10 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -350,6 +350,16 @@
"delta": "Variation",
"distribution": "Répartition"
},
+ "chartTrend": "Courbe de tendance",
+ "trends": {
+ "reactive": "Réactive",
+ "balanced": "Équilibrée",
+ "sluggish": "Lissée"
+ },
+ "chartAverageWindow": "Moyenne sur",
+ "chartAverageWindowDays_one": "1 jour",
+ "chartAverageWindowDays_other": "{{count}} jours",
+ "chartAverageWindowDays_many": "{{count}} jours",
"distributionMedian": "Médiane",
"distributionLatest": "Actuel",
"distributionEntryCount_one": "1 entrée",
diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx
index ea98ad47b..1aa069f5c 100644
--- a/src/components/Dashboard/WeightCard.tsx
+++ b/src/components/Dashboard/WeightCard.tsx
@@ -66,6 +66,7 @@ export const WeightCardContent = (props: { entries: MeasurementEntry[] }) => {
weights={props.entries}
unit={displayUnit}
categoryUnit={categoryUnit}
+ chartConfig={categoryQuery.data?.chartConfig}
height={200} />
{
unit: "%",
metric_type: "custom",
chart_type: null,
+ chart_config: {},
parent: null,
order: 0
});
@@ -303,6 +304,7 @@ describe('measurement service tests', () => {
unit: "%",
metric_type: "custom",
chart_type: null,
+ chart_config: {},
parent: null,
order: 0
});
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index a2cd2a209..1d01a7903 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -14,7 +14,7 @@ import {
groupComponentSeries,
groupRangeEntries,
groupStackedEntries,
- moving7dAverage,
+ movingAverage,
niceBinWidth,
overallChange,
smoothedTrendline,
@@ -73,13 +73,13 @@ describe('chartPointsFor', () => {
});
});
-describe('moving7dAverage', () => {
+describe('movingAverage', () => {
test('returns an empty series unchanged', () => {
- expect(moving7dAverage([])).toEqual([]);
+ expect(movingAverage([])).toEqual([]);
});
test('averages over the 7 days preceding each point', () => {
- const result = moving7dAverage([
+ const result = movingAverage([
point(day(1), 10),
point(day(2), 20),
point(day(3), 30),
@@ -89,7 +89,7 @@ describe('moving7dAverage', () => {
});
test('drops points that fell out of the window', () => {
- const result = moving7dAverage([
+ const result = movingAverage([
point(day(1), 10),
point(day(20), 30),
point(day(21), 50),
@@ -100,13 +100,21 @@ describe('moving7dAverage', () => {
});
test('sorts the input before averaging', () => {
- const result = moving7dAverage([point(day(2), 20), point(day(1), 10)]);
+ const result = movingAverage([point(day(2), 20), point(day(1), 10)]);
expect(result.map(p => p.value)).toEqual([10, 15]);
});
+ test('a wider window reaches further back', () => {
+ const points = [point(day(1), 10), point(day(12), 20)];
+
+ // the first point is outside 7 days but inside 14
+ expect(movingAverage(points, 7).map(p => p.value)).toEqual([10, 20]);
+ expect(movingAverage(points, 14).map(p => p.value)).toEqual([10, 15]);
+ });
+
test('carries no range, an average has no spread of its own', () => {
- const result = moving7dAverage([{ date: day(1).getTime(), value: 10, min: 5, max: 15 }]);
+ const result = movingAverage([{ date: day(1).getTime(), value: 10, min: 5, max: 15 }]);
expect(result[0]).toStrictEqual({ date: day(1).getTime(), value: 10 });
});
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 253b91fe4..f8d8df42e 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -1,7 +1,10 @@
import {
+ averageWindowOf,
+ ChartConfig,
isGroupTotalMetricType,
isSummedPerDay,
- MeasurementCategory
+ MeasurementCategory,
+ trendPeriodOf
} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { pointsSince } from "@/components/Measurements/charts/range";
@@ -10,8 +13,8 @@ import { calculateEMA } from "@/core/lib/ema";
const DAY_MS = 24 * 60 * 60 * 1000;
-/** Length of the moving average window */
-const AVERAGE_WINDOW_DAYS = 7;
+/** Length of the moving average window for a category that configured none */
+const DEFAULT_AVERAGE_WINDOW_DAYS = 7;
/** Point count above which a series is condensed, see downsample */
export const MAX_CHART_POINTS = 200;
@@ -50,13 +53,16 @@ export const chartPointsFor = (
});
/**
- * For each point, the average of all points in the 7 days preceding it.
+ * For each point, the average of all points in the given days preceding it.
*
* The window total is carried along instead of re-summing the window for every
* point: with densely sampled metrics the window holds thousands of values,
* and re-adding them each time makes this quadratic.
*/
-export const moving7dAverage = (points: ChartPoint[]): ChartPoint[] => {
+export const movingAverage = (
+ points: ChartPoint[],
+ days: number = DEFAULT_AVERAGE_WINDOW_DAYS,
+): ChartPoint[] => {
const sorted = [...points].sort((a, b) => a.date - b.date);
const out: ChartPoint[] = [];
let start = 0;
@@ -67,7 +73,7 @@ export const moving7dAverage = (points: ChartPoint[]): ChartPoint[] => {
// Users log measurements days or minutes apart, so the start of the
// window has to be advanced by date, not by a fixed number of points
- const windowStart = sorted[end].date - AVERAGE_WINDOW_DAYS * DAY_MS;
+ const windowStart = sorted[end].date - days * DAY_MS;
while (start < end && sorted[start].date < windowStart) {
sum -= sorted[start].value;
start++;
@@ -555,12 +561,13 @@ export const measurementSeries = (
targetUnit: string,
categoryUnit: string,
cutoff: Date | null = null,
+ config: ChartConfig = {},
): ChartSeries[] => {
const all = chartPointsFor(entries, targetUnit, categoryUnit);
// 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(moving7dAverage(all), cutoff);
+ const average = pointsSince(movingAverage(all, averageWindowOf(config)), cutoff);
const points = pointsSince(all, cutoff);
const condensed = downsample(points);
@@ -575,7 +582,7 @@ export const measurementSeries = (
return [
raw,
{ points: downsample(average), role: 'average' },
- { points: smoothedTrendline(condensed), role: 'trend' },
+ { points: smoothedTrendline(condensed, trendPeriodOf(config)), role: 'trend' },
];
};
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 6dc800cf0..44948c249 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -1,5 +1,6 @@
import {
availableChartTypes,
+ averageWindowOf,
binWidthFor,
categoryDisplayName,
isComponentMetricType,
@@ -9,7 +10,10 @@ import {
MEASUREMENT_SCHEMA_MAX_VALUE,
MeasurementCategory,
metricTypeFromApi,
- resolveChartType
+ resolveChartType,
+ TrendCharacter,
+ trendOf,
+ trendPeriodOf
} from "./Category";
describe('MeasurementCategory', () => {
@@ -49,6 +53,7 @@ describe('MeasurementCategory', () => {
metric_type: 'steps',
chart_type: null,
+ chart_config: {},
parent: null,
order: 2,
});
@@ -181,6 +186,48 @@ describe('MeasurementCategory', () => {
});
});
+ describe('chart config', () => {
+
+ test('an unconfigured category gets the defaults', () => {
+ expect(trendOf({})).toBe('balanced');
+ expect(averageWindowOf({})).toBe(7);
+ });
+
+ test('reads what was configured', () => {
+ expect(trendOf({ trend: 'sluggish' })).toBe('sluggish');
+ expect(averageWindowOf({ average_window: 30 })).toBe(30);
+ });
+
+ test('a value this release does not know falls back to the default', () => {
+ expect(trendOf({ trend: 'glacial' as TrendCharacter })).toBe('balanced');
+ expect(averageWindowOf({ average_window: 21 })).toBe(7);
+ expect(averageWindowOf({ average_window: 'a fortnight' as unknown as number })).toBe(7);
+ });
+
+ 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' }));
+ });
+
+ test('a setting is changed without dropping the keys of another client', () => {
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm');
+ category.chartConfig = { goal_line: 75 };
+
+ expect(category.withChartSetting('trend', 'reactive').chartConfig)
+ .toEqual({ goal_line: 75, trend: 'reactive' });
+ });
+
+ test('fromJson ignores a configuration that is not an object', () => {
+ const category = MeasurementCategory.fromJson({
+ id: 'c-1', name: 'Steps', unit: 'steps', chart_config: null,
+ });
+
+ expect(category.chartConfig).toEqual({});
+ });
+ });
+
describe('binWidthFor', () => {
test('body weight follows the unit, like its limits do', () => {
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index c184030ef..87a4e491c 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -42,6 +42,58 @@ 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];
+/**
+ * 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 type TrendCharacter = typeof TREND_CHARACTERS[number];
+
+const TREND_EMA_PERIODS: Record = {
+ reactive: 5,
+ balanced: 10,
+ sluggish: 20,
+};
+
+/** Windows the moving average may be computed over, in days */
+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;
+
+ /** Keys another client wrote, kept so a write from here does not drop them */
+ [key: string]: unknown;
+}
+
+/** Falls back to 'balanced', which is the unconfigured chart */
+export function trendOf(config: ChartConfig): TrendCharacter {
+ return TREND_CHARACTERS.includes(config.trend as TrendCharacter)
+ ? config.trend as 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)];
+}
+
+/**
+ * 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.
+ */
+export function averageWindowOf(config: ChartConfig): number {
+ const window = config.average_window;
+
+ return typeof window === 'number' && AVERAGE_WINDOWS.includes(window)
+ ? window
+ : AVERAGE_WINDOWS[0];
+}
+
/**
* Narrows a server value to a known chart type. Null is the server's "no
* override"; an unrecognised value is one added after this release, and
@@ -372,6 +424,8 @@ export class MeasurementCategory {
public order: number = 0,
/** Chart the user picked, 'auto' (the server's null) for the derived one */
public chartType: ChartType = 'auto',
+ /** Taste-level chart settings, read through trendOf and averageWindowOf */
+ public chartConfig: ChartConfig = {},
) {
if (entries) {
this.entries = entries;
@@ -395,6 +449,7 @@ export class MeasurementCategory {
overrides !== undefined && 'parentId' in overrides ? overrides.parentId ?? null : other.parentId,
other.order,
overrides?.chartType ?? other.chartType,
+ other.chartConfig,
);
category.children = other.children;
return category;
@@ -405,6 +460,16 @@ export class MeasurementCategory {
return adapter.fromJson(json);
}
+ /**
+ * A copy with one chart setting changed, keeping the keys this release
+ * does not know: a write replaces the whole object.
+ */
+ withChartSetting(key: string, value: unknown): MeasurementCategory {
+ const category = MeasurementCategory.clone(this);
+ category.chartConfig = { ...this.chartConfig, [key]: value };
+ return category;
+ }
+
toJson() {
return adapter.toJson(this);
}
@@ -424,6 +489,10 @@ class MeasurementCategoryAdapter implements Adapter {
item.parent ?? null,
item.order ?? 0,
chartTypeFromApi(item.chart_type),
+ // Anything that is not an object is not a configuration
+ typeof item.chart_config === 'object' && item.chart_config !== null
+ ? item.chart_config
+ : {},
);
}
@@ -438,6 +507,8 @@ class MeasurementCategoryAdapter implements Adapter {
// the chart from the metric type
// eslint-disable-next-line camelcase
chart_type: item.chartType === 'auto' ? null : item.chartType,
+ // eslint-disable-next-line camelcase
+ chart_config: item.chartConfig,
parent: item.parentId,
order: item.order,
};
diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
index 004e6ea1b..49ff6a4c5 100644
--- a/src/components/Measurements/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -47,7 +47,8 @@ export const BodyWeight = () => {
unit={displayUnit}
categoryUnit={categoryUnit}
range={range}
- planPeriods={planPeriods} />
+ planPeriods={planPeriods}
+ chartConfig={categoryQuery.data!.chartConfig} />
{/* The entries are read by their own query here, the official
category is fetched without them */}
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index c94acc138..7acf2a91d 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -6,7 +6,7 @@ import {
useEditMeasurementCategoryQuery,
useMeasurementsCategoryQuery
} from "@/components/Measurements/queries";
-import { MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementCategory, TrendCharacter } from "@/components/Measurements/models/Category";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
import React from 'react';
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
@@ -325,4 +325,119 @@ describe("Test the CategoryForm component", () => {
expect(screen.getByRole('combobox', { name: 'measurements.chartType' }))
.toBeInTheDocument();
});
+
+ test('A leaf category gets the line chart settings', () => {
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.getByRole('combobox', { name: 'measurements.chartTrend' }))
+ .toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: 'measurements.chartAverageWindow' }))
+ .toBeInTheDocument();
+ });
+
+ test('A summed type has no line to configure', () => {
+ // Its chart is one bar per day, which has neither a trend nor an average
+ const steps = new MeasurementCategory(
+ 'cccccccc-cccc-cccc-cccc-000000000045', 'Steps', 'steps', undefined, 'steps',
+ );
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.queryByRole('combobox', { name: 'measurements.chartTrend' })).toBeNull();
+ expect(screen.queryByRole('combobox', { name: 'measurements.chartAverageWindow' }))
+ .toBeNull();
+ });
+
+ test('The line settings are disabled for a chart without a line', () => {
+ // Kept rather than hidden: switching the chart type back applies them
+ // again, and a field that vanishes takes the reason with it
+ const category = MeasurementCategory.clone(
+ TEST_MEASUREMENT_CATEGORY_1, { chartType: 'delta' },
+ );
+
+ // Act
+ render(
+
+
+
+ );
+
+ // Assert
+ expect(screen.getByRole('combobox', { name: 'measurements.chartTrend' }))
+ .toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByRole('combobox', { name: 'measurements.chartAverageWindow' }))
+ .toHaveAttribute('aria-disabled', 'true');
+ });
+
+ test('Picking a trend keeps the settings of another client', async () => {
+ // Arrange
+ const user = userEvent.setup();
+ const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1);
+ category.chartConfig = { goal_line: 75 };
+
+ // Act
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('combobox', { name: 'measurements.chartTrend' }));
+ await user.click(screen.getByRole('option', { name: 'measurements.trends.reactive' }));
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ expect(mutate.mock.calls[0][0].chartConfig)
+ .toEqual({ goal_line: 75, trend: 'reactive' });
+ });
+
+ 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.
+ const user = userEvent.setup();
+ const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1);
+ category.chartConfig = { trend: 'glacial' as TrendCharacter };
+
+ // Act
+ render(
+
+
+
+ );
+ const nameInput = await screen.findByLabelText('name');
+ await user.clear(nameInput);
+ await user.type(nameInput, 'a better name');
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ expect(mutate.mock.calls[0][0].chartConfig).toEqual({ trend: 'glacial' });
+ });
+
+ test('An untouched category keeps its empty configuration', async () => {
+ // Renaming a category must not fill its config with the defaults
+ const user = userEvent.setup();
+
+ // Act
+ render(
+
+
+
+ );
+ await user.click(screen.getByRole('button', { name: 'submit' }));
+
+ // Assert
+ expect(mutate.mock.calls[0][0].chartConfig).toEqual({});
+ });
});
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index c34eb7b39..71735e970 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -1,9 +1,15 @@
import {
availableChartTypes,
+ AVERAGE_WINDOWS,
+ averageWindowOf,
ChartType,
isGroupMetricType,
MeasurementCategory,
- MetricType
+ MetricType,
+ resolveChartType,
+ TREND_CHARACTERS,
+ TrendCharacter,
+ trendOf
} from "@/components/Measurements/models/Category";
import {
useAddMeasurementCategoryQuery,
@@ -26,6 +32,18 @@ interface CategoryFormProps {
const chartTypeChoices = (metricType: MetricType): ChartType[] =>
['auto', ...availableChartTypes(metricType)];
+/**
+ * Whether the category can be drawn as a line at all, which is what the trend
+ * and the average settings belong to. A summed type is drawn as bars whatever
+ * is picked, and a group by what its components are.
+ */
+const canDrawLine = (metricType: MetricType, hasChildren: boolean): boolean =>
+ !hasChildren && availableChartTypes(metricType).includes('line');
+
+/** Whether it is drawn as one right now, i.e. whether those settings apply */
+const drawsLine = (values: { metricType: MetricType, chartType: ChartType }): boolean =>
+ resolveChartType(values.metricType, values.chartType) === 'line';
+
export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
const [t] = useTranslation();
@@ -71,6 +89,11 @@ 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 ?? {});
+
return (
{
unit: category ? category.unit : "",
metricType: category ? category.metricType : 'custom' as MetricType,
chartType: category ? category.chartType : 'auto' as ChartType,
+ trend: seededTrend,
+ averageWindow: seededWindow,
// the empty string stands in for "no group", MUI selects
// don't accept null values
parentId: category?.parentId ?? "",
@@ -85,21 +110,42 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
validationSchema={validationSchema}
onSubmit={async (values) => {
const parentId = values.parentId === "" ? null : values.parentId;
+
+ /**
+ * Applies the chart settings the user actually changed.
+ *
+ * Only a changed one is written, so renaming a category leaves
+ * its configuration exactly as it was: a value another client
+ * wrote and this one does not know reads as the default here,
+ * and writing that default back would drop it.
+ */
+ const withSettings = (target: MeasurementCategory): MeasurementCategory => {
+ let out = target;
+ if (values.trend !== seededTrend) {
+ out = out.withChartSetting('trend', values.trend);
+ }
+ if (values.averageWindow !== seededWindow) {
+ out = out.withChartSetting('average_window', values.averageWindow);
+ }
+
+ return out;
+ };
+
// The form closes only once the server took the category, so a
// rejected write is shown instead of disappearing with it
const options = { onSuccess: () => closeFn?.() };
// Edit existing category
if (category) {
- useEditCategoryQuery.mutate(MeasurementCategory.clone(category, {
+ useEditCategoryQuery.mutate(withSettings(MeasurementCategory.clone(category, {
name: values.name,
unit: values.unit,
metricType: values.metricType,
chartType: values.chartType,
parentId: parentId,
- }), options);
+ })), options);
} else {
- useAddCategoryQuery.mutate(new MeasurementCategory(
+ useAddCategoryQuery.mutate(withSettings(new MeasurementCategory(
null,
values.name,
values.unit,
@@ -109,7 +155,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
parentId,
0,
values.chartType,
- ), options);
+ )), options);
}
}}
>
@@ -164,6 +210,42 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
)}
}
+ {/* The trend line and the moving average are parts of
+ * the line chart: a category that can never be drawn
+ * as one is not offered them at all, and one that is
+ * currently drawn as something else keeps its
+ * settings but cannot change them
+ */}
+ {canDrawLine(formik.values.metricType, hasChildren) && <>
+
+ {TREND_CHARACTERS.map((trend: TrendCharacter) =>
+
+ {t(`measurements.trends.${trend}`)}
+
+ )}
+
+
+ {AVERAGE_WINDOWS.map(days =>
+
+ {t('measurements.chartAverageWindowDays', { count: days })}
+
+ )}
+
+ >}
{!hasChildren && formik.values.metricType === 'custom'
&& parentCandidates.length > 0 &&
@@ -691,7 +693,13 @@ export const MeasurementChart = (props: {
unit={props.category.unit} />
{/* the one-number version of the bars above; a summed metric has no level to change */}
{!summed && }
>;
}
diff --git a/src/components/Measurements/widgets/WeightChart.tsx b/src/components/Measurements/widgets/WeightChart.tsx
index 5cd2a86d5..562371056 100644
--- a/src/components/Measurements/widgets/WeightChart.tsx
+++ b/src/components/Measurements/widgets/WeightChart.tsx
@@ -1,6 +1,7 @@
import { measurementSeries } from "@/components/Measurements/charts/data";
import { ChartRange, cutoffFor, DEFAULT_CHART_RANGE } 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";
import { MeasurementSeriesChart } from "@/components/Measurements/widgets/MeasurementSeriesChart";
import { OverallChange } from "@/components/Measurements/widgets/OverallChange";
@@ -15,6 +16,8 @@ export interface WeightChartProps {
range?: ChartRange,
planPeriods?: PlanPeriod[],
height?: number,
+ /** Chart settings of the body weight category */
+ chartConfig?: ChartConfig,
}
/**
@@ -23,7 +26,8 @@ export interface WeightChartProps {
* screens showed before body weight became a measurement.
*/
export const WeightChart = (
- { weights, unit, categoryUnit, range, planPeriods, height = 300 }: WeightChartProps,
+ { weights, unit, categoryUnit, range, planPeriods, height = 300, chartConfig = {} }:
+ WeightChartProps,
) => {
const [t] = useTranslation();
@@ -34,6 +38,7 @@ export const WeightChart = (
unit,
categoryUnit,
cutoffFor(range ?? DEFAULT_CHART_RANGE),
+ chartConfig,
);
return <>
diff --git a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
index 87bccf41a..a089fa585 100644
--- a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
+++ b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx
@@ -48,6 +48,7 @@ export const PlanWeightChart = (props: { plan: NutritionalPlan }) => {
weights={entries}
unit={displayUnit}
categoryUnit={categoryQuery.data.unit}
+ chartConfig={categoryQuery.data.chartConfig}
range="all"
height={200} />
>;
From 80b1df522dce0e1bb6928a23bbe2f0763b51fd77 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Wed, 5 Aug 2026 21:54:23 +0200
Subject: [PATCH 51/71] Add a one-month chart range
---
public/locales/de/translation.json | 6 +++--
public/locales/en/translation.json | 6 +++--
public/locales/es/translation.json | 8 +++++--
public/locales/fr/translation.json | 8 +++++--
.../Measurements/charts/range.test.ts | 14 ++++++-----
src/components/Measurements/charts/range.ts | 11 ++++++---
.../widgets/ChartRangeSelector.tsx | 24 ++++++++++++++-----
7 files changed, 54 insertions(+), 23 deletions(-)
diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json
index 0c569986f..2665146c4 100644
--- a/public/locales/de/translation.json
+++ b/public/locales/de/translation.json
@@ -307,8 +307,10 @@
"indicatorTrend": "Trend",
"overallChangeWeight": "Allgemeine Veränderung",
"chartRangeAll": "Gesamt",
- "chartRangeLastYear": "1 Jahr",
- "chartRangeLast3Months": "3 Monate",
+ "chartRangeMonths_one": "1 Monat",
+ "chartRangeMonths_other": "{{count}} Monate",
+ "chartRangeYears_one": "1 Jahr",
+ "chartRangeYears_other": "{{count}} Jahre",
"customMeasurement": "Eigene Messung",
"metricAlreadyTracked": "Wird bereits aufgezeichnet",
"categoryFormHelpText": "Messkategorie, z. B. „Bizeps“ oder „Körperfett“"
diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json
index a4a3a3531..cf6b0213b 100644
--- a/public/locales/en/translation.json
+++ b/public/locales/en/translation.json
@@ -392,8 +392,10 @@
"noDataAvailable": "No data available",
"overallChangeWeight": "Overall change",
"chartRangeAll": "All",
- "chartRangeLastYear": "1 year",
- "chartRangeLast3Months": "3 months",
+ "chartRangeMonths_one": "1 month",
+ "chartRangeMonths_other": "{{count}} months",
+ "chartRangeYears_one": "1 year",
+ "chartRangeYears_other": "{{count}} years",
"customMeasurement": "Custom measurement",
"metricAlreadyTracked": "Already tracked",
"categoryFormHelpText": "Measurement category, such as 'biceps' or 'body fat'"
diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json
index eb320c925..84157171a 100644
--- a/public/locales/es/translation.json
+++ b/public/locales/es/translation.json
@@ -312,8 +312,12 @@
"indicatorTrend": "tendencia",
"overallChangeWeight": "Cambio general",
"chartRangeAll": "Todo",
- "chartRangeLastYear": "1 año",
- "chartRangeLast3Months": "3 meses",
+ "chartRangeMonths_one": "1 mes",
+ "chartRangeMonths_other": "{{count}} meses",
+ "chartRangeMonths_many": "{{count}} meses",
+ "chartRangeYears_one": "1 año",
+ "chartRangeYears_other": "{{count}} años",
+ "chartRangeYears_many": "{{count}} años",
"customMeasurement": "Medición personalizada",
"metricAlreadyTracked": "Ya se está registrando",
"categoryFormHelpText": "Categoría de medición, como \"bíceps\" o \"grasa corporal\""
diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json
index df06d0a10..aaf8aeb1f 100644
--- a/public/locales/fr/translation.json
+++ b/public/locales/fr/translation.json
@@ -395,8 +395,12 @@
"indicatorTrend": "tendance",
"overallChangeWeight": "Changement global",
"chartRangeAll": "Tout",
- "chartRangeLastYear": "1 an",
- "chartRangeLast3Months": "3 mois",
+ "chartRangeMonths_one": "1 mois",
+ "chartRangeMonths_other": "{{count}} mois",
+ "chartRangeMonths_many": "{{count}} mois",
+ "chartRangeYears_one": "1 an",
+ "chartRangeYears_other": "{{count}} ans",
+ "chartRangeYears_many": "{{count}} ans",
"customMeasurement": "Mesure personnalisée",
"metricAlreadyTracked": "Déjà suivi",
"categoryFormHelpText": "Catégorie de mesure, comme « biceps » ou « graisse corporelle »"
diff --git a/src/components/Measurements/charts/range.test.ts b/src/components/Measurements/charts/range.test.ts
index 6549f782b..618aa2576 100644
--- a/src/components/Measurements/charts/range.test.ts
+++ b/src/components/Measurements/charts/range.test.ts
@@ -4,12 +4,14 @@ import { describe, expect, test } from 'vitest';
const noon = new Date(2026, 5, 15, 12, 30);
describe('fetchCutoffFor', () => {
- test('fetches a week beyond the cutoff, for the moving average', () => {
+ test('fetches the widest average window beyond the cutoff', () => {
// The first days in range average the days before them, so those have
- // to be fetched as well. Rounding to midnight also makes the bound
- // immune to the hour the clock change shifts cutoffFor by
- expect(fetchCutoffFor('last3Months', noon)).toStrictEqual(new Date(2026, 2, 10));
- expect(fetchCutoffFor('lastYear', noon)).toStrictEqual(new Date(2025, 5, 8));
+ // to be fetched as well, and how many depends on a setting this bound
+ // must not vary with. Rounding to midnight also makes it immune to the
+ // hour the clock change shifts cutoffFor by
+ expect(fetchCutoffFor('lastMonth', noon)).toStrictEqual(new Date(2026, 3, 16));
+ expect(fetchCutoffFor('last3Months', noon)).toStrictEqual(new Date(2026, 1, 15));
+ expect(fetchCutoffFor('lastYear', noon)).toStrictEqual(new Date(2025, 4, 16));
});
test('is stable across the day, so it can go into a query key', () => {
@@ -30,7 +32,7 @@ describe('fetchCutoffFor', () => {
describe('entryFilterFor', () => {
test('filters the entries by the fetch cutoff', () => {
expect(entryFilterFor('last3Months', noon))
- .toStrictEqual({ "date__gte": new Date(2026, 2, 10).toISOString() });
+ .toStrictEqual({ "date__gte": new Date(2026, 1, 15).toISOString() });
});
test('the full history needs no filter', () => {
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index aa8e82bd9..1c508aa7c 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -1,3 +1,4 @@
+import { AVERAGE_WINDOWS } from "@/components/Measurements/models/Category";
import { ChartPoint } from "@/components/Measurements/charts/series";
/**
@@ -6,7 +7,7 @@ import { ChartPoint } from "@/components/Measurements/charts/series";
* 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.
*/
-export const CHART_RANGES = ['last3Months', 'lastYear', 'all'] as const;
+export const CHART_RANGES = ['lastMonth', 'last3Months', 'lastYear', 'all'] as const;
export type ChartRange = typeof CHART_RANGES[number];
export const DEFAULT_CHART_RANGE: ChartRange = 'last3Months';
@@ -14,6 +15,7 @@ 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,
@@ -29,9 +31,12 @@ export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | nul
/**
* Days fetched beyond the cutoff, so the moving average of the first days in
* range averages the days before them instead of starting over at the cutoff.
- * Matches AVERAGE_WINDOW_DAYS in charts/data.
+ *
+ * The largest window a category can be set to, rather than its own: this ends
+ * up in a query key, so deriving it from the setting would refetch whenever
+ * the setting changes.
*/
-const AVERAGE_LEAD_DAYS = 7;
+const AVERAGE_LEAD_DAYS = Math.max(...AVERAGE_WINDOWS);
/**
* Oldest entry to fetch for a range, null for the full history.
diff --git a/src/components/Measurements/widgets/ChartRangeSelector.tsx b/src/components/Measurements/widgets/ChartRangeSelector.tsx
index 9ad2fa18e..077fea5c1 100644
--- a/src/components/Measurements/widgets/ChartRangeSelector.tsx
+++ b/src/components/Measurements/widgets/ChartRangeSelector.tsx
@@ -1,13 +1,25 @@
import { ToggleButton, ToggleButtonGroup } from "@mui/material";
import { CHART_RANGES, ChartRange } from "@/components/Measurements/charts/range";
+import { TFunction } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
-const LABELS = {
- last3Months: 'measurements.chartRangeLast3Months',
- lastYear: 'measurements.chartRangeLastYear',
- all: 'measurements.chartRangeAll',
-} as const satisfies Record;
+/**
+ * Label of a range, counted rather than one string per range, so a range
+ * added here needs no new translation.
+ */
+const rangeLabel = (range: ChartRange, t: TFunction): string => {
+ switch (range) {
+ case 'lastMonth':
+ return t('measurements.chartRangeMonths', { count: 1 });
+ case 'last3Months':
+ return t('measurements.chartRangeMonths', { count: 3 });
+ case 'lastYear':
+ return t('measurements.chartRangeYears', { count: 1 });
+ case 'all':
+ return t('measurements.chartRangeAll');
+ }
+};
/** Picks how far back the charts below go */
export const ChartRangeSelector = (props: {
@@ -28,6 +40,6 @@ export const ChartRangeSelector = (props: {
}}
>
{CHART_RANGES.map(range =>
- {t(LABELS[range])} )}
+ {rangeLabel(range, t)} )}
;
};
From 143189ccf029e743b8e02bea98e8d5c094d81286 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 6 Aug 2026 18:01:57 +0200
Subject: [PATCH 52/71] Read the measurement charts from the aggregate
endpoints
---
.../Dashboard/MeasurementCard.test.tsx | 4 +
src/components/Dashboard/MeasurementCard.tsx | 17 +-
.../Measurements/api/measurements.ts | 62 +++++
.../Measurements/charts/data.test.ts | 113 +++++---
src/components/Measurements/charts/data.ts | 252 +++++++++++++++---
src/components/Measurements/charts/range.ts | 33 ++-
src/components/Measurements/index.ts | 13 +-
src/components/Measurements/models/Bucket.ts | 67 +++++
src/components/Measurements/queries/index.ts | 79 +++++-
.../MeasurementCategoryDetail.test.tsx | 3 +
.../MeasurementCategoryOverview.test.tsx | 3 +
.../widgets/MeasurementChart.test.tsx | 53 ++--
.../Measurements/widgets/MeasurementChart.tsx | 140 +++++-----
.../Measurements/widgets/WeightChart.test.tsx | 8 +-
.../Measurements/widgets/WeightChart.tsx | 6 +-
src/core/lib/consts.ts | 2 +
src/tests/chartQueries.ts | 91 +++++++
17 files changed, 758 insertions(+), 188 deletions(-)
create mode 100644 src/components/Measurements/models/Bucket.ts
create mode 100644 src/tests/chartQueries.ts
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index cba55a1c8..86bee262e 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -6,6 +6,7 @@ import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
+import { mockChartQueries } from "@/tests/chartQueries";
vi.mock("@/components/Measurements/queries");
vi.useFakeTimers();
@@ -24,6 +25,8 @@ describe("smoke test the MeasurementCard component", () => {
TEST_MEASUREMENT_CATEGORY_2
]
}));
+ // The cards read their points from the aggregated queries
+ mockChartQueries([TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2]);
});
test('renders the current categories correctly', async () => {
@@ -64,6 +67,7 @@ describe("smoke test the MeasurementCard component", () => {
isLoading: false,
data: [group]
}));
+ mockChartQueries([group]);
});
test('lists the latest reading of each component', async () => {
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index f311de4e6..4a91e16a5 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -5,10 +5,14 @@ import {
CategoryForm,
componentColor,
componentPalette,
+ chartQueryFor,
+ DEFAULT_CHART_RANGE,
entryFilterFor,
groupChart,
+ groupComponentPoints,
MeasurementCategory,
MeasurementChart,
+ useMeasurementBucketsQuery,
useMeasurementsCategoryQuery,
valueWithUnit
} from "@/components/Measurements";
@@ -98,9 +102,18 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
const { t } = useTranslation();
// The dot ties a component row to its line in the chart above. A range is
- // a single bar, where the ends speak for themselves.
+ // a single bar, where the ends speak for themselves. The same derivation
+ // the chart uses, so both decide over one span and one cached request.
+ const { ids, level, filters } = chartQueryFor(props.category, DEFAULT_CHART_RANGE);
+ const buckets = useMeasurementBucketsQuery(
+ ids,
+ level,
+ filters,
+ props.category.isGroup,
+ ).data ?? [];
const showComponentColors = props.category.isGroup
- && groupChart(props.category).kind === 'components';
+ && groupChart(props.category, groupComponentPoints(props.category, buckets)).kind
+ === 'components';
const palette = componentPalette(props.category.children.length);
return (<>
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 33f793904..a2dc5ccd5 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -1,4 +1,5 @@
import axios from 'axios';
+import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket";
import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { ApiMeasurementCategoryType } from '@/types';
@@ -8,6 +9,67 @@ import { makeHeader, makeUrl } from "@/core/lib/url";
export const API_MEASUREMENTS_CATEGORY_PATH = 'measurement-category';
export const API_MEASUREMENTS_ENTRY_PATH = 'measurement';
+export const API_MEASUREMENTS_AGGREGATE_PATH = 'measurement/aggregate';
+export const API_MEASUREMENTS_VALUE_COUNTS_PATH = 'measurement/value-counts';
+
+/**
+ * Calendar unit the server condenses into. 'auto' takes the finest one that
+ * keeps the series under the point limit, which is what a line chart wants;
+ * the others exist because the chart is built on a unit and coarser points
+ * would draw a grid of the wrong cells.
+ */
+export type BucketLevel = 'auto' | 'hour' | 'day' | 'week' | 'month';
+
+/** The zone the buckets are cut in: a reading half an hour after midnight
+ * belongs to the day the user had it, not to the one UTC was on. */
+const browserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/**
+ * The entries of one or more categories, condensed into chart points.
+ *
+ * [categoryIds] takes a group's components in one call, which is what lets the
+ * halves of a reading meet on the same bucket.
+ */
+export const getMeasurementBuckets = async (
+ categoryIds: string[],
+ level: BucketLevel = 'auto',
+ filtersetQuery: object = {},
+): Promise => {
+ const url = makeUrl(API_MEASUREMENTS_AGGREGATE_PATH, {
+ query: {
+ category__in: categoryIds.join(','),
+ bucket: level,
+ tz: browserTimezone(),
+ ...filtersetQuery,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return data.map((item: unknown) => MeasurementBucket.fromJson(item));
+};
+
+/**
+ * How often each value of a category occurred, which is what the histogram
+ * bins. [summedPerDay] counts daily totals instead, for the metrics whose
+ * samples mean nothing on their own.
+ */
+export const getMeasurementValueCounts = async (
+ categoryId: string,
+ summedPerDay: boolean,
+ filtersetQuery: object = {},
+): Promise => {
+ const url = makeUrl(API_MEASUREMENTS_VALUE_COUNTS_PATH, {
+ query: {
+ category: categoryId,
+ summed_per_day: summedPerDay ? 'true' : 'false',
+ tz: browserTimezone(),
+ ...filtersetQuery,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return data.map((item: unknown) => MeasurementValueCount.fromJson(item));
+};
/**
* How much of each category's history a caller needs.
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 1d01a7903..0585d5fec 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -1,4 +1,9 @@
-import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
+import { MeasurementBucket } from "@/components/Measurements/models/Bucket";
+import {
+ isSummedPerDay,
+ MeasurementCategory,
+ MetricType
+} from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
aggregatePerDay,
@@ -12,6 +17,7 @@ import {
HEATMAP_MAX_WEEKS,
groupChart,
groupComponentSeries,
+ groupComponentPoints,
groupRangeEntries,
groupStackedEntries,
movingAverage,
@@ -337,58 +343,79 @@ describe('niceBinWidth', () => {
});
});
+/**
+ * The points the aggregated read returns for a group, one bucket per entry
+ * unless the metric is summed per day, which the query condenses to days.
+ */
+const groupPoints = (group: MeasurementCategory) => groupComponentPoints(
+ group,
+ group.children.flatMap(child => {
+ const summed = isSummedPerDay(child.metricType);
+ const byStart = new Map();
+ for (const entry of child.entries) {
+ const date = entry.date;
+ const start = summed
+ ? new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
+ : date.getTime();
+ byStart.set(start, [...(byStart.get(start) ?? []), entry]);
+ }
+
+ return [...byStart.entries()].map(([start, entries]) => new MeasurementBucket(
+ child.id!,
+ new Date(start),
+ null,
+ entries.length,
+ entries.reduce((sum, e) => sum + e.value, 0),
+ Math.min(...entries.map(e => e.value)),
+ Math.max(...entries.map(e => e.value)),
+ ));
+ }),
+);
+
describe('buildHistogram', () => {
+ const counted = (...values: number[]) => values.map(value => ({ value: value, count: 1 }));
+
test('aligns the bin edges to round multiples of the width', () => {
- const result = buildHistogram([point(day(1), 79.7), point(day(2), 82.3)], 0.5);
+ const result = buildHistogram(counted(79.7, 82.3), 0, 0.5);
expect(result.firstEdge).toBe(79.5);
expect(result.firstEdge + result.counts.length * result.binWidth).toBe(82.5);
});
test('keeps empty bins between the occupied ones, a gap is information', () => {
- const result = buildHistogram(
- [point(day(1), 60), point(day(2), 61), point(day(3), 65)],
- 2,
- );
+ const result = buildHistogram(counted(60, 61, 65), 0, 2);
expect(result.counts).toEqual([2, 0, 1]);
});
- test('takes the median of the values, odd and even', () => {
- const odd = buildHistogram(
- [point(day(1), 60), point(day(2), 62), point(day(3), 70)],
- 2,
- );
- expect(odd.median).toBe(62);
+ test('counts a value as often as it occurred', () => {
+ // What the aggregated read hands over: a year of readings arrives as
+ // the distinct values it covers, with their counts
+ const result = buildHistogram([{ value: 60, count: 30 }, { value: 61, count: 5 }], 61, 1);
- const even = buildHistogram(
- [point(day(1), 60), point(day(2), 63), point(day(3), 65), point(day(4), 70)],
- 2,
- );
- expect(even.median).toBe(64);
+ expect(result.counts).toEqual([30, 5]);
});
- test('the latest value follows the dates, not the array order', () => {
- const result = buildHistogram(
- [point(day(3), 70), point(day(5), 60), point(day(1), 65)],
- 5,
- );
+ test('takes the median of the values, odd and even', () => {
+ expect(buildHistogram(counted(60, 62, 70), 70, 2).median).toBe(62);
+ expect(buildHistogram(counted(60, 63, 65, 70), 70, 2).median).toBe(64);
+ });
- expect(result.latest).toBe(60);
+ test('the median weighs the counts, not the distinct values', () => {
+ // Thirty readings at 60 and one at 90: the middle reading is a 60,
+ // which an unweighted median over the two values would miss
+ const result = buildHistogram([{ value: 60, count: 30 }, { value: 90, count: 1 }], 90, 10);
+
+ expect(result.median).toBe(60);
});
test('derives a width from the span when the type brings none', () => {
- const result = buildHistogram([point(day(1), 59.3), point(day(2), 73.9)]);
-
- expect(result.binWidth).toBe(1);
+ expect(buildHistogram(counted(59.3, 73.9), 0).binWidth).toBe(1);
});
test('doubles the width until an outlier no longer stretches it into hundreds of bins', () => {
// 20 to 350 at 0.5 kg would be 661 bins; doubling keeps the edges round
- const result = buildHistogram(
- [point(day(1), 20), point(day(2), 80), point(day(3), 350)],
- 0.5,
- );
+ const result = buildHistogram(counted(20, 80, 350), 350, 0.5);
expect(result.binWidth).toBe(4);
expect(result.counts.length).toBeLessThanOrEqual(100);
@@ -522,19 +549,19 @@ describe('groups', () => {
};
test('pairs the components of a reading into one range', () => {
- const result = groupRangeEntries(bloodPressure([[day(1), 120, 80]]));
+ const result = groupRangeEntries(groupPoints(bloodPressure([[day(1), 120, 80]])));
expect(result).toStrictEqual([{ date: day(1).getTime(), value: 100, min: 80, max: 120 }]);
});
test('skips a half reading, it has no range', () => {
- const result = groupRangeEntries(bloodPressure([[day(1), 120, 80], [day(2), 125, null]]));
+ const result = groupRangeEntries(groupPoints(bloodPressure([[day(1), 120, 80], [day(2), 125, null]])));
expect(result.map(r => r.date)).toEqual([day(1).getTime()]);
});
test('sorts the readings chronologically', () => {
- const result = groupRangeEntries(bloodPressure([[day(3), 130, 90], [day(1), 120, 80]]));
+ const result = groupRangeEntries(groupPoints(bloodPressure([[day(3), 130, 90], [day(1), 120, 80]])));
expect(result.map(r => r.max)).toEqual([120, 130]);
});
@@ -542,11 +569,11 @@ describe('groups', () => {
test('reads the low and high end from the values, not from the component order', () => {
const group = bloodPressure([[day(1), 80, 120]]);
- expect(groupRangeEntries(group)[0]).toMatchObject({ min: 80, max: 120 });
+ expect(groupRangeEntries(groupPoints(group))[0]).toMatchObject({ min: 80, max: 120 });
});
test('builds one named component series per child', () => {
- const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]));
+ const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]), groupPoints(bloodPressure([[day(1), 120, 80]])));
expect(series.map(s => s.label)).toEqual(['Systolic', 'Diastolic']);
expect(series.map(s => s.role)).toEqual(['component', 'component']);
@@ -554,13 +581,13 @@ describe('groups', () => {
});
test('two components are charted as ranges', () => {
- const chart = groupChart(bloodPressure([[day(1), 120, 80]]));
+ const chart = groupChart(bloodPressure([[day(1), 120, 80]]), groupPoints(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]]));
+ const chart = groupChart(bloodPressure([[day(1), 120, null], [day(2), 125, null]]), groupPoints(bloodPressure([[day(1), 120, null], [day(2), 125, null]])));
expect(chart.kind).toBe('components');
});
@@ -571,7 +598,7 @@ describe('groups', () => {
third.entries = [new MeasurementEntry(null, 'c-map', day(1), 93, '')];
group.children = [...group.children, third];
- const chart = groupChart(group);
+ const chart = groupChart(group, groupPoints(group));
expect(chart.kind).toBe('components');
});
@@ -611,7 +638,8 @@ describe('sleep group', () => {
});
test('stacked entries carry one value per component and day', () => {
- const stacked = groupStackedEntries(stackableComponents(sleep()));
+ const group = sleep();
+ const stacked = groupStackedEntries(stackableComponents(group), groupPoints(group));
expect(stacked).toStrictEqual([{ date: day(2).getTime(), values: [90, 60] }]);
});
@@ -622,11 +650,12 @@ describe('sleep group', () => {
const deep = group.children[1];
deep.entries = [...deep.entries, new MeasurementEntry('e-nap', 'deep', day(2, 14), 20, '')];
- expect(groupStackedEntries(stackableComponents(group))[0].values).toEqual([110, 60]);
+ expect(groupStackedEntries(stackableComponents(group), groupPoints(group))[0].values)
+ .toEqual([110, 60]);
});
test('a summed group stacks its components', () => {
- const chart = groupChart(sleep());
+ const chart = groupChart(sleep(), groupPoints(sleep()));
expect(chart.kind).toBe('stacked');
expect(chart.kind === 'stacked' && chart.labels).toEqual(['Deep sleep', 'REM sleep']);
@@ -635,7 +664,7 @@ describe('sleep group', () => {
test('without stage data the group falls back to component lines', () => {
// Only the total reported, so there is nothing to stack. Falling
// through keeps the chart from going blank while data exists
- expect(groupChart(sleep(false)).kind).toBe('components');
+ expect(groupChart(sleep(false), groupPoints(sleep(false))).kind).toBe('components');
});
});
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index f8d8df42e..93463ff6c 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -1,13 +1,19 @@
+import { BucketLevel } from "@/components/Measurements/api/measurements";
import {
averageWindowOf,
ChartConfig,
+ ChartType,
isGroupTotalMetricType,
isSummedPerDay,
MeasurementCategory,
+ MetricType,
+ resolveChartType,
trendPeriodOf
} from "@/components/Measurements/models/Category";
+import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { pointsSince } from "@/components/Measurements/charts/range";
+import { convertWeight, isWeightUnit } from "@/core/lib/weightUnit";
+import { ChartRange, entryFilterFor, pointsSince } from "@/components/Measurements/charts/range";
import { ChartPoint, ChartSeries, PlanPeriod } from "@/components/Measurements/charts/series";
import { calculateEMA } from "@/core/lib/ema";
@@ -52,6 +58,98 @@ export const chartPointsFor = (
};
});
+/**
+ * Turns the server's condensed buckets into chart points, converting to the
+ * target unit.
+ *
+ * The counterpart of chartPointsFor for the aggregated read path. A bucket
+ * arrives once per unit its entries were written in, so the slices are
+ * converted before they are merged: a mean over kg and lb values is a number
+ * in neither. Their spread becomes the point's range, left off where it says
+ * nothing (a single reading, a summed total, which has no spread).
+ */
+export const chartPointsForBuckets = (
+ buckets: MeasurementBucket[],
+ targetUnit: string,
+ categoryUnit: string,
+ summed: boolean = false,
+): ChartPoint[] => {
+ const convert = (value: number, from: string | null) => {
+ const unit = from || categoryUnit;
+
+ return isWeightUnit(unit) && isWeightUnit(targetUnit)
+ ? convertWeight(value, unit, targetUnit)
+ : value;
+ };
+
+ const byStart = new Map();
+ for (const bucket of buckets) {
+ const start = bucket.start.getTime();
+ byStart.set(start, [...(byStart.get(start) ?? []), bucket]);
+ }
+
+ return [...byStart.entries()]
+ .sort(([a], [b]) => a - b)
+ .map(([start, slices]) => {
+ const total = slices.reduce((sum, s) => sum + convert(s.sum, s.unit), 0);
+ if (summed) {
+ return { date: start, value: total };
+ }
+
+ const value = total / slices.reduce((count, s) => count + s.count, 0);
+ const min = Math.min(...slices.map(s => convert(s.min, s.unit)));
+ const max = Math.max(...slices.map(s => convert(s.max, s.unit)));
+
+ return min < value || max > value
+ ? { date: start, value: value, min: min, max: max }
+ : { date: start, value: value };
+ });
+};
+
+/**
+ * What the chart of a category reads: which categories, at which level, over
+ * which span.
+ *
+ * Derived in one place because two widgets ask for it, the chart and the card
+ * whose component rows follow the chart's kind. Two different derivations
+ * would mean two query keys, i.e. two requests deciding over different spans.
+ */
+export const chartQueryFor = (category: MeasurementCategory, range: ChartRange): {
+ ids: string[],
+ level: BucketLevel,
+ filters: object,
+} => ({
+ // A group asks for its components in one call, so they share the calendar
+ // unit and the halves of a reading still meet on the same bucket
+ ids: category.isGroup ? category.children.map(child => child.id!) : [category.id!],
+ level: category.isGroup
+ ? (isSummedPerDay(category.metricType) ? 'day' : 'auto')
+ : bucketLevelFor(category.metricType, category.chartType),
+ // The points reach back beyond the range, so the moving average derived
+ // from them does not start over at the cutoff
+ filters: entryFilterFor(range),
+});
+
+/**
+ * The point level a category's chart needs.
+ *
+ * Two charts are built on a calendar unit and fix it: a heatmap draws days, a
+ * week-over-week chart weeks. A distribution has no time axis and reads
+ * counted values of its own; the points it gets here are what its fallback
+ * draws when there are too few values to bin.
+ */
+export const bucketLevelFor = (metricType: MetricType, chartType: ChartType): BucketLevel => {
+ switch (resolveChartType(metricType, chartType)) {
+ case 'heatmap':
+ return 'day';
+ case 'delta':
+ return 'week';
+ default:
+ // The summed types are drawn as daily totals whatever the range
+ return isSummedPerDay(metricType) ? 'day' : 'auto';
+ }
+};
+
/**
* For each point, the average of all points in the given days preceding it.
*
@@ -379,10 +477,14 @@ export interface Histogram {
* decision, the same split as for the heatmap: the summed types distribute
* their days, the sample types every reading.
*/
-export const buildHistogram = (points: ChartPoint[], binWidth?: number): Histogram => {
- const values = points.map(point => point.value).sort((a, b) => a - b);
- const minValue = values[0];
- const maxValue = values[values.length - 1];
+export const buildHistogram = (
+ values: ValueCount[],
+ latest: number,
+ binWidth?: number,
+): Histogram => {
+ const sorted = [...values].sort((a, b) => a.value - b.value);
+ const minValue = sorted[0].value;
+ const maxValue = sorted[sorted.length - 1].value;
let width = binWidth ?? niceBinWidth(minValue, maxValue);
// Doubling keeps the edges round, unlike recomputing a fitted width
@@ -392,18 +494,78 @@ export const buildHistogram = (points: ChartPoint[], binWidth?: number): Histogr
const firstBin = Math.floor(minValue / width);
const counts = new Array(Math.floor(maxValue / width) - firstBin + 1).fill(0);
- for (const value of values) {
- counts[Math.floor(value / width) - firstBin]++;
+ for (const entry of sorted) {
+ counts[Math.floor(entry.value / width) - firstBin] += entry.count;
}
- const middle = Math.floor(values.length / 2);
-
return {
firstEdge: firstBin * width,
binWidth: width,
counts: counts,
- median: values.length % 2 === 1 ? values[middle] : (values[middle - 1] + values[middle]) / 2,
- latest: points.reduce((a, b) => b.date > a.date ? b : a).value,
+ median: weightedMedian(sorted),
+ latest: latest,
+ };
+};
+
+/** One value and how often it occurred, which is what a histogram bins */
+export interface ValueCount {
+ value: number;
+ count: number;
+}
+
+/** The middle value, counting each one as often as it occurred */
+const weightedMedian = (sorted: ValueCount[]): number => {
+ const total = sorted.reduce((sum, entry) => sum + entry.count, 0);
+ const at = (index: number) => {
+ let seen = 0;
+ for (const entry of sorted) {
+ seen += entry.count;
+ if (index < seen) {
+ return entry.value;
+ }
+ }
+ return sorted[sorted.length - 1].value;
+ };
+
+ const first = at(Math.floor((total - 1) / 2));
+
+ return total % 2 === 1 ? first : (first + at(Math.floor(total / 2))) / 2;
+};
+
+/**
+ * The counted values of a category in the target unit, and where the user
+ * stands today.
+ *
+ * Values are counted per unit they were entered in, so each goes through the
+ * conversion helper before equal ones are added up.
+ */
+export const valueHistogram = (
+ counts: MeasurementValueCount[],
+ targetUnit: string,
+ categoryUnit: string,
+): { values: ValueCount[], latest: number } => {
+ const convert = (value: number, from: string | null) => {
+ const unit = from || categoryUnit;
+
+ return isWeightUnit(unit) && isWeightUnit(targetUnit)
+ ? convertWeight(value, unit, targetUnit)
+ : value;
+ };
+
+ const merged = new Map();
+ for (const count of counts) {
+ const value = convert(count.value, count.unit);
+ merged.set(value, (merged.get(value) ?? 0) + count.count);
+ }
+
+ const newest = counts.reduce(
+ (a, b) => b.newest > a.newest ? b : a,
+ counts[0],
+ );
+
+ return {
+ values: [...merged.entries()].map(([value, count]) => ({ value: value, count: count })),
+ latest: newest === undefined ? 0 : convert(newest.value, newest.unit),
};
};
@@ -499,20 +661,34 @@ export const fillMissingDays = (points: ChartPoint[]): ChartPoint[] => {
* shared timestamp, which is how both the importer and the group form write
* them; an unpaired half-reading is skipped, it has no range.
*/
-export const groupRangeEntries = (
+export const groupComponentPoints = (
group: MeasurementCategory,
+ buckets: MeasurementBucket[],
cutoff: Date | null = null,
-): ChartPoint[] => {
+): Map => new Map(group.children.map(child => [
+ child.id!,
+ pointsSince(
+ chartPointsForBuckets(
+ buckets.filter(bucket => bucket.category === child.id),
+ child.unit,
+ child.unit,
+ // A stage the night was slept in twice is that night's total, not
+ // the average of its two stretches
+ isSummedPerDay(child.metricType),
+ ),
+ cutoff,
+ ),
+]));
+
+export const groupRangeEntries = (points: Map): ChartPoint[] => {
const byDate = new Map();
- for (const child of group.children) {
- for (const entry of child.entries) {
- const date = entry.date.getTime();
- const values = byDate.get(date);
- const value = entry.valueIn(child.unit, child.unit);
+ for (const component of points.values()) {
+ for (const point of component) {
+ const values = byDate.get(point.date);
if (values === undefined) {
- byDate.set(date, [value]);
+ byDate.set(point.date, [point.value]);
} else {
- values.push(value);
+ values.push(point.value);
}
}
}
@@ -529,7 +705,7 @@ export const groupRangeEntries = (
}))
.sort((a, b) => a.date - b.date);
- return pointsSince(ranges, cutoff);
+ return ranges;
};
/**
@@ -538,11 +714,11 @@ export const groupRangeEntries = (
*/
export const groupComponentSeries = (
group: MeasurementCategory,
- cutoff: Date | null = null,
+ points: Map,
labelOf: (category: MeasurementCategory) => string = category => category.name,
): ChartSeries[] =>
group.children.map(child => ({
- points: pointsSince(chartPointsFor(child.entries, child.unit, child.unit), cutoff),
+ points: points.get(child.id!) ?? [],
role: 'component' as const,
label: labelOf(child),
}));
@@ -557,13 +733,10 @@ export const groupComponentSeries = (
* so it stays a 7-day average rather than an average of bucket means.
*/
export const measurementSeries = (
- entries: MeasurementEntry[],
- targetUnit: string,
- categoryUnit: string,
+ all: ChartPoint[],
cutoff: Date | null = null,
config: ChartConfig = {},
): ChartSeries[] => {
- const all = chartPointsFor(entries, targetUnit, categoryUnit);
// 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
@@ -614,21 +787,14 @@ export interface StackedPoint {
*/
export const groupStackedEntries = (
components: MeasurementCategory[],
- cutoff: Date | null = null,
+ points: Map,
): StackedPoint[] => {
const byDay = new Map();
components.forEach((child, index) => {
- for (const entry of child.entries) {
- if (cutoff !== null && entry.date < cutoff) {
- continue;
- }
- const date = entry.date;
- const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
- const values = byDay.get(day) ?? new Array(components.length).fill(0);
- // A component can hold several entries for one day (a nap next to
- // the night), and the bar shows the day, so they add up
- values[index] += entry.valueIn(child.unit, child.unit);
- byDay.set(day, values);
+ for (const point of points.get(child.id!) ?? []) {
+ const values = byDay.get(point.date) ?? new Array(components.length).fill(0);
+ values[index] += point.value;
+ byDay.set(point.date, values);
}
});
@@ -654,22 +820,22 @@ export type GroupChart =
export const groupChart = (
group: MeasurementCategory,
- cutoff: Date | null = null,
+ points: Map,
labelOf: (category: MeasurementCategory) => string = category => category.name,
): GroupChart => {
if (isSummedPerDay(group.metricType)) {
const components = stackableComponents(group);
- const stacked = groupStackedEntries(components, cutoff);
+ const stacked = groupStackedEntries(components, points);
if (stacked.length > 0) {
return { kind: 'stacked', points: stacked, labels: components.map(labelOf) };
}
}
- const ranges = group.children.length === 2 ? groupRangeEntries(group, cutoff) : [];
+ const ranges = group.children.length === 2 ? groupRangeEntries(points) : [];
return ranges.length > 0
? { kind: 'range', points: ranges }
- : { kind: 'components', series: groupComponentSeries(group, cutoff, labelOf) };
+ : { kind: 'components', series: groupComponentSeries(group, points, labelOf) };
};
/**
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index 1c508aa7c..c8efef298 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -39,23 +39,37 @@ export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | nul
const AVERAGE_LEAD_DAYS = Math.max(...AVERAGE_WINDOWS);
/**
- * Oldest entry to fetch for a range, null for the full history.
+ * The cutoff minus a lead, rounded down to midnight.
*
- * Rounded down to midnight, and deliberately so: this ends up in a query key,
- * and a bound derived from the current instant would differ on every render
- * and refetch forever.
+ * The rounding is deliberate: these end up in query keys, and a bound derived
+ * from the current instant would differ on every render and refetch forever.
*/
-export const fetchCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => {
+const cutoffAtMidnight = (range: ChartRange, now: Date, leadDays: number): Date | null => {
const cutoff = cutoffFor(range, now);
if (cutoff === null) {
return null;
}
- const lead = new Date(cutoff.getTime() - AVERAGE_LEAD_DAYS * DAY_MS);
+ const lead = new Date(cutoff.getTime() - leadDays * DAY_MS);
return new Date(lead.getFullYear(), lead.getMonth(), lead.getDate());
};
+/** Oldest entry to fetch for a range, null for the full history */
+export const fetchCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null =>
+ cutoffAtMidnight(range, now, AVERAGE_LEAD_DAYS);
+
+/**
+ * Oldest entry to summarise for a range, 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.
+ */
+export const displayCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null =>
+ cutoffAtMidnight(range, now, 0);
+
/**
* Entry filter that fetches only what a range needs, empty for the full
* history. The server has an index on (category, date), so this is cheaper
@@ -67,6 +81,13 @@ export const entryFilterFor = (range: ChartRange, now: Date = new Date()): objec
return cutoff === null ? {} : { "date__gte": cutoff.toISOString() };
};
+/** Filter for the reads that summarise exactly the range, see displayCutoffFor */
+export const displayFilterFor = (range: ChartRange, now: Date = new Date()): object => {
+ const cutoff = displayCutoffFor(range, now);
+
+ return cutoff === null ? {} : { "date__gte": cutoff.toISOString() };
+};
+
/** The points from the cutoff on; a null cutoff covers the full history */
export const pointsSince = (points: ChartPoint[], cutoff: Date | null): ChartPoint[] =>
cutoff === null ? points : points.filter(point => point.date >= cutoff.getTime());
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index b39231749..7cbecb5b8 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -16,6 +16,7 @@ export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOvervi
export {
categoryDisplayName,
correlatesWithNutrition,
+ isSummedPerDay,
limitsFor,
MeasurementCategory,
METRIC_TYPE_BODY_WEIGHT
@@ -35,7 +36,9 @@ export {
useAddMeasurementEntryQuery,
useDeleteMeasurementEntryQuery,
useEditMeasurementEntryQuery,
- useMeasurementsCategoryQuery
+ useMeasurementBucketsQuery,
+ useMeasurementsCategoryQuery,
+ useMeasurementValueCountsQuery
} from "./queries";
export {
useBodyWeightCategoryQuery,
@@ -45,7 +48,13 @@ export {
// Charts
export { componentColor, componentPalette } from "./charts/colors";
-export { groupChart, measurementSeries } from "./charts/data";
+export {
+ chartPointsFor,
+ chartQueryFor,
+ groupChart,
+ groupComponentPoints,
+ measurementSeries
+} from "./charts/data";
export { valueWithUnit } from "./charts/format";
export { CHART_RANGES, cutoffFor, DEFAULT_CHART_RANGE, entryFilterFor } from "./charts/range";
export type { ChartRange } from "./charts/range";
diff --git a/src/components/Measurements/models/Bucket.ts b/src/components/Measurements/models/Bucket.ts
new file mode 100644
index 000000000..297300384
--- /dev/null
+++ b/src/components/Measurements/models/Bucket.ts
@@ -0,0 +1,67 @@
+/**
+ * What the aggregate endpoints return: measurements condensed into what a
+ * chart draws.
+ *
+ * A chart shows a few hundred points and a watch-fed metric holds tens of
+ * thousands a year, so the condensing happens in the query. Both shapes are
+ * grouped by the unit the values were entered in as well, because a mean over
+ * kg and lb values is a number in neither: the client converts each row
+ * through `valueIn` before merging them.
+ */
+
+/** One calendar bucket of a category's entries */
+export class MeasurementBucket {
+ constructor(
+ public category: string,
+ public start: Date,
+ /** The unit the values were entered in, null when they carry none */
+ public unit: string | null,
+ public count: number,
+ public sum: number,
+ /**
+ * Lowest and highest value the bucket stands for. An entry that is
+ * itself a daily aggregate contributes its stored bounds rather than
+ * its value, so condensing one keeps the true extremes.
+ */
+ public min: number,
+ public max: number,
+ ) {
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static fromJson(item: any): MeasurementBucket {
+ return new MeasurementBucket(
+ item.category,
+ new Date(item.start),
+ item.unit ?? null,
+ item.count,
+ parseFloat(item.sum),
+ parseFloat(item.min),
+ parseFloat(item.max),
+ );
+ }
+}
+
+/** How often one value occurred, the histogram's counterpart to a bucket */
+export class MeasurementValueCount {
+ constructor(
+ public category: string,
+ public value: number,
+ public unit: string | null,
+ public count: number,
+ /** Newest entry holding this value, i.e. where the user stands today */
+ public newest: Date,
+ ) {
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ static fromJson(item: any): MeasurementValueCount {
+ return new MeasurementValueCount(
+ item.category,
+ parseFloat(item.value),
+ item.unit ?? null,
+ item.count,
+ new Date(item.newest),
+ );
+ }
+}
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index d2ab4b521..8fc341257 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -5,8 +5,11 @@ import {
deleteMeasurementEntry,
editMeasurementCategory,
editMeasurementEntry,
+ BucketLevel,
+ getMeasurementBuckets,
getMeasurementCategories,
getMeasurementCategory,
+ getMeasurementValueCounts,
MeasurementQueryOptions,
updateMeasurementCategoryOrder
} from "@/components/Measurements/api/measurements";
@@ -16,6 +19,12 @@ import { QueryKey } from "@/core/lib/consts";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+/** The condensed reads behind the charts, which every write invalidates */
+const invalidateChartReads = (queryClient: ReturnType) => {
+ queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_BUCKETS,] });
+ queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_VALUE_COUNTS,] });
+};
+
export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) {
return useQuery({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, JSON.stringify(options || {})],
@@ -31,9 +40,10 @@ export const useAddMeasurementCategoryQuery = () => {
return useMutation({
mutationFn: (category: MeasurementCategory) => addMeasurementCategory(category),
- onSuccess: () => queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- })
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] });
+ invalidateChartReads(queryClient);
+ }
});
};
@@ -49,6 +59,7 @@ export const useEditMeasurementCategoryQuery = (id: string) => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
@@ -65,6 +76,7 @@ export const useDeleteMeasurementCategoryQuery = (id: string) => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
@@ -78,9 +90,10 @@ export const useReorderMeasurementCategoriesQuery = () => {
mutationFn: (categories: MeasurementCategory[]) => Promise.all(
categories.map((category, index) => updateMeasurementCategoryOrder(category.id!, index))
),
- onSuccess: () => queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- })
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] });
+ invalidateChartReads(queryClient);
+ }
});
};
@@ -104,6 +117,7 @@ export const useAddMeasurementEntryQuery = () => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
@@ -121,6 +135,7 @@ export const useAddGroupEntriesQuery = () => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
@@ -137,6 +152,7 @@ export const useEditMeasurementEntryQuery = () => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
@@ -154,6 +170,57 @@ export const useDeleteMeasurementEntryQuery = () => {
queryClient.invalidateQueries({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
});
+ invalidateChartReads(queryClient);
}
});
};
+
+
+/**
+ * The chart points of one or more categories, condensed by the server.
+ *
+ * Kept apart from the category queries, which hand over the entries
+ * themselves: a chart shows a few hundred points, and a watch-fed metric holds
+ * tens of thousands a year. A group passes its components in one call, so they
+ * share the calendar unit and their readings still meet on the same bucket.
+ */
+export function useMeasurementBucketsQuery(
+ categoryIds: string[],
+ level: BucketLevel,
+ filtersetQuery: object = {},
+ enabled: boolean = true,
+) {
+ return useQuery({
+ queryKey: [
+ QueryKey.MEASUREMENT_BUCKETS,
+ categoryIds.join(','),
+ level,
+ JSON.stringify(filtersetQuery),
+ ],
+ queryFn: () => getMeasurementBuckets(categoryIds, level, filtersetQuery),
+ enabled: enabled && categoryIds.length > 0,
+ // Picking another range refetches, and the chart would otherwise drop
+ // back to the loading placeholder while the new one arrives
+ placeholderData: keepPreviousData,
+ });
+}
+
+/** How often each value occurred, which is what the histogram bins */
+export function useMeasurementValueCountsQuery(
+ categoryId: string,
+ summedPerDay: boolean,
+ filtersetQuery: object = {},
+ enabled: boolean = true,
+) {
+ return useQuery({
+ queryKey: [
+ QueryKey.MEASUREMENT_VALUE_COUNTS,
+ categoryId,
+ summedPerDay,
+ JSON.stringify(filtersetQuery),
+ ],
+ queryFn: () => getMeasurementValueCounts(categoryId, summedPerDay, filtersetQuery),
+ enabled: enabled,
+ placeholderData: keepPreviousData,
+ });
+}
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
index cda7bc024..083f715fa 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
@@ -1,5 +1,6 @@
import { useMeasurementsQuery } from "@/components/Measurements/queries";
import { MeasurementCategoryDetail } from "@/components/Measurements/screens/MeasurementCategoryDetail";
+import { mockChartQueries } from "@/tests/chartQueries";
import { TEST_MEASUREMENT_CATEGORY_1 } from "@/tests/measurementsTestData";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from '@testing-library/react';
@@ -24,6 +25,8 @@ describe("Test the MeasurementCategoryDetail component", () => {
isLoading: false,
data: TEST_MEASUREMENT_CATEGORY_1
}));
+ // The chart reads its points from the aggregated queries
+ mockChartQueries([TEST_MEASUREMENT_CATEGORY_1]);
});
afterEach(() => {
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
index d802f0173..106ad7f94 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
@@ -5,6 +5,7 @@ import { useMeasurementsCategoryQuery, useReorderMeasurementCategoriesQuery } fr
import { MeasurementCategoryOverview } from "@/components/Measurements/screens/MeasurementCategoryOverview";
import React from 'react';
import { BrowserRouter } from "react-router-dom";
+import { mockChartQueries } from "@/tests/chartQueries";
import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
@@ -23,6 +24,8 @@ describe("Test the MeasurementCategoryOverview component", () => {
(useReorderMeasurementCategoriesQuery as Mock).mockImplementation(() => ({
mutate: vi.fn()
}));
+ // The cards read their points from the aggregated queries
+ mockChartQueries([TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2]);
});
afterEach(() => {
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index e5063c63c..5648399f7 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,10 +1,23 @@
+import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from '@testing-library/react';
+import { mockChartQueries } from "@/tests/chartQueries";
+import { testQueryClient } from "@/tests/queryClient";
import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
import React from 'react';
import { describe, test } from 'vitest';
+vi.mock("@/components/Measurements/queries");
+
+/** The chart reads its points from the aggregated queries, not from the
+ * entries the category carries */
+const renderChart = (element: React.ReactElement, categories: MeasurementCategory[]) => {
+ mockChartQueries(categories);
+
+ return render({element} );
+};
+
const entry = (id: string, date: Date, value: number) =>
new MeasurementEntry(id, 'c-1', date, value, '');
@@ -18,7 +31,7 @@ describe('MeasurementChart', () => {
entry('d-2', new Date(2023, 1, 2), 31),
]);
- render( );
+ renderChart( , [category]);
});
test('mounts a bar chart for a summed-per-day category', () => {
@@ -27,12 +40,15 @@ describe('MeasurementChart', () => {
entry('d-2', new Date(2023, 1, 1, 18), 6000),
], 'steps');
- render( );
+ renderChart( , [category]);
});
test('mounts with no entries', () => {
- render( );
- render( );
+ const empty = new MeasurementCategory('c-1', 'Biceps', 'cm');
+ const emptySummed = new MeasurementCategory('c-2', 'Steps', 'steps', [], 'steps');
+
+ renderChart( , [empty]);
+ renderChart( , [emptySummed]);
});
test('mounts a combined chart for a group', () => {
@@ -48,7 +64,7 @@ describe('MeasurementChart', () => {
];
group.children = [systolic, diastolic];
- render( );
+ renderChart( , [group]);
});
test('draws a heatmap when the category asks for one', () => {
@@ -56,7 +72,7 @@ describe('MeasurementChart', () => {
entry('d-1', new Date(2023, 1, 1), 4000),
], 'steps', false, null, 0, 'heatmap');
- render( );
+ renderChart( , [category]);
// Unlike the recharts charts, the grid is plain elements and does
// render in jsdom
@@ -70,7 +86,7 @@ describe('MeasurementChart', () => {
entry('d-2', new Date(2026, 0, 12), 31),
], 'custom', false, null, 0, 'delta');
- render( );
+ renderChart( , [category]);
expect(screen.getByText(/overallChangeWeight/)).toBeInTheDocument();
});
@@ -81,7 +97,7 @@ describe('MeasurementChart', () => {
entry('d-2', new Date(2026, 0, 12), 6000),
], 'steps', false, null, 0, 'delta');
- render( );
+ renderChart( , [category]);
expect(screen.queryByText(/overallChangeWeight/)).not.toBeInTheDocument();
});
@@ -96,7 +112,7 @@ describe('MeasurementChart', () => {
'custom', false, null, 0, 'distribution',
);
- render( );
+ renderChart( , [category]);
// Plain elements like the heatmap, so the bars render in jsdom
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
@@ -117,7 +133,7 @@ describe('MeasurementChart', () => {
'steps', false, null, 0, 'distribution',
);
- render( );
+ renderChart( , [category]);
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
fireEvent.mouseEnter(chart.firstChild!.firstChild as Element);
@@ -138,12 +154,17 @@ describe('MeasurementChart', () => {
'custom', false, null, 0, 'distribution',
);
- const { rerender } = render( );
+ const { rerender } = renderChart( , [wide]);
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
fireEvent.mouseEnter(chart.firstChild!.lastChild as Element);
expect(screen.getByText(/distributionEntryCount/)).toBeInTheDocument();
- rerender( );
+ mockChartQueries([narrow]);
+ rerender(
+
+
+ ,
+ );
expect(screen.queryByText(/distributionEntryCount/)).not.toBeInTheDocument();
expect(screen.getByText(/distributionMedian/)).toBeInTheDocument();
@@ -155,7 +176,7 @@ describe('MeasurementChart', () => {
entry('d-2', new Date(2026, 0, 12), 31),
], 'custom', false, null, 0, 'distribution');
- render( );
+ renderChart( , [category]);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
@@ -176,7 +197,7 @@ describe('MeasurementChart', () => {
'steps', false, null, 0, 'distribution',
);
- render( );
+ renderChart( , [category]);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
@@ -188,7 +209,7 @@ describe('MeasurementChart', () => {
entry('d-1', new Date(2023, 1, 1), 30),
], 'custom', false, null, 0, 'bar');
- render( );
+ renderChart( , [category]);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
@@ -206,6 +227,6 @@ describe('MeasurementChart', () => {
stage('rem', 'REM sleep', 'sleep_rem', 60),
];
- render( );
+ renderChart( , [group]);
});
});
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 3bfc7e41b..9584beb8e 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -3,6 +3,7 @@ import {
averageWindowOf,
binWidthFor,
categoryDisplayName,
+ ChartConfig,
isSummedPerDay,
MeasurementCategory,
resolveChartType
@@ -11,18 +12,26 @@ import {
aggregatePerDay,
averagePerDay,
buildHeatmapGrid,
+ chartQueryFor,
buildHistogram,
- chartPointsFor,
+ chartPointsForBuckets,
DAYS_PER_WEEK,
DISTRIBUTION_MIN_VALUES,
fillMissingDays,
groupChart,
+ groupComponentPoints,
heatmapDayAt,
measurementSeries,
movingAverage,
StackedPoint,
+ ValueCount,
+ valueHistogram,
weeklyDeltas
} from "@/components/Measurements/charts/data";
+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 {
@@ -36,6 +45,7 @@ import {
ChartRange,
cutoffFor,
DEFAULT_CHART_RANGE,
+ displayFilterFor,
pointsSince
} from "@/components/Measurements/charts/range";
import { ChartPoint, PlanPeriod } from "@/components/Measurements/charts/series";
@@ -76,14 +86,13 @@ const CustomTooltip = ({ active, payload, label, category }: TooltipProps) => {
return null;
};
-const MeasurementBarChart = (props: { category: MeasurementCategory, cutoff: Date | null }) => {
+const MeasurementBarChart = (props: { category: MeasurementCategory, points: ChartPoint[] }) => {
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 points = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
- const data = fillMissingDays(aggregatePerDay(pointsSince(points, props.cutoff)));
+ const data = fillMissingDays(aggregatePerDay(props.points));
if (data.length === 0) {
return ;
@@ -364,7 +373,8 @@ const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string })
* bar chart cannot place a marker line at an exact value on a band axis.
*/
const MeasurementDistributionChart = (props: {
- points: ChartPoint[],
+ values: ValueCount[],
+ latest: number,
unit: string,
binWidth?: number,
countsAreDays?: boolean,
@@ -372,11 +382,11 @@ const MeasurementDistributionChart = (props: {
const [t, i18n] = useTranslation();
const [selected, setSelected] = React.useState(null);
- if (props.points.length === 0) {
+ if (props.values.length === 0) {
return ;
}
- const histogram = buildHistogram(props.points, props.binWidth);
+ 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;
@@ -630,24 +640,20 @@ const MeasurementHeatmapChart = (props: { points: ChartPoint[], unit: string })
};
const MeasurementLineChart = (props: {
- category: MeasurementCategory,
+ unit: string,
+ points: ChartPoint[],
cutoff: Date | null,
+ config: ChartConfig,
planPeriods?: PlanPeriod[],
}) => {
- const series = measurementSeries(
- props.category.entries,
- props.category.unit,
- props.category.unit,
- props.cutoff,
- props.category.chartConfig,
- );
+ const series = measurementSeries(props.points, props.cutoff, props.config);
return <>
-
+
>;
};
@@ -657,72 +663,83 @@ export const MeasurementChart = (props: {
planPeriods?: PlanPeriod[],
}) => {
const [t] = useTranslation();
- const cutoff = cutoffFor(props.range ?? DEFAULT_CHART_RANGE);
+ const category = props.category;
+ const range = props.range ?? DEFAULT_CHART_RANGE;
+ const cutoff = cutoffFor(range);
+ const summed = isSummedPerDay(category.metricType);
- if (props.category.isGroup) {
+ // A pick that does not fit the metric type falls back to the derived chart,
+ // which is also what a category configured on another client gets here
+ const resolved = resolveChartType(category.metricType, category.chartType);
+
+ const { ids, level, filters } = chartQueryFor(category, range);
+ const buckets = useMeasurementBucketsQuery(ids, level, filters).data ?? [];
+
+ // Its own query, since a histogram needs every value and the points above
+ // are condensed. Over the range itself, without the average lead: counted
+ // values carry no date and cannot be trimmed afterwards.
+ const counts = useMeasurementValueCountsQuery(
+ category.id!,
+ summed,
+ displayFilterFor(range),
+ !category.isGroup && resolved === 'distribution',
+ ).data ?? [];
+
+ if (category.isGroup) {
+ const points = groupComponentPoints(category, buckets, cutoff);
// The components are labelled by their metric type, like everywhere else
- const chart = groupChart(props.category, cutoff, c => categoryDisplayName(c, t));
+ const chart = groupChart(category, points, c => categoryDisplayName(c, t));
switch (chart.kind) {
case 'stacked':
return ;
+ unit={category.unit} />;
case 'range':
- return ;
+ return ;
case 'components':
- return ;
+ return ;
}
}
- const summed = isSummedPerDay(props.category.metricType);
-
- // A pick that does not fit the metric type falls back to the derived chart,
- // which is also what a category configured on another client gets here
- const resolved = resolveChartType(props.category.metricType, props.category.chartType);
+ const all = chartPointsForBuckets(buckets, category.unit, category.unit, summed);
+ const points = pointsSince(all, cutoff);
if (resolved === 'delta') {
- const all = chartPointsFor(props.category.entries, props.category.unit, props.category.unit);
-
return <>
+ points={weeklyDeltas(points, summed)}
+ unit={category.unit} />
{/* the one-number version of the bars above; a summed metric has no level to change */}
{!summed && }
+ unit={category.unit} />}
>;
}
if (resolved === 'distribution') {
- // What is binned mirrors the heatmap's split: the summed types
- // distribute their daily totals, the sample types every reading (three
- // weigh-ins on one day are all part of the distribution). Deliberately
- // not condensed on the way: bucket means would narrow the very spread
- // the histogram exists to show
- const points = pointsSince(
- chartPointsFor(props.category.entries, props.category.unit, props.category.unit),
- cutoff,
- );
- const values = summed ? aggregatePerDay(points) : points;
-
- // A histogram of a handful of values is noise with gaps, so too few
+ // Values are counted per unit they were entered in, so each goes
+ // through the conversion helper before equal ones are added up
+ const histogram = valueHistogram(counts, category.unit, category.unit);
+ // How many readings there are, not how many distinct values: a
+ // hundred weigh-ins around one number are a distribution, three are
+ // not, however far apart they lie
+ const readings = counts.reduce((sum, count) => sum + count.count, 0);
+
+ // A histogram of a handful of readings is noise with gaps, so too few
// fall through to the derived default chart below
- if (values.length >= DISTRIBUTION_MIN_VALUES) {
+ if (readings >= DISTRIBUTION_MIN_VALUES) {
return ;
}
}
@@ -730,23 +747,18 @@ export const MeasurementChart = (props: {
if (resolved === 'heatmap') {
// The cells are days, so how a day's readings become one value has to
// be decided here: the summed types are a daily total, the sample types
- // are repeated readings of the same thing and average. The points are
- // deliberately not condensed on the way, which for a grid of days would
- // collapse whole weeks into a single cell
- const points = pointsSince(
- chartPointsFor(props.category.entries, props.category.unit, props.category.unit),
- cutoff,
- );
-
+ // are repeated readings of the same thing and average
return ;
+ unit={category.unit} />;
}
return summed
- ?
+ ?
: ;
};
diff --git a/src/components/Measurements/widgets/WeightChart.test.tsx b/src/components/Measurements/widgets/WeightChart.test.tsx
index 0b07a08f6..f7cf3cd1e 100644
--- a/src/components/Measurements/widgets/WeightChart.test.tsx
+++ b/src/components/Measurements/widgets/WeightChart.test.tsx
@@ -1,4 +1,4 @@
-import { measurementSeries, MeasurementEntry } from "@/components/Measurements";
+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';
@@ -61,8 +61,10 @@ describe("the series the chart is built from", () => {
makeWeightEntry(new Date('2021-12-10'), 80, { id: 'd-1', unit: 'kg' }),
];
- const inKg = measurementSeries(weights, 'kg', 'kg')[0].points.map(p => p.value);
- const inLb = measurementSeries(weights, 'lb', 'kg')[0].points.map(p => p.value);
+ const inKg = measurementSeries(chartPointsFor(weights, 'kg', 'kg'))[0].points
+ .map(p => p.value);
+ const inLb = measurementSeries(chartPointsFor(weights, 'lb', 'kg'))[0].points
+ .map(p => p.value);
expect(inKg).toStrictEqual([80, 40.82]);
expect(inLb).toStrictEqual([176.37, 90]);
diff --git a/src/components/Measurements/widgets/WeightChart.tsx b/src/components/Measurements/widgets/WeightChart.tsx
index 562371056..8bfedde70 100644
--- a/src/components/Measurements/widgets/WeightChart.tsx
+++ b/src/components/Measurements/widgets/WeightChart.tsx
@@ -1,4 +1,4 @@
-import { measurementSeries } from "@/components/Measurements/charts/data";
+import { chartPointsFor, measurementSeries } from "@/components/Measurements/charts/data";
import { ChartRange, cutoffFor, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range";
import { PlanPeriod } from "@/components/Measurements/charts/series";
import { ChartConfig } from "@/components/Measurements/models/Category";
@@ -34,9 +34,7 @@ export const WeightChart = (
// Entries can be stored in mixed units, so every value is converted
// before anything is derived from it
const series = measurementSeries(
- weights,
- unit,
- categoryUnit,
+ chartPointsFor(weights, unit, categoryUnit),
cutoffFor(range ?? DEFAULT_CHART_RANGE),
chartConfig,
);
diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts
index b813014d1..e96ad134e 100644
--- a/src/core/lib/consts.ts
+++ b/src/core/lib/consts.ts
@@ -62,6 +62,8 @@ export enum QueryKey {
// the body weight queries)
MEASUREMENTS = 'measurements',
MEASUREMENTS_CATEGORIES = 'measurements-categories',
+ MEASUREMENT_BUCKETS = 'measurement-buckets',
+ MEASUREMENT_VALUE_COUNTS = 'measurement-value-counts',
// Nutrition (search)
INGREDIENT_SEARCH = 'ingredient-search',
diff --git a/src/tests/chartQueries.ts b/src/tests/chartQueries.ts
new file mode 100644
index 000000000..9ef50e961
--- /dev/null
+++ b/src/tests/chartQueries.ts
@@ -0,0 +1,91 @@
+import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket";
+import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category";
+import { MeasurementEntry } from "@/components/Measurements/models/Entry";
+import {
+ useMeasurementBucketsQuery,
+ useMeasurementValueCountsQuery
+} from "@/components/Measurements/queries";
+import type { Mock } from 'vitest';
+
+/**
+ * Answers the aggregated chart reads from the entries the [categories] carry.
+ *
+ * The charts read those separately from the categories in production; a test
+ * that seeds one list would otherwise have to build the condensed shapes by
+ * hand. One bucket per entry, which is what the server returns for a series
+ * short enough not to be condensed, and daily totals for the summed metrics,
+ * which it condenses whatever the count.
+ */
+export const mockChartQueries = (categories: MeasurementCategory[]) => {
+ const flat = categories.flatMap(category => [category, ...category.children]);
+ const byId = new Map(flat.map(category => [category.id, category]));
+
+ (useMeasurementBucketsQuery as Mock).mockImplementation((ids: string[]) => ({
+ data: ids.flatMap(id => bucketsFor(byId.get(id))),
+ }));
+
+ (useMeasurementValueCountsQuery as Mock).mockImplementation((id: string) => ({
+ data: valueCountsFor(byId.get(id)),
+ }));
+};
+
+const startOf = (entry: MeasurementEntry, summed: boolean): number => {
+ const date = entry.date;
+
+ return summed
+ ? new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
+ : date.getTime();
+};
+
+const groupBy = (items: T[], key: (item: T) => number): Map => {
+ const out = new Map();
+ for (const item of items) {
+ out.set(key(item), [...(out.get(key(item)) ?? []), item]);
+ }
+
+ return out;
+};
+
+const bucketsFor = (category: MeasurementCategory | undefined): MeasurementBucket[] => {
+ if (category === undefined) {
+ return [];
+ }
+ const summed = isSummedPerDay(category.metricType);
+
+ return [...groupBy(category.entries, entry => startOf(entry, summed)).entries()]
+ .map(([start, entries]) => new MeasurementBucket(
+ category.id!,
+ new Date(start),
+ (entries[0].extraData.unit as string) ?? null,
+ entries.length,
+ entries.reduce((sum, entry) => sum + entry.value, 0),
+ Math.min(...entries.map(entry => (entry.extraData.min as number) ?? entry.value)),
+ Math.max(...entries.map(entry => (entry.extraData.max as number) ?? entry.value)),
+ ))
+ .sort((a, b) => a.start.getTime() - b.start.getTime());
+};
+
+const valueCountsFor = (category: MeasurementCategory | undefined): MeasurementValueCount[] => {
+ if (category === undefined) {
+ return [];
+ }
+ const summed = isSummedPerDay(category.metricType);
+
+ // A summed metric distributes its daily totals, the sample types every
+ // reading, which is the split the server makes
+ const values = summed
+ ? [...groupBy(category.entries, entry => startOf(entry, true)).values()].map(entries => ({
+ value: entries.reduce((sum, entry) => sum + entry.value, 0),
+ newest: new Date(Math.max(...entries.map(entry => entry.date.getTime()))),
+ }))
+ : category.entries.map(entry => ({ value: entry.value, newest: entry.date }));
+
+ return [...groupBy(values, item => item.value).entries()].map(([value, items]) =>
+ new MeasurementValueCount(
+ category.id!,
+ value,
+ null,
+ items.length,
+ new Date(Math.max(...items.map(item => item.newest.getTime()))),
+ ));
+};
From 120a0622ad8a1109315b75e5d20b1afb134f864b Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 6 Aug 2026 18:17:37 +0200
Subject: [PATCH 53/71] Read the histogram over the range it is labelled with
---
.../Measurements/charts/data.test.ts | 39 +++----------------
src/components/Measurements/charts/data.ts | 20 +++-------
.../Measurements/charts/range.test.ts | 27 ++++++++++++-
src/components/Measurements/charts/range.ts | 9 ++++-
src/components/Measurements/models/Entry.ts | 8 +---
src/components/Measurements/queries/index.ts | 9 +++--
src/core/lib/weightUnit.ts | 21 ++++++++++
src/tests/chartQueries.ts | 6 ++-
8 files changed, 77 insertions(+), 62 deletions(-)
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 0585d5fec..79594ff67 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -1,9 +1,4 @@
-import { MeasurementBucket } from "@/components/Measurements/models/Bucket";
-import {
- isSummedPerDay,
- MeasurementCategory,
- MetricType
-} from "@/components/Measurements/models/Category";
+import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import {
aggregatePerDay,
@@ -28,6 +23,7 @@ import {
weeklyDeltas
} from "@/components/Measurements/charts/data";
import { ChartPoint } from "@/components/Measurements/charts/series";
+import { bucketsFor } from "@/tests/chartQueries";
import { describe, expect, test } from 'vitest';
const entry = (date: Date, value: number, extraData: Record = {}) =>
@@ -343,34 +339,9 @@ describe('niceBinWidth', () => {
});
});
-/**
- * The points the aggregated read returns for a group, one bucket per entry
- * unless the metric is summed per day, which the query condenses to days.
- */
-const groupPoints = (group: MeasurementCategory) => groupComponentPoints(
- group,
- group.children.flatMap(child => {
- const summed = isSummedPerDay(child.metricType);
- const byStart = new Map();
- for (const entry of child.entries) {
- const date = entry.date;
- const start = summed
- ? new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
- : date.getTime();
- byStart.set(start, [...(byStart.get(start) ?? []), entry]);
- }
-
- return [...byStart.entries()].map(([start, entries]) => new MeasurementBucket(
- child.id!,
- new Date(start),
- null,
- entries.length,
- entries.reduce((sum, e) => sum + e.value, 0),
- Math.min(...entries.map(e => e.value)),
- Math.max(...entries.map(e => e.value)),
- ));
- }),
-);
+/** The points the aggregated read returns for a group */
+const groupPoints = (group: MeasurementCategory) =>
+ groupComponentPoints(group, group.children.flatMap(child => bucketsFor(child)));
describe('buildHistogram', () => {
const counted = (...values: number[]) => values.map(value => ({ value: value, count: 1 }));
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 93463ff6c..03a02dd0f 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -12,7 +12,7 @@ import {
} from "@/components/Measurements/models/Category";
import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { convertWeight, isWeightUnit } from "@/core/lib/weightUnit";
+import { convertStoredValue } from "@/core/lib/weightUnit";
import { ChartRange, entryFilterFor, pointsSince } from "@/components/Measurements/charts/range";
import { ChartPoint, ChartSeries, PlanPeriod } from "@/components/Measurements/charts/series";
import { calculateEMA } from "@/core/lib/ema";
@@ -74,13 +74,8 @@ export const chartPointsForBuckets = (
categoryUnit: string,
summed: boolean = false,
): ChartPoint[] => {
- const convert = (value: number, from: string | null) => {
- const unit = from || categoryUnit;
-
- return isWeightUnit(unit) && isWeightUnit(targetUnit)
- ? convertWeight(value, unit, targetUnit)
- : value;
- };
+ const convert = (value: number, from: string | null) =>
+ convertStoredValue(value, from, categoryUnit, targetUnit);
const byStart = new Map();
for (const bucket of buckets) {
@@ -544,13 +539,8 @@ export const valueHistogram = (
targetUnit: string,
categoryUnit: string,
): { values: ValueCount[], latest: number } => {
- const convert = (value: number, from: string | null) => {
- const unit = from || categoryUnit;
-
- return isWeightUnit(unit) && isWeightUnit(targetUnit)
- ? convertWeight(value, unit, targetUnit)
- : value;
- };
+ const convert = (value: number, from: string | null) =>
+ convertStoredValue(value, from, categoryUnit, targetUnit);
const merged = new Map();
for (const count of counts) {
diff --git a/src/components/Measurements/charts/range.test.ts b/src/components/Measurements/charts/range.test.ts
index 618aa2576..b09ca7a7f 100644
--- a/src/components/Measurements/charts/range.test.ts
+++ b/src/components/Measurements/charts/range.test.ts
@@ -1,4 +1,8 @@
-import { entryFilterFor, fetchCutoffFor } from "@/components/Measurements/charts/range";
+import {
+ displayCutoffFor,
+ entryFilterFor,
+ fetchCutoffFor
+} from "@/components/Measurements/charts/range";
import { describe, expect, test } from 'vitest';
const noon = new Date(2026, 5, 15, 12, 30);
@@ -38,4 +42,25 @@ describe('entryFilterFor', () => {
test('the full history needs no filter', () => {
expect(entryFilterFor('all', noon)).toStrictEqual({});
});
+
+ test('the display cutoff is the range itself, with no lead', () => {
+ // The counted values behind the histogram carry no date and cannot be
+ // trimmed afterwards, so reading them with the average lead would bin
+ // a month and a half into a chart labelled one month
+ const now = new Date(2026, 4, 20, 15, 30);
+ const display = displayCutoffFor('lastMonth', now)!;
+ const fetch = fetchCutoffFor('lastMonth', now)!;
+
+ expect(display).toStrictEqual(new Date(2026, 3, 20));
+ expect(display.getTime()).toBeGreaterThan(fetch.getTime());
+ });
+
+ test('both query cutoffs sit at midnight, so they hold across renders', () => {
+ const now = new Date(2026, 4, 20, 15, 30);
+
+ for (const cutoff of [displayCutoffFor('lastMonth', now)!, fetchCutoffFor('lastMonth', now)!]) {
+ expect(cutoff.getHours()).toBe(0);
+ expect(cutoff.getMinutes()).toBe(0);
+ }
+ });
});
diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts
index c8efef298..ad29108c6 100644
--- a/src/components/Measurements/charts/range.ts
+++ b/src/components/Measurements/charts/range.ts
@@ -88,6 +88,13 @@ export const displayFilterFor = (range: ChartRange, now: Date = new Date()): obj
return cutoff === null ? {} : { "date__gte": cutoff.toISOString() };
};
-/** The points from the cutoff on; a null cutoff covers the full history */
+/**
+ * The points from the cutoff on; a null cutoff covers the full history.
+ *
+ * A condensed point sits at the start of its bucket, so the bucket the cutoff
+ * falls into drops out whole rather than half: at a week bucket that is up to
+ * a week of readings the range technically covers. Deliberate, a part bucket
+ * drawn next to full ones reads as a real dip.
+ */
export const pointsSince = (points: ChartPoint[], cutoff: Date | null): ChartPoint[] =>
cutoff === null ? points : points.filter(point => point.date >= cutoff.getTime());
diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts
index b2f9ef705..7b02fa6c4 100644
--- a/src/components/Measurements/models/Entry.ts
+++ b/src/components/Measurements/models/Entry.ts
@@ -1,5 +1,5 @@
import { Adapter } from "@/core/lib/Adapter";
-import { convertWeight, isWeightUnit } from "@/core/lib/weightUnit";
+import { convertStoredValue } from "@/core/lib/weightUnit";
export class MeasurementEntry {
@@ -57,11 +57,7 @@ export class MeasurementEntry {
}
private convert(value: number, targetUnit: string, categoryUnit: string): number {
- const from = this.unitOrFallback(categoryUnit);
-
- return isWeightUnit(from) && isWeightUnit(targetUnit)
- ? convertWeight(value, from, targetUnit)
- : value;
+ return convertStoredValue(value, this.extraData.unit as string, categoryUnit, targetUnit);
}
static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry {
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index 8fc341257..a1c3f02ed 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -90,10 +90,11 @@ export const useReorderMeasurementCategoriesQuery = () => {
mutationFn: (categories: MeasurementCategory[]) => Promise.all(
categories.map((category, index) => updateMeasurementCategoryOrder(category.id!, index))
),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] });
- invalidateChartReads(queryClient);
- }
+ // Not the chart reads: the order decides where a card sits, not what
+ // it draws
+ onSuccess: () => queryClient.invalidateQueries({
+ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
+ })
});
};
diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts
index c89e03ccb..cc1555c43 100644
--- a/src/core/lib/weightUnit.ts
+++ b/src/core/lib/weightUnit.ts
@@ -23,3 +23,24 @@ export function convertWeight(value: number, from: WeightUnit, to: WeightUnit):
return Math.round(converted * 100) / 100;
}
+
+/*
+ * Reads a stored value in the target unit: the unit it was entered in wins,
+ * the category unit fills in, and anything that is not a weight is a plain
+ * label and passes through.
+ *
+ * The one place that decides what a stored number means. A category can hold
+ * mixed units, so the raw value on its own is meaningless.
+ */
+export function convertStoredValue(
+ value: number,
+ storedUnit: string | null | undefined,
+ categoryUnit: string,
+ targetUnit: string,
+): number {
+ const from = storedUnit || categoryUnit;
+
+ return isWeightUnit(from) && isWeightUnit(targetUnit)
+ ? convertWeight(value, from, targetUnit)
+ : value;
+}
diff --git a/src/tests/chartQueries.ts b/src/tests/chartQueries.ts
index 9ef50e961..6a0a221b8 100644
--- a/src/tests/chartQueries.ts
+++ b/src/tests/chartQueries.ts
@@ -46,7 +46,11 @@ const groupBy = (items: T[], key: (item: T) => number): Map => {
return out;
};
-const bucketsFor = (category: MeasurementCategory | undefined): MeasurementBucket[] => {
+/**
+ * The buckets the server returns for a category: one per entry, or daily
+ * totals for the summed metrics, which it condenses whatever the count.
+ */
+export const bucketsFor = (category: MeasurementCategory | undefined): MeasurementBucket[] => {
if (category === undefined) {
return [];
}
From c2166cbc372717948d306c13a57f2c5b7f19d7e4 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 6 Aug 2026 21:00:15 +0200
Subject: [PATCH 54/71] Draw the bar charts in one shared frame
---
src/components/Measurements/charts/data.ts | 5 +-
.../Measurements/widgets/MeasurementChart.tsx | 337 ++++++++----------
src/core/lib/weightUnit.ts | 5 +-
3 files changed, 154 insertions(+), 193 deletions(-)
diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts
index 03a02dd0f..16762d3b0 100644
--- a/src/components/Measurements/charts/data.ts
+++ b/src/components/Measurements/charts/data.ts
@@ -237,7 +237,10 @@ const summarise = (date: number, bucket: ChartPoint[]): ChartPoint => {
* the spread as a band. That keeps exactly the information a line through
* every single sample buries.
*
- * Series that already fit are returned unchanged.
+ * Series that already fit are returned unchanged, which is what the points of
+ * the aggregate endpoint are: the server condenses to the same limit. What is
+ * left for this are the charts that still read raw entries (WeightChart), and
+ * it goes once those read buckets too.
*/
export const downsample = (points: ChartPoint[], maxPoints: number = MAX_CHART_POINTS): ChartPoint[] => {
if (points.length <= maxPoints) {
diff --git a/src/components/Measurements/widgets/MeasurementChart.tsx b/src/components/Measurements/widgets/MeasurementChart.tsx
index 9584beb8e..a66ada40b 100644
--- a/src/components/Measurements/widgets/MeasurementChart.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.tsx
@@ -58,48 +58,38 @@ import { Bar, BarChart, CartesianGrid, Cell, ReferenceLine, Tooltip, XAxis, YAxi
import { theme } from "@/theme";
import { dateToLocale } from "@/core/lib/date";
-export interface TooltipProps {
+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,
- category: MeasurementCategory
}
-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');
-
- return (
-
- {dateToLocale(new Date(label!))}
- {value &&
- {category.name}: {valueWithUnit(value.value, category.unit, i18n.language)}
-
}
-
- );
- }
+/** 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 &&
+ {categoryDisplayName(props.category, t)}
+ : {valueWithUnit(value.value, props.category.unit, i18n.language)}
+
}
+ ;
+};
-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 */}
+
+ {valueOnly(high, props.unit, i18n.language)}/
+ {valueWithUnit(low, props.unit, i18n.language)}
+
+ ;
};
/**
@@ -169,70 +192,45 @@ const RangeTooltip = ({ active, payload, label, unit }: RangeTooltipProps) => {
* matters, the gap within one reading.
*/
const MeasurementRangeBarChart = (props: { points: ChartPoint[], unit: string }) => {
- const [, i18n] = useTranslation();
const data = props.points.map(point => ({ date: point.date, range: [point.min!, point.max!] }));
- const axis = durationAxis(
- props.unit,
- Math.min(...props.points.map(point => point.min!)),
- Math.max(...props.points.map(point => point.max!)),
- );
- return
-
-
-
- valueWithUnit(value, props.unit, i18n.language)} />
- } />
-
-
- ;
+ return point.min!)),
+ Math.max(...props.points.map(point => point.max!)),
+ )}
+ tooltip={ }>
+
+ ;
};
-interface StackedTooltipProps {
- active?: boolean;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- payload?: any;
- label?: string;
- unit: string;
-}
-
/** The whole bar with its parts: a single segment says little without the night it belongs to */
-const StackedTooltip = ({ active, payload, label, unit }: StackedTooltipProps) => {
+const StackedTooltip = (props: TooltipProps & { unit: string }) => {
const [, i18n] = useTranslation();
- if (!active || !payload?.length) {
+ if (!props.active || !props.payload?.length) {
return null;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const parts = payload.filter((entry: any) => entry.value > 0);
+ 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 (
-
- {dateToLocale(new Date(Number(label)))}
- {valueWithUnit(total, unit, i18n.language)}
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {parts.map((entry: any) =>
- {entry.dataKey}: {valueOnly(entry.value, unit, i18n.language)}
-
)}
-
- );
+ return
+ {valueWithUnit(total, props.unit, i18n.language)}
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {parts.map((entry: any) =>
+ {entry.dataKey}: {valueOnly(entry.value, props.unit, i18n.language)}
+
)}
+ ;
};
/**
@@ -249,7 +247,6 @@ const MeasurementStackedBarChart = (props: {
labels: string[],
unit: string,
}) => {
- const [, i18n] = useTranslation();
const palette = componentPalette(props.labels.length);
const data = props.points.map(point => ({
date: point.date,
@@ -260,59 +257,35 @@ const MeasurementStackedBarChart = (props: {
const totals = props.points.map(
point => point.values.reduce((sum: number, value) => sum + (value ?? 0), 0),
);
- const axis = durationAxis(props.unit, 0, Math.max(...totals));
- return
-
-
-
- valueWithUnit(value, props.unit, i18n.language)} />
- } />
- {props.labels.map((label, index) => )}
-
- ;
+ return }>
+ {props.labels.map((label, index) => )}
+ ;
};
-interface DeltaTooltipProps {
- active?: boolean;
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- payload?: any;
- label?: string;
- unit: string;
-}
-
-const DeltaTooltip = ({ active, payload, label, unit }: DeltaTooltipProps) => {
+const DeltaTooltip = (props: TooltipProps & { unit: string }) => {
const [, i18n] = useTranslation();
- if (!active || !payload?.length) {
+ if (!props.active || !props.payload?.length) {
return null;
}
- const value = payload[0].value as number;
+ const value = props.payload[0].value as number;
- return (
-
- {dateToLocale(new Date(Number(label)))}
- {/* the plus is ours, only the minus comes out of the number format */}
- {value > 0 ? '+' : ''}{valueWithUnit(value, unit, i18n.language)}
-
- );
+ return
+ {/* the plus is ours, only the minus comes out of the number format */}
+ {value > 0 ? '+' : ''}{valueWithUnit(value, props.unit, i18n.language)}
+ ;
};
/**
@@ -321,46 +294,28 @@ const DeltaTooltip = ({ active, payload, label, unit }: DeltaTooltipProps) => {
* directly than the trend line does.
*/
const MeasurementDeltaBarChart = (props: { points: ChartPoint[], unit: string }) => {
- const [t, i18n] = useTranslation();
+ const [t] = useTranslation();
if (props.points.length === 0) {
return ;
}
const values = props.points.map(point => point.value);
- const axis = durationAxis(props.unit, Math.min(0, ...values), Math.max(0, ...values));
- return
-
-
-
- valueWithUnit(value, props.unit, i18n.language)} />
- } />
- {/* without the baseline a chart of only decreases reads as a normal one pointing down */}
-
-
- {props.points.map(point =>
- | )}
-
-
- ;
+ 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/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts
index cc1555c43..e7f5cac00 100644
--- a/src/core/lib/weightUnit.ts
+++ b/src/core/lib/weightUnit.ts
@@ -1,6 +1,9 @@
export type WeightUnit = 'kg' | 'lb';
+// Mirror the server's constants (wger/utils/units.py), both of them: a
+// division by the other factor is a hair off and could round differently
export const KG_PER_LB = 0.45359237;
+export const LB_PER_KG = 2.20462262;
/*
* Narrows a stored or server-provided unit. Everything else is a free-text
@@ -19,7 +22,7 @@ export function convertWeight(value: number, from: WeightUnit, to: WeightUnit):
if (from === to) {
return value;
}
- const converted = from === 'lb' ? value * KG_PER_LB : value / KG_PER_LB;
+ const converted = from === 'lb' ? value * KG_PER_LB : value * LB_PER_KG;
return Math.round(converted * 100) / 100;
}
From 55e68150df4dcad3f2528be4d9fa3cda7f4722f1 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 6 Aug 2026 21:38:17 +0200
Subject: [PATCH 55/71] Read only what the measurement screens show
---
src/components/Dashboard/MeasurementCard.tsx | 20 +++++-----
.../Measurements/api/measurements.test.ts | 11 +++++
.../Measurements/api/measurements.ts | 40 ++++++++++++++-----
.../screens/MeasurementCategoryOverview.tsx | 11 +++--
4 files changed, 56 insertions(+), 26 deletions(-)
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index 4a91e16a5..a5320602b 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -7,7 +7,6 @@ import {
componentPalette,
chartQueryFor,
DEFAULT_CHART_RANGE,
- entryFilterFor,
groupChart,
groupComponentPoints,
MeasurementCategory,
@@ -33,16 +32,17 @@ import Slider, { Settings } from "react-slick";
import "slick-carousel/slick/slick-theme.css";
+/** Entries the table under each chart lists, at most */
+const TABLE_ROWS = 5;
+
export const MeasurementCard = () => {
const { t } = useTranslation();
- // A year, like the body weight card next to it: the chart below covers
- // three months, the table under it wants the latest entries of a category
- // that may be measured only every few months. Fetching the full history
- // instead is what a synced account pays for, the sleep stages alone write
- // five entries a night
- const categoryQuery = useMeasurementsCategoryQuery({
- filtersetQueryEntries: entryFilterFor('lastYear'),
- });
+ // The chart reads its points condensed, the table below it only wants the
+ // newest few rows, so that is all that is read here. A window would leave
+ // a category that is measured every few months with an empty table, and
+ // the full history is what a synced account pays for: the sleep stages
+ // alone write five entries a night
+ const categoryQuery = useMeasurementsCategoryQuery({ entryLimit: TABLE_ROWS });
if (categoryQuery.isLoading) {
return ;
@@ -156,7 +156,7 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
;
})
- : [...props.category.entries].slice(0, 5).map(entry => (
+ : [...props.category.entries].slice(0, TABLE_ROWS).map(entry => (
{entry.date.toLocaleDateString()}
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 3baa8e331..19ae861a2 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -119,6 +119,17 @@ describe('measurement service tests', () => {
expect(result[0].entries).toHaveLength(1);
});
+ test("entryLimit caps how many entries are read per category", async () => {
+
+ const result = await getMeasurementCategories({ entryLimit: 5 });
+
+ expect(axios.get).toHaveBeenNthCalledWith(2,
+ expect.stringContaining('limit=5'),
+ expect.anything()
+ );
+ expect(result[0].entries).toHaveLength(1);
+ });
+
test("entries 'probe' ignores the entry filterset, it fetches no history", async () => {
await getMeasurementCategories({
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index a2dc5ccd5..8b0072e40 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -74,11 +74,12 @@ export const getMeasurementValueCounts = async (
/**
* How much of each category's history a caller needs.
*
- * 'all' is the history the entry filterset asks for. 'probe' fetches a single
- * entry per category, for callers that only ask whether a category holds
- * entries at all: the lists come back truncated, so `entries.length === 0` is
- * the only thing they may be read for. 'none' skips the entries, and with them
- * one request per category, for callers that need the categories themselves.
+ * 'all' is the history the entry filterset asks for, capped by [entryLimit].
+ * 'probe' fetches a single entry per category, for callers that only ask
+ * whether a category holds entries at all: the lists come back truncated, so
+ * `entries.length === 0` is the only thing they may be read for. 'none' skips
+ * the entries, and with them one request per category, for callers that need
+ * the categories themselves.
*/
export type EntryLoading = 'all' | 'probe' | 'none';
@@ -86,20 +87,38 @@ export type MeasurementQueryOptions = {
filtersetQueryCategories?: object,
filtersetQueryEntries?: object,
entries?: EntryLoading,
+ /** How many entries per category at most, unlimited if left out */
+ entryLimit?: number,
}
-/** Every entry of a category, over all pages */
-export const getMeasurementEntries = async (categoryId: string, filtersetQuery: object = {}): Promise => {
- const out: MeasurementEntry[] = [];
+/**
+ * Every entry of a category, over all pages.
+ *
+ * [limit] stops at that many of the newest ones, in a single request: the
+ * server orders by date descending, so a caller that shows the latest handful
+ * has no reason to drain a history that runs into thousands of rows.
+ */
+export const getMeasurementEntries = async (
+ categoryId: string,
+ filtersetQuery: object = {},
+ limit?: number,
+): Promise => {
const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
query: {
category: categoryId,
- limit: API_MAX_PAGE_SIZE,
+ limit: limit ?? API_MAX_PAGE_SIZE,
...filtersetQuery,
}
});
+ if (limit !== undefined) {
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData));
+ }
+
// Collect all pages of entries
+ const out: MeasurementEntry[] = [];
for await (const page of fetchPaginated(url, makeHeader())) {
for (const entryData of page) {
out.push(MeasurementEntry.fromJson(entryData));
@@ -127,6 +146,7 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
filtersetQueryCategories = {},
filtersetQueryEntries = {},
entries = 'all',
+ entryLimit,
} = options || {};
let categories: MeasurementCategory[] = [];
@@ -152,7 +172,7 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
await Promise.all(categories.map(async (category) => {
category.entries = entries === 'probe'
? await probeMeasurementEntries(category.id!)
- : await getMeasurementEntries(category.id!, filtersetQueryEntries);
+ : await getMeasurementEntries(category.id!, filtersetQueryEntries, entryLimit);
}));
}
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index a0729bdca..6f7c8aba9 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -7,7 +7,7 @@ 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 { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
+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";
@@ -59,11 +59,10 @@ export const MeasurementCategoryOverview = () => {
// 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);
- // Fetch what the range shows, rather than the whole history: this page
- // charts three months by default, and a synced account holds years
- const categoryQuery = useMeasurementsCategoryQuery({
- filtersetQueryEntries: entryFilterFor(range),
- });
+ // Only the categories: the cards chart the condensed reads, so nothing on
+ // this page touches the entries themselves. The range stays out of the
+ // query as well, otherwise picking one would read the same list again
+ const categoryQuery = useMeasurementsCategoryQuery({ entries: 'none' });
return categoryQuery.isLoading
?
From 5754e38826c639f9a228a2309684529a82a2f956 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Thu, 6 Aug 2026 22:13:30 +0200
Subject: [PATCH 56/71] Read the measurement entries where they are shown
---
.../Components/CalendarComponent.test.tsx | 61 ++++++-
.../Calendar/Components/CalendarComponent.tsx | 47 +++--
.../Dashboard/MeasurementCard.test.tsx | 41 ++++-
src/components/Dashboard/MeasurementCard.tsx | 77 +++++----
.../Measurements/api/measurements.test.ts | 73 +++-----
.../Measurements/api/measurements.ts | 87 +++++-----
.../Measurements/charts/data.test.ts | 95 ++++++-----
src/components/Measurements/index.ts | 2 +
.../Measurements/models/Category.test.ts | 8 +-
.../Measurements/models/Category.ts | 9 -
src/components/Measurements/queries/index.ts | 107 +++++++-----
.../Measurements/screens/BodyWeight.tsx | 2 -
.../MeasurementCategoryDetail.test.tsx | 19 ++-
.../screens/MeasurementCategoryDetail.tsx | 23 ++-
.../MeasurementCategoryOverview.test.tsx | 9 +-
.../screens/MeasurementCategoryOverview.tsx | 5 +-
.../widgets/CategoryDetailDataGrid.test.tsx | 32 ++--
.../widgets/CategoryDetailDataGrid.tsx | 6 +-
.../widgets/CategoryForm.test.tsx | 33 ++--
.../Measurements/widgets/CategoryForm.tsx | 35 ++--
.../Measurements/widgets/EntryForm.test.tsx | 6 +-
.../widgets/MeasurementChart.test.tsx | 160 +++++++++---------
.../widgets/MetricPicker.test.tsx | 5 +-
.../Measurements/widgets/MetricPicker.tsx | 4 +-
src/core/lib/consts.ts | 1 +
src/tests/chartQueries.ts | 49 +++---
src/tests/measurementsTestData.ts | 13 +-
src/tests/weight/testData.ts | 1 -
28 files changed, 576 insertions(+), 434 deletions(-)
diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx
index ac5d34dfa..2d9691d99 100644
--- a/src/components/Calendar/Components/CalendarComponent.test.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.test.tsx
@@ -1,11 +1,18 @@
import { MeasurementCategory, MeasurementEntry } from "@/components/Measurements";
-import { getMeasurementCategories } from "@/components/Measurements/api/measurements";
+import {
+ getAllMeasurementEntries,
+ getMeasurementCategories
+} from "@/components/Measurements/api/measurements";
import { getNutritionalDiaryEntries } from "@/components/Nutrition/api/nutritionalDiary";
import { getSessions } from "@/components/Routines/api/session";
import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight";
import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata";
import { testQueryClient } from "@/tests/queryClient";
-import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData";
+import {
+ makeWeightEntry,
+ TEST_BODY_WEIGHT_CATEGORY_UUID,
+ testBodyWeightCategory
+} from "@/tests/weight/testData";
import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData";
import { dateToYYYYMMDD } from "@/core/lib/date";
import { QueryClientProvider } from "@tanstack/react-query";
@@ -50,16 +57,44 @@ describe('CalendarComponent', () => {
[testWorkoutSession]
));
+ const group = new MeasurementCategory(
+ 'cccccccc-cccc-cccc-cccc-000000000002',
+ "Blood pressure",
+ "mmHg",
+ );
+ group.children = [new MeasurementCategory(
+ 'cccccccc-cccc-cccc-cccc-000000000003',
+ "Systolic",
+ "mmHg",
+ 'custom',
+ false,
+ group.id,
+ )];
(getMeasurementCategories as Mock).mockImplementation(() => Promise.resolve([
new MeasurementCategory(
'cccccccc-cccc-cccc-cccc-000000000001',
"Body Fat",
"%",
- [new MeasurementEntry(
- 'dddddddd-dddd-dddd-dddd-000000000001',
- 'cccccccc-cccc-cccc-cccc-000000000001',
- new Date(currentYear, currentMonth, 1, 12, 0), 20, "Normal"
- )]
+ ),
+ group,
+ ]));
+ // the entries of the month, over all categories, which is where the
+ // components of a group and the body weight arrive in as well
+ (getAllMeasurementEntries as Mock).mockImplementation(() => Promise.resolve([
+ new MeasurementEntry(
+ 'dddddddd-dddd-dddd-dddd-000000000001',
+ 'cccccccc-cccc-cccc-cccc-000000000001',
+ new Date(currentYear, currentMonth, 1, 12, 0), 20, "Normal"
+ ),
+ new MeasurementEntry(
+ 'dddddddd-dddd-dddd-dddd-000000000002',
+ 'cccccccc-cccc-cccc-cccc-000000000003',
+ new Date(currentYear, currentMonth, 1, 12, 0), 120, ""
+ ),
+ new MeasurementEntry(
+ 'dddddddd-dddd-dddd-dddd-000000000003',
+ TEST_BODY_WEIGHT_CATEGORY_UUID,
+ new Date(currentYear, currentMonth, 1, 12, 0), 65, ""
),
]));
@@ -130,8 +165,18 @@ describe('CalendarComponent', () => {
const day = await screen.findByTestId(`day-${dateToYYYYMMDD(new Date(currentYear, currentMonth, 1))}`);
await user.click(day);
+ // more than one measurement, so they are behind the expander
+ await user.click(await screen.findByText('measurements.measurements'));
+
// Assert
- expect(await screen.findByText(/body fat: 20 %/i)).toBeInTheDocument();
+ expect(await screen.findByText('Body Fat')).toBeInTheDocument();
+ expect(screen.getByText(/20 %/i)).toBeInTheDocument();
+ // the components of a group are categories of their own, and the only
+ // place their readings can come from
+ expect(screen.getByText('Systolic')).toBeInTheDocument();
+ expect(screen.getByText(/120 mmHg/i)).toBeInTheDocument();
+ // body weight has its own row on a day, it is not listed a second time
+ expect(screen.queryByText(/65/)).toBeNull();
});
test('displays weight details for selected day', async () => {
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 0a32e0f67..337b5ee15 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -5,6 +5,7 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import {
categoryDisplayName,
MeasurementEntry,
+ useAllMeasurementEntriesQuery,
useBodyWeightQuery,
useMeasurementsCategoryQuery
} from "@/components/Measurements";
@@ -55,11 +56,14 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
"date__lte": dateToYYYYMMDD(endOfMonth),
}
});
- const measurementQuery = useMeasurementsCategoryQuery({
- filtersetQueryEntries: {
- "date__gte": dateToYYYYMMDD(startOfMonth),
- "date__lte": dateToYYYYMMDD(endOfMonth),
- }
+ // 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 nutritionDiaryQuery = useNutritionDiaryQuery({
filtersetQuery: {
@@ -68,8 +72,8 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
}
});
- const isLoading = weightsQuery.isLoading || sessionQuery.isLoading || measurementQuery.isLoading || nutritionDiaryQuery.isLoading;
- const isSuccess = weightsQuery.isSuccess && sessionQuery.isSuccess && measurementQuery.isSuccess && nutritionDiaryQuery.isSuccess;
+ const isLoading = weightsQuery.isLoading || sessionQuery.isLoading || categoryQuery.isLoading || measurementQuery.isLoading || nutritionDiaryQuery.isLoading;
+ const isSuccess = weightsQuery.isSuccess && sessionQuery.isSuccess && categoryQuery.isSuccess && measurementQuery.isSuccess && nutritionDiaryQuery.isSuccess;
const defaultDay: DayProps = {
date: currentDate,
@@ -85,14 +89,25 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
const date = new Date(year, month, 1);
const result: DayProps[] = [];
- const measurements = measurementQuery.data?.flatMap(category =>
- category.entries.map(entry => new CalendarMeasurement(
- categoryDisplayName(category, t),
- category.unit,
- entry.value,
- entry.date,
- ))
- ) ?? [];
+ // Body weight has its own row on a day, and the official category it
+ // is stored in is not in this list; an entry of it is skipped here
+ // rather than shown a second time
+ const byId = new Map((categoryQuery.data ?? [])
+ .flatMap(category => [category, ...category.children])
+ .map(category => [category.id, category]));
+
+ const measurements = (measurementQuery.data ?? []).flatMap(entry => {
+ const category = byId.get(entry.category);
+
+ return category === undefined
+ ? []
+ : [new CalendarMeasurement(
+ categoryDisplayName(category, t),
+ category.unit,
+ entry.value,
+ entry.date,
+ )];
+ });
const firstDayOfMonth = new Date(year, month, 1);
let dayOfWeek = firstDayOfMonth.getDay();
@@ -134,7 +149,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => {
}
return result;
- }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, measurementQuery.data, nutritionDiaryQuery.data, t]);
+ }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, categoryQuery.data, measurementQuery.data, nutritionDiaryQuery.data, t]);
const [selectedDay, setSelectedDay] = useState(days.find(day => isSameDay(day.date, currentDate)) || defaultDay);
const theme = useTheme();
diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx
index 86bee262e..dd5c04bb4 100644
--- a/src/components/Dashboard/MeasurementCard.test.tsx
+++ b/src/components/Dashboard/MeasurementCard.test.tsx
@@ -1,9 +1,18 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, within } from '@testing-library/react';
import { MeasurementCard } from "@/components/Dashboard/MeasurementCard";
-import { MeasurementCategory, useMeasurementsCategoryQuery } from "@/components/Measurements";
+import {
+ MeasurementCategory,
+ useMeasurementEntriesQuery,
+ useMeasurementsCategoryQuery
+} from "@/components/Measurements";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
-import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
+import {
+ TEST_MEASUREMENT_CATEGORY_1,
+ TEST_MEASUREMENT_CATEGORY_2,
+ TEST_MEASUREMENT_SEED_1,
+ TEST_MEASUREMENT_SEED_2
+} from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
import { mockChartQueries } from "@/tests/chartQueries";
@@ -12,6 +21,12 @@ vi.useFakeTimers();
const queryClient = new QueryClient();
+/** Answers the entry reads of the table under each chart, by category */
+const mockEntryQueries = (byCategory: Record) =>
+ (useMeasurementEntriesQuery as Mock).mockImplementation(
+ (categoryId: string) => ({ data: byCategory[categoryId] ?? [] })
+ );
+
describe("smoke test the MeasurementCard component", () => {
describe("Measurements available", () => {
@@ -26,7 +41,11 @@ describe("smoke test the MeasurementCard component", () => {
]
}));
// The cards read their points from the aggregated queries
- mockChartQueries([TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2]);
+ mockChartQueries([TEST_MEASUREMENT_SEED_1, TEST_MEASUREMENT_SEED_2]);
+ mockEntryQueries({
+ [TEST_MEASUREMENT_CATEGORY_1.id!]: TEST_MEASUREMENT_SEED_1.entries,
+ [TEST_MEASUREMENT_CATEGORY_2.id!]: TEST_MEASUREMENT_SEED_2.entries,
+ });
});
test('renders the current categories correctly', async () => {
@@ -53,21 +72,27 @@ describe("smoke test the MeasurementCard component", () => {
beforeEach(() => {
const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
- const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1');
- systolic.entries = [
+ 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');
+ group.children = [systolic, diastolic];
+ const systolicEntries = [
// sorted by date descending, like the server delivers them
new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''),
new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''),
];
- const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', undefined, 'blood_pressure', false, 'g-1');
- group.children = [systolic, diastolic];
(useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
isSuccess: true,
isLoading: false,
data: [group]
}));
- mockChartQueries([group]);
+ mockChartQueries([
+ { category: group },
+ { category: systolic, entries: systolicEntries },
+ { category: diastolic },
+ ]);
+ // the component row reads the newest entry, the group parent none
+ mockEntryQueries({ 'c-sys': systolicEntries });
});
test('lists the latest reading of each component', async () => {
diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx
index a5320602b..26d4ab762 100644
--- a/src/components/Dashboard/MeasurementCard.tsx
+++ b/src/components/Dashboard/MeasurementCard.tsx
@@ -12,6 +12,7 @@ import {
MeasurementCategory,
MeasurementChart,
useMeasurementBucketsQuery,
+ useMeasurementEntriesQuery,
useMeasurementsCategoryQuery,
valueWithUnit
} from "@/components/Measurements";
@@ -37,12 +38,7 @@ const TABLE_ROWS = 5;
export const MeasurementCard = () => {
const { t } = useTranslation();
- // The chart reads its points condensed, the table below it only wants the
- // newest few rows, so that is all that is read here. A window would leave
- // a category that is measured every few months with an empty table, and
- // the full history is what a synced account pays for: the sleep stages
- // alone write five entries a night
- const categoryQuery = useMeasurementsCategoryQuery({ entryLimit: TABLE_ROWS });
+ const categoryQuery = useMeasurementsCategoryQuery();
if (categoryQuery.isLoading) {
return ;
@@ -98,6 +94,36 @@ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) =>
};
+/**
+ * One component of a group, with its latest reading.
+ *
+ * The dot ties the row to the component's line in the chart above, and is
+ * left out where the chart draws something else than one line per component.
+ */
+const ComponentRow = (props: { component: MeasurementCategory, unit: string, color?: string }) => {
+ // Only the newest one is shown, so only the newest one is read
+ const latest = useMeasurementEntriesQuery(props.component.id!, {}, 1).data?.[0];
+
+ return
+
+
+ {props.color !== undefined && }
+ {props.component.name}
+
+
+
+ {latest !== undefined
+ ? valueWithUnit(latest.valueIn(props.unit, props.unit), props.unit, i18n.language)
+ : '—'}
+
+ ;
+};
+
const MeasurementCardTableContent = (props: { category: MeasurementCategory }) => {
const { t } = useTranslation();
@@ -115,6 +141,13 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
&& groupChart(props.category, groupComponentPoints(props.category, buckets)).kind
=== 'components';
const palette = componentPalette(props.category.children.length);
+ // A group lists its components instead, each of which reads its own
+ const entries = useMeasurementEntriesQuery(
+ props.category.id!,
+ {},
+ TABLE_ROWS,
+ !props.category.isGroup,
+ ).data ?? [];
return (<>
@@ -132,31 +165,13 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) =
{props.category.isGroup
// group parents hold no entries themselves, list the
// latest reading of each component instead
- ? props.category.children.map((child, index) => {
- // entries arrive sorted by date descending
- const latest = child.entries[0];
- const unit = child.unit || props.category.unit;
-
- return
-
-
- {showComponentColors && }
- {child.name}
-
-
-
- {latest !== undefined
- ? valueWithUnit(latest.valueIn(unit, unit), unit, i18n.language)
- : '—'}
-
- ;
- })
- : [...props.category.entries].slice(0, TABLE_ROWS).map(entry => (
+ ? props.category.children.map((child, index) =>
+ )
+ : entries.map(entry => (
{entry.date.toLocaleDateString()}
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 19ae861a2..8d7d8bb99 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -5,8 +5,10 @@ import {
deleteMeasurementEntry,
editMeasurementCategory,
editMeasurementEntry,
+ getCategoryEntryFlags,
getMeasurementCategories,
getMeasurementCategory,
+ getMeasurementEntries,
} from "@/components/Measurements/api/measurements";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -69,78 +71,49 @@ describe('measurement service tests', () => {
});
});
- test('Correctly filters categories and entries', async () => {
+ test('Correctly filters the categories', async () => {
- await getMeasurementCategories({
- filtersetQueryEntries: { foo: "bar" },
- filtersetQueryCategories: { baz: "1234" }
- });
+ await getMeasurementCategories({ filtersetQueryCategories: { baz: "1234" } });
- expect(axios.get).toHaveBeenCalledTimes(2);
- expect(axios.get).toHaveBeenNthCalledWith(1,
+ expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('baz=1234'),
expect.anything()
);
- expect(axios.get).toHaveBeenNthCalledWith(2,
- expect.stringContaining('foo=bar'),
- expect.anything()
- );
-
});
- test('GET measurement categories', async () => {
+ test('GET measurement categories reads no entries along with them', async () => {
const result = await getMeasurementCategories();
- expect(axios.get).toHaveBeenCalledTimes(2);
+ expect(axios.get).toHaveBeenCalledTimes(1);
expect(result).toStrictEqual([
- new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [
- new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "")
- ])
+ new MeasurementCategory(CATEGORY_UUID, "Weight", "kg")
]);
});
- test("entries 'none' reads the categories without a request per category", async () => {
-
- const result = await getMeasurementCategories({ entries: 'none' });
-
- expect(axios.get).toHaveBeenCalledTimes(1);
- expect(result[0].entries).toHaveLength(0);
- });
-
- test("entries 'probe' asks for a single entry per category", async () => {
+ test('the entry flags ask for a single entry per category', async () => {
- const result = await getMeasurementCategories({ entries: 'probe' });
+ const result = await getCategoryEntryFlags();
expect(axios.get).toHaveBeenNthCalledWith(2,
expect.stringContaining('limit=1'),
expect.anything()
);
- expect(result[0].entries).toHaveLength(1);
+ expect(result).toStrictEqual([{
+ category: new MeasurementCategory(CATEGORY_UUID, "Weight", "kg"),
+ hasEntries: true,
+ }]);
});
- test("entryLimit caps how many entries are read per category", async () => {
+ test('an entry limit reads the newest entries in a single request', async () => {
- const result = await getMeasurementCategories({ entryLimit: 5 });
+ const result = await getMeasurementEntries(CATEGORY_UUID, {}, 5);
- expect(axios.get).toHaveBeenNthCalledWith(2,
+ expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('limit=5'),
expect.anything()
);
- expect(result[0].entries).toHaveLength(1);
- });
-
- test("entries 'probe' ignores the entry filterset, it fetches no history", async () => {
-
- await getMeasurementCategories({
- entries: 'probe',
- filtersetQueryEntries: { foo: "bar" },
- });
-
- expect(axios.get).toHaveBeenNthCalledWith(2,
- expect.not.stringContaining('foo=bar'),
- expect.anything()
- );
+ expect(result).toHaveLength(1);
});
test('GET measurement categories hides the official body weight category', async () => {
@@ -172,8 +145,6 @@ describe('measurement service tests', () => {
const result = await getMeasurementCategories();
expect(result.map(c => c.id)).toStrictEqual([CATEGORY_UUID]);
- // no entries are loaded for the hidden category
- expect(axios.get).toHaveBeenCalledTimes(2);
});
test('GET measurement category', async () => {
@@ -189,12 +160,10 @@ describe('measurement service tests', () => {
});
const result = await getMeasurementCategory(CATEGORY_UUID);
- expect(axios.get).toHaveBeenCalledTimes(3);
+ expect(axios.get).toHaveBeenCalledTimes(2);
expect(result).toStrictEqual(
- new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [
- new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "")
- ])
+ new MeasurementCategory(CATEGORY_UUID, "Weight", "kg")
);
});
@@ -242,9 +211,7 @@ describe('measurement service tests', () => {
const result = await getMeasurementCategory(CATEGORY_UUID);
expect(result.isGroup).toBe(true);
- expect(result.entries).toStrictEqual([]);
expect(result.children.map(c => c.id)).toStrictEqual([CATEGORY_UUID_2]);
- expect(result.children[0].entries.map(e => e.value)).toStrictEqual([120]);
});
test('GET measurement categories attaches children to their group', async () => {
diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts
index 8b0072e40..88f1319e2 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -71,24 +71,8 @@ export const getMeasurementValueCounts = async (
return data.map((item: unknown) => MeasurementValueCount.fromJson(item));
};
-/**
- * How much of each category's history a caller needs.
- *
- * 'all' is the history the entry filterset asks for, capped by [entryLimit].
- * 'probe' fetches a single entry per category, for callers that only ask
- * whether a category holds entries at all: the lists come back truncated, so
- * `entries.length === 0` is the only thing they may be read for. 'none' skips
- * the entries, and with them one request per category, for callers that need
- * the categories themselves.
- */
-export type EntryLoading = 'all' | 'probe' | 'none';
-
export type MeasurementQueryOptions = {
filtersetQueryCategories?: object,
- filtersetQueryEntries?: object,
- entries?: EntryLoading,
- /** How many entries per category at most, unlimited if left out */
- entryLimit?: number,
}
/**
@@ -128,26 +112,30 @@ export const getMeasurementEntries = async (
};
/**
- * The first entry of a category, or none: enough to tell an empty category
- * from a filled one without reading a history that can run into thousands of
- * rows (the sleep stages alone write five entries a night).
+ * The entries of every category at once, for the callers that show a window
+ * of time rather than one category: asking per category would be one request
+ * each, and would miss the components of a group, which are categories the
+ * category list does not return on their own.
*/
-const probeMeasurementEntries = async (categoryId: string): Promise => {
+export const getAllMeasurementEntries = async (filtersetQuery: object = {}): Promise => {
+ const out: MeasurementEntry[] = [];
const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
- query: { category: categoryId, limit: 1 }
+ query: {
+ limit: API_MAX_PAGE_SIZE,
+ ...filtersetQuery,
+ }
});
- const { data } = await axios.get(url, { headers: makeHeader() });
- return data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData));
+ for await (const page of fetchPaginated(url, makeHeader())) {
+ for (const entryData of page) {
+ out.push(MeasurementEntry.fromJson(entryData));
+ }
+ }
+ return out;
};
export const getMeasurementCategories = async (options?: MeasurementQueryOptions): Promise => {
- const {
- filtersetQueryCategories = {},
- filtersetQueryEntries = {},
- entries = 'all',
- entryLimit,
- } = options || {};
+ const { filtersetQueryCategories = {} } = options || {};
let categories: MeasurementCategory[] = [];
const categoryUrl = makeUrl(API_MEASUREMENTS_CATEGORY_PATH, {
@@ -167,15 +155,6 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
// don't surface it between the regular measurement categories
categories = categories.filter(c => !(c.isOfficial && c.metricType === METRIC_TYPE_BODY_WEIGHT));
- // Load entries for each category
- if (entries !== 'none') {
- await Promise.all(categories.map(async (category) => {
- category.entries = entries === 'probe'
- ? await probeMeasurementEntries(category.id!)
- : await getMeasurementEntries(category.id!, filtersetQueryEntries, entryLimit);
- }));
- }
-
// Multi-value groups: attach the children to their parent, only the
// top-level categories are returned
const byId = new Map(categories.map(c => [c.id, c]));
@@ -193,10 +172,30 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions
return categories.filter(c => c.parentId === null);
};
-export const getMeasurementCategory = async (
- id: string,
- filtersetQueryEntries: object = {},
-): Promise => {
+/** A category, and whether it holds any entries at all */
+export type CategoryEntryFlag = {
+ category: MeasurementCategory,
+ hasEntries: boolean,
+}
+
+/**
+ * The categories, each with whether it holds entries: what the group picker
+ * needs, since only an entry-free category may become a group parent.
+ *
+ * One entry per category is read to answer it, rather than a history that can
+ * run into thousands of rows (the sleep stages alone write five entries a
+ * night). The entries themselves are of no interest, so they don't leave here.
+ */
+export const getCategoryEntryFlags = async (): Promise => {
+ const categories = await getMeasurementCategories();
+
+ return Promise.all(categories.map(async (category) => ({
+ category: category,
+ hasEntries: (await getMeasurementEntries(category.id!, {}, 1)).length > 0,
+ })));
+};
+
+export const getMeasurementCategory = async (id: string): Promise => {
const { data: receivedCategories } = await axios.get(
makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }),
{ headers: makeHeader() },
@@ -215,10 +214,6 @@ export const getMeasurementCategory = async (
}
category.children.sort((a, b) => a.order - b.order);
- await Promise.all([category, ...category.children].map(async (cat) => {
- cat.entries = await getMeasurementEntries(cat.id!, filtersetQueryEntries);
- }));
-
return category;
};
diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts
index 79594ff67..6fcac8666 100644
--- a/src/components/Measurements/charts/data.test.ts
+++ b/src/components/Measurements/charts/data.test.ts
@@ -23,7 +23,7 @@ import {
weeklyDeltas
} from "@/components/Measurements/charts/data";
import { ChartPoint } from "@/components/Measurements/charts/series";
-import { bucketsFor } from "@/tests/chartQueries";
+import { bucketsFor, CategorySeed } from "@/tests/chartQueries";
import { describe, expect, test } from 'vitest';
const entry = (date: Date, value: number, extraData: Record = {}) =>
@@ -339,9 +339,12 @@ describe('niceBinWidth', () => {
});
});
+/** A group and the entries its components hold, which are read separately */
+type SeededGroup = { group: MeasurementCategory, seeds: CategorySeed[] };
+
/** The points the aggregated read returns for a group */
-const groupPoints = (group: MeasurementCategory) =>
- groupComponentPoints(group, group.children.flatMap(child => bucketsFor(child)));
+const groupPoints = (seeded: SeededGroup) =>
+ groupComponentPoints(seeded.group, seeded.seeds.flatMap(seed => bucketsFor(seed)));
describe('buildHistogram', () => {
const counted = (...values: number[]) => values.map(value => ({ value: value, count: 1 }));
@@ -503,20 +506,28 @@ describe('fillMissingDays', () => {
describe('groups', () => {
- const bloodPressure = (readings: [Date, number, number | null][]) => {
- const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', [], 'blood_pressure');
- const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', [], 'custom', false, 'g-1', 0);
- const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', [], 'custom', false, 'g-1', 1);
+ const bloodPressure = (readings: [Date, number, number | null][]): SeededGroup => {
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
+ const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'custom', false, 'g-1', 0);
+ const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'custom', false, 'g-1', 1);
+ const systolicEntries: MeasurementEntry[] = [];
+ const diastolicEntries: MeasurementEntry[] = [];
for (const [date, high, low] of readings) {
- systolic.entries.push(new MeasurementEntry(null, 'c-sys', date, high, ''));
+ systolicEntries.push(new MeasurementEntry(null, 'c-sys', date, high, ''));
if (low !== null) {
- diastolic.entries.push(new MeasurementEntry(null, 'c-dia', date, low, ''));
+ diastolicEntries.push(new MeasurementEntry(null, 'c-dia', date, low, ''));
}
}
group.children = [systolic, diastolic];
- return group;
+ return {
+ group: group,
+ seeds: [
+ { category: systolic, entries: systolicEntries },
+ { category: diastolic, entries: diastolicEntries },
+ ],
+ };
};
test('pairs the components of a reading into one range', () => {
@@ -538,13 +549,13 @@ describe('groups', () => {
});
test('reads the low and high end from the values, not from the component order', () => {
- const group = bloodPressure([[day(1), 80, 120]]);
+ const seeded = bloodPressure([[day(1), 80, 120]]);
- expect(groupRangeEntries(groupPoints(group))[0]).toMatchObject({ min: 80, max: 120 });
+ expect(groupRangeEntries(groupPoints(seeded))[0]).toMatchObject({ min: 80, max: 120 });
});
test('builds one named component series per child', () => {
- const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]), groupPoints(bloodPressure([[day(1), 120, 80]])));
+ const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]).group, groupPoints(bloodPressure([[day(1), 120, 80]])));
expect(series.map(s => s.label)).toEqual(['Systolic', 'Diastolic']);
expect(series.map(s => s.role)).toEqual(['component', 'component']);
@@ -552,24 +563,31 @@ describe('groups', () => {
});
test('two components are charted as ranges', () => {
- const chart = groupChart(bloodPressure([[day(1), 120, 80]]), groupPoints(bloodPressure([[day(1), 120, 80]])));
+ const chart = groupChart(bloodPressure([[day(1), 120, 80]]).group, groupPoints(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]]), groupPoints(bloodPressure([[day(1), 120, null], [day(2), 125, null]])));
+ const chart = groupChart(
+ bloodPressure([[day(1), 120, null], [day(2), 125, null]]).group,
+ groupPoints(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, '')];
+ const seeded = bloodPressure([[day(1), 120, 80]]);
+ const third = new MeasurementCategory('c-map', 'Mean', 'mmHg', 'custom', false, 'g-1', 2);
+ const group = seeded.group;
group.children = [...group.children, third];
+ seeded.seeds = [
+ ...seeded.seeds,
+ { category: third, entries: [new MeasurementEntry(null, 'c-map', day(1), 93, '')] },
+ ];
- const chart = groupChart(group, groupPoints(group));
+ const chart = groupChart(group, groupPoints(seeded));
expect(chart.kind).toBe('components');
});
@@ -577,56 +595,57 @@ describe('groups', () => {
describe('sleep group', () => {
/** A sleep group: the total plus two stages, all on the same night */
- const sleep = (withStages: boolean = true) => {
- const group = new MeasurementCategory('g-s', 'Sleep', 'min', [], 'sleep');
+ const sleep = (withStages: boolean = true): SeededGroup => {
+ const group = new MeasurementCategory('g-s', 'Sleep', 'min', 'sleep');
const child = (
id: string,
name: string,
type: MetricType,
order: number,
value: number | null,
- ) => {
- const category = new MeasurementCategory(id, name, 'min', [], type, false, 'g-s', order);
- category.entries = value === null
+ ): CategorySeed => ({
+ category: new MeasurementCategory(id, name, 'min', type, false, 'g-s', order),
+ entries: value === null
? []
- : [new MeasurementEntry(`e-${id}`, id, day(2), value, '')];
- return category;
- };
+ : [new MeasurementEntry(`e-${id}`, id, day(2), value, '')],
+ });
- group.children = [
+ const seeds = [
child('total', 'Total sleep', 'sleep_total', 0, 480),
child('deep', 'Deep sleep', 'sleep_deep', 1, withStages ? 90 : null),
child('rem', 'REM sleep', 'sleep_rem', 2, withStages ? 60 : null),
];
- return group;
+ group.children = seeds.map(seed => seed.category);
+
+ return { group: group, seeds: seeds };
};
test('the roll-up component is left out of the stack', () => {
// Total sleep covers the stages, so stacking it would count the night
// twice
- expect(stackableComponents(sleep()).map(c => c.metricType))
+ expect(stackableComponents(sleep().group).map(c => c.metricType))
.toEqual(['sleep_deep', 'sleep_rem']);
});
test('stacked entries carry one value per component and day', () => {
- const group = sleep();
- const stacked = groupStackedEntries(stackableComponents(group), groupPoints(group));
+ const seeded = sleep();
+ const stacked = groupStackedEntries(stackableComponents(seeded.group), groupPoints(seeded));
expect(stacked).toStrictEqual([{ date: day(2).getTime(), values: [90, 60] }]);
});
test('several entries of one day add up within their component', () => {
// A nap next to the night: the bar shows the day, not the segment
- const group = sleep();
- const deep = group.children[1];
- deep.entries = [...deep.entries, new MeasurementEntry('e-nap', 'deep', day(2, 14), 20, '')];
+ const seeded = sleep();
+ const deep = seeded.seeds[1];
+ deep.entries = [...deep.entries!, new MeasurementEntry('e-nap', 'deep', day(2, 14), 20, '')];
- expect(groupStackedEntries(stackableComponents(group), groupPoints(group))[0].values)
+ expect(groupStackedEntries(stackableComponents(seeded.group), groupPoints(seeded))[0].values)
.toEqual([110, 60]);
});
test('a summed group stacks its components', () => {
- const chart = groupChart(sleep(), groupPoints(sleep()));
+ const chart = groupChart(sleep().group, groupPoints(sleep()));
expect(chart.kind).toBe('stacked');
expect(chart.kind === 'stacked' && chart.labels).toEqual(['Deep sleep', 'REM sleep']);
@@ -635,7 +654,7 @@ describe('sleep group', () => {
test('without stage data the group falls back to component lines', () => {
// Only the total reported, so there is nothing to stack. Falling
// through keeps the chart from going blank while data exists
- expect(groupChart(sleep(false), groupPoints(sleep(false))).kind).toBe('components');
+ expect(groupChart(sleep(false).group, groupPoints(sleep(false))).kind).toBe('components');
});
});
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index 7cbecb5b8..c377379f9 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -34,9 +34,11 @@ export {
// Query hooks
export {
useAddMeasurementEntryQuery,
+ useAllMeasurementEntriesQuery,
useDeleteMeasurementEntryQuery,
useEditMeasurementEntryQuery,
useMeasurementBucketsQuery,
+ useMeasurementEntriesQuery,
useMeasurementsCategoryQuery,
useMeasurementValueCountsQuery
} from "./queries";
diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts
index 44948c249..a2c05999b 100644
--- a/src/components/Measurements/models/Category.test.ts
+++ b/src/components/Measurements/models/Category.test.ts
@@ -66,7 +66,7 @@ describe('MeasurementCategory', () => {
});
test('clone treats a null parentId override as "remove from group"', () => {
- const category = new MeasurementCategory('c-1', 'Systolic', 'mmHg', undefined, 'blood_pressure', false, 'c-parent', 1);
+ const category = new MeasurementCategory('c-1', 'Systolic', 'mmHg', 'blood_pressure', false, 'c-parent', 1);
expect(MeasurementCategory.clone(category).parentId).toBe('c-parent');
expect(MeasurementCategory.clone(category, { name: 'x' }).parentId).toBe('c-parent');
@@ -146,7 +146,7 @@ describe('MeasurementCategory', () => {
test('toJson sends the picked type', () => {
const category = new MeasurementCategory(
- 'c-1', 'Steps', 'steps', undefined, 'steps', false, null, 0, 'heatmap',
+ 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap',
);
expect(category.toJson().chart_type).toBe('heatmap');
@@ -154,7 +154,7 @@ describe('MeasurementCategory', () => {
test('clone carries the chart type over and can override it', () => {
const category = new MeasurementCategory(
- 'c-1', 'Steps', 'steps', undefined, 'steps', false, null, 0, 'heatmap',
+ 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap',
);
expect(MeasurementCategory.clone(category).chartType).toBe('heatmap');
@@ -254,7 +254,7 @@ describe('MeasurementCategory', () => {
test('a typed category is named after its metric type', () => {
const category = new MeasurementCategory(
- 'c-1', 'Blutdruck', 'mmHg', undefined, 'blood_pressure_systolic',
+ 'c-1', 'Blutdruck', 'mmHg', 'blood_pressure_systolic',
);
expect(categoryDisplayName(category, t))
diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts
index 87a4e491c..7533419d0 100644
--- a/src/components/Measurements/models/Category.ts
+++ b/src/components/Measurements/models/Category.ts
@@ -1,4 +1,3 @@
-import { MeasurementEntry } from "@/components/Measurements/models/Entry";
import { Adapter } from "@/core/lib/Adapter";
import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit";
import { TFunction } from "i18next";
@@ -404,8 +403,6 @@ export function isGroupTotalMetricType(type: MetricType): boolean {
export class MeasurementCategory {
- entries: MeasurementEntry[] = [];
-
/**
* Child categories (components) of a multi-value group such as blood
* pressure. Populated by the API layer for display, never persisted
@@ -417,7 +414,6 @@ export class MeasurementCategory {
public id: string | null,
public name: string,
public unit: string,
- entries?: MeasurementEntry[],
public metricType: MetricType = 'custom',
public isOfficial: boolean = false,
public parentId: string | null = null,
@@ -427,9 +423,6 @@ export class MeasurementCategory {
/** Taste-level chart settings, read through trendOf and averageWindowOf */
public chartConfig: ChartConfig = {},
) {
- if (entries) {
- this.entries = entries;
- }
}
get isGroup(): boolean {
@@ -441,7 +434,6 @@ export class MeasurementCategory {
overrides?.id ?? other.id,
overrides?.name ?? other.name,
overrides?.unit ?? other.unit,
- other.entries,
overrides?.metricType ?? other.metricType,
other.isOfficial,
// null is a meaningful override here (remove from group), so the
@@ -483,7 +475,6 @@ class MeasurementCategoryAdapter implements Adapter {
item.id,
item.name,
item.unit,
- undefined,
metricTypeFromApi(item.metric_type),
item.is_official,
item.parent ?? null,
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index a1c3f02ed..7a289ff08 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -6,9 +6,12 @@ import {
editMeasurementCategory,
editMeasurementEntry,
BucketLevel,
+ getAllMeasurementEntries,
+ getCategoryEntryFlags,
getMeasurementBuckets,
getMeasurementCategories,
getMeasurementCategory,
+ getMeasurementEntries,
getMeasurementValueCounts,
MeasurementQueryOptions,
updateMeasurementCategoryOrder
@@ -19,19 +22,41 @@ import { QueryKey } from "@/core/lib/consts";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+/**
+ * Which categories hold entries. Under the category key so that adding or
+ * removing one refreshes it, like every other read of that list.
+ */
+const CATEGORY_ENTRY_FLAGS_KEY = [QueryKey.MEASUREMENTS_CATEGORIES, 'entry-flags'];
+
/** The condensed reads behind the charts, which every write invalidates */
const invalidateChartReads = (queryClient: ReturnType) => {
queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_BUCKETS,] });
queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_VALUE_COUNTS,] });
};
+/**
+ * What a written entry ages: the entries themselves, the charts drawn from
+ * them, and whether the category holds any. Not the categories, they carry
+ * nothing that an entry can change.
+ */
+const invalidateEntryReads = (queryClient: ReturnType) => {
+ queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_ENTRIES,] });
+ queryClient.invalidateQueries({ queryKey: CATEGORY_ENTRY_FLAGS_KEY });
+ invalidateChartReads(queryClient);
+};
+
export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) {
return useQuery({
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, JSON.stringify(options || {})],
queryFn: () => getMeasurementCategories(options),
- // Widening the range refetches, and the charts would otherwise drop
- // back to the loading placeholder while the longer history arrives
- placeholderData: keepPreviousData,
+ });
+}
+
+/** The categories, each with whether it holds entries: what a group parent may be */
+export function useCategoryEntryFlagsQuery() {
+ return useQuery({
+ queryKey: CATEGORY_ENTRY_FLAGS_KEY,
+ queryFn: () => getCategoryEntryFlags(),
});
}
@@ -98,28 +123,49 @@ export const useReorderMeasurementCategoriesQuery = () => {
});
};
-export function useMeasurementsQuery(id: string, filtersetQueryEntries: object = {}) {
+export function useMeasurementsQuery(id: string) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENTS, id, JSON.stringify(filtersetQueryEntries)],
- queryFn: () => getMeasurementCategory(id, filtersetQueryEntries),
+ queryKey: [QueryKey.MEASUREMENTS, id],
+ queryFn: () => getMeasurementCategory(id),
+ });
+}
+
+/**
+ * The entries of one category.
+ *
+ * [limit] reads only that many of the newest ones, for the callers that show
+ * the latest handful rather than a span of time.
+ */
+export function useMeasurementEntriesQuery(
+ categoryId: string,
+ filtersetQuery: object = {},
+ limit?: number,
+ enabled: boolean = true,
+) {
+ return useQuery({
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), limit ?? null],
+ queryFn: () => getMeasurementEntries(categoryId, filtersetQuery, limit),
+ enabled: enabled,
+ // Picking another range refetches, and the table would otherwise drop
+ // back to the loading placeholder while the new one arrives
placeholderData: keepPreviousData,
});
}
+/** The entries of every category in one read, for the views that show a window of time */
+export function useAllMeasurementEntriesQuery(filtersetQuery: object = {}) {
+ return useQuery({
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'all', JSON.stringify(filtersetQuery)],
+ queryFn: () => getAllMeasurementEntries(filtersetQuery),
+ });
+}
+
export const useAddMeasurementEntryQuery = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (entry: MeasurementEntry) => addMeasurementEntry(entry),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS,]
- });
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- });
- invalidateChartReads(queryClient);
- }
+ onSuccess: () => invalidateEntryReads(queryClient)
});
};
@@ -129,15 +175,7 @@ export const useAddGroupEntriesQuery = () => {
return useMutation({
mutationFn: (entries: MeasurementEntry[]) => Promise.all(entries.map(entry => addMeasurementEntry(entry))),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS,]
- });
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- });
- invalidateChartReads(queryClient);
- }
+ onSuccess: () => invalidateEntryReads(queryClient)
});
};
@@ -146,15 +184,7 @@ export const useEditMeasurementEntryQuery = () => {
return useMutation({
mutationFn: (entry: MeasurementEntry) => editMeasurementEntry(entry),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS,]
- });
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- });
- invalidateChartReads(queryClient);
- }
+ onSuccess: () => invalidateEntryReads(queryClient)
});
};
@@ -163,16 +193,7 @@ export const useDeleteMeasurementEntryQuery = () => {
return useMutation({
mutationFn: (id: string) => deleteMeasurementEntry(id),
- onSuccess: () => {
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS,]
- });
- // The category lists carry the entries as well, like on add and edit
- queryClient.invalidateQueries({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,]
- });
- invalidateChartReads(queryClient);
- }
+ onSuccess: () => invalidateEntryReads(queryClient)
});
};
diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
index 49ff6a4c5..3e007638d 100644
--- a/src/components/Measurements/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -50,8 +50,6 @@ export const BodyWeight = () => {
planPeriods={planPeriods}
chartConfig={categoryQuery.data!.chartConfig} />
- {/* The entries are read by their own query here, the official
- category is fetched without them */}
{
isLoading: false,
data: TEST_MEASUREMENT_CATEGORY_1
}));
- // The chart reads its points from the aggregated queries
- mockChartQueries([TEST_MEASUREMENT_CATEGORY_1]);
+ // The chart reads its points from the aggregated queries, the grid
+ // under it the entries themselves
+ mockChartQueries([TEST_MEASUREMENT_SEED_1]);
+ (useMeasurementEntriesQuery as Mock).mockImplementation(() => ({
+ data: TEST_MEASUREMENT_ENTRIES_1
+ }));
});
afterEach(() => {
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 4908542ba..17709e3b8 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -4,9 +4,10 @@ import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import {
categoryDisplayName,
correlatesWithNutrition,
+ MeasurementCategory,
METRIC_TYPE_BODY_WEIGHT
} from "@/components/Measurements/models/Category";
-import { useMeasurementsQuery } from "@/components/Measurements/queries";
+import { useMeasurementEntriesQuery, useMeasurementsQuery } from "@/components/Measurements/queries";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
@@ -19,6 +20,18 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { Navigate, useParams } from "react-router-dom";
+/**
+ * The grid of one category, over the entries the range covers.
+ *
+ * A component of its own because a group renders one per component, and each
+ * of them reads its own entries.
+ */
+const CategoryEntriesGrid = (props: { category: MeasurementCategory, range: ChartRange }) => {
+ const entriesQuery = useMeasurementEntriesQuery(props.category.id!, entryFilterFor(props.range));
+
+ return ;
+};
+
export const MeasurementCategoryDetail = () => {
const params = useParams<{ categoryId: string }>();
const categoryId = params.categoryId ?? '';
@@ -28,10 +41,8 @@ export const MeasurementCategoryDetail = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [range, setRange] = React.useState(DEFAULT_CHART_RANGE);
- // Fetch what the range shows, rather than the whole history. The grid
- // below lists the same entries, so it follows the range too
// eslint-disable-next-line react-hooks/rules-of-hooks
- const categoryQuery = useMeasurementsQuery(categoryId, entryFilterFor(range));
+ const categoryQuery = useMeasurementsQuery(categoryId);
// eslint-disable-next-line react-hooks/rules-of-hooks
const planPeriods = useNutritionPlanPeriods(
correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
@@ -67,9 +78,9 @@ export const MeasurementCategoryDetail = () => {
? categoryQuery.data!.children.map(child =>
{categoryDisplayName(child, t)}
-
+
)
- : }
+ : }
}
fab={ }
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
index 106ad7f94..2850228e6 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx
@@ -6,7 +6,12 @@ import { MeasurementCategoryOverview } from "@/components/Measurements/screens/M
import React from 'react';
import { BrowserRouter } from "react-router-dom";
import { mockChartQueries } from "@/tests/chartQueries";
-import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData";
+import {
+ TEST_MEASUREMENT_CATEGORY_1,
+ TEST_MEASUREMENT_CATEGORY_2,
+ TEST_MEASUREMENT_SEED_1,
+ TEST_MEASUREMENT_SEED_2
+} from "@/tests/measurementsTestData";
import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/queries");
@@ -25,7 +30,7 @@ describe("Test the MeasurementCategoryOverview component", () => {
mutate: vi.fn()
}));
// The cards read their points from the aggregated queries
- mockChartQueries([TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2]);
+ mockChartQueries([TEST_MEASUREMENT_SEED_1, TEST_MEASUREMENT_SEED_2]);
});
afterEach(() => {
diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
index 6f7c8aba9..9c254610b 100644
--- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx
@@ -59,10 +59,7 @@ export const MeasurementCategoryOverview = () => {
// 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);
- // Only the categories: the cards chart the condensed reads, so nothing on
- // this page touches the entries themselves. The range stays out of the
- // query as well, otherwise picking one would read the same list again
- const categoryQuery = useMeasurementsCategoryQuery({ entries: 'none' });
+ const categoryQuery = useMeasurementsCategoryQuery();
return categoryQuery.isLoading
?
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index 840836d9c..d03095b45 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -30,19 +30,15 @@ describe('CategoryDetailDataGrid', () => {
});
test('entries synced from a health app offer no edit or delete actions', async () => {
- const category = new MeasurementCategory(
- CATEGORY_UUID,
- 'Biceps',
- 'cm',
- [
- new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 10, '', 'user'),
- new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 12, '', 'apple'),
- ],
- );
+ const category = new MeasurementCategory(CATEGORY_UUID, 'Biceps', 'cm');
+ const entries = [
+ new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 10, '', 'user'),
+ new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 12, '', 'apple'),
+ ];
render(
-
+
);
await screen.findByText('10');
@@ -59,19 +55,15 @@ describe('CategoryDetailDataGrid', () => {
});
test('a duration reads h:mm, in the value and in the change columns', async () => {
- const category = new MeasurementCategory(
- CATEGORY_UUID,
- 'Total sleep',
- 'min',
- [
- new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 480, '', 'user'),
- new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 437, '', 'apple'),
- ],
- );
+ const category = new MeasurementCategory(CATEGORY_UUID, 'Total sleep', 'min');
+ const entries = [
+ new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 480, '', 'user'),
+ new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 437, '', 'apple'),
+ ];
render(
-
+
);
await screen.findByText('8:00 h');
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index a43bae3ab..252e961d8 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -46,8 +46,8 @@ const buildRows = (entries: MeasurementEntry[], unit: string, categoryUnit: stri
export const CategoryDetailDataGrid = (props: {
category: MeasurementCategory,
- /** Rows to show, the category's own entries by default */
- entries?: MeasurementEntry[],
+ /** Rows to show, read by the caller: a category carries no entries itself */
+ entries: MeasurementEntry[],
/**
* Unit the values are shown and edited in, the category's own by default.
* Body weight is shown in the profile unit, since its entries can be
@@ -57,7 +57,7 @@ export const CategoryDetailDataGrid = (props: {
}) => {
const [t, i18n] = useTranslation();
- const entries = props.entries ?? props.category.entries;
+ const entries = props.entries;
const unit = props.displayUnit ?? props.category.unit;
const data: GridRowsProp = buildRows(entries, unit, props.category.unit);
const updateEntryQuery = useEditMeasurementEntryQuery();
diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx
index 7acf2a91d..50ee84835 100644
--- a/src/components/Measurements/widgets/CategoryForm.test.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.test.tsx
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import {
useAddMeasurementCategoryQuery,
useEditMeasurementCategoryQuery,
- useMeasurementsCategoryQuery
+ useCategoryEntryFlagsQuery
} from "@/components/Measurements/queries";
import { MeasurementCategory, TrendCharacter } from "@/components/Measurements/models/Category";
import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm";
@@ -34,7 +34,6 @@ const groupWithComponent = (childId: string): MeasurementCategory => {
childId,
'Systolic',
'mmHg',
- undefined,
'blood_pressure',
false,
TEST_GROUP_CATEGORY.id,
@@ -56,8 +55,13 @@ describe("Test the CategoryForm component", () => {
(useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({
mutate: mutate
}));
- (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
- data: [TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2, TEST_GROUP_CATEGORY]
+ // the two measurement categories hold entries, the group is free
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({
+ data: [
+ { category: TEST_MEASUREMENT_CATEGORY_1, hasEntries: true },
+ { category: TEST_MEASUREMENT_CATEGORY_2, hasEntries: true },
+ { category: TEST_GROUP_CATEGORY, hasEntries: false },
+ ]
}));
});
@@ -182,7 +186,6 @@ describe("Test the CategoryForm component", () => {
null,
'Something',
'mmHg',
- undefined,
'custom',
false,
TEST_GROUP_CATEGORY.id,
@@ -208,8 +211,11 @@ describe("Test the CategoryForm component", () => {
test('A group is not offered as a parent, it only holds its own components', async () => {
// Arrange
- (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
- data: [MeasurementCategory.clone(TEST_GROUP_CATEGORY, { metricType: 'blood_pressure' })]
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({
+ data: [{
+ category: MeasurementCategory.clone(TEST_GROUP_CATEGORY, { metricType: 'blood_pressure' }),
+ hasEntries: false,
+ }]
}));
// Act
@@ -250,8 +256,11 @@ describe("Test the CategoryForm component", () => {
'Waist',
'cm',
);
- (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
- data: [group, candidate]
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({
+ data: [
+ { category: group, hasEntries: false },
+ { category: candidate, hasEntries: false },
+ ]
}));
// Act
@@ -297,8 +306,8 @@ describe("Test the CategoryForm component", () => {
// Arrange: its chart follows from what its components are to each
// other, which is what groupChart decides; a pick would have no effect
const group = groupWithComponent('cccccccc-cccc-cccc-cccc-000000000044');
- (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
- data: [group]
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({
+ data: [{ category: group, hasEntries: false }]
}));
// Act
@@ -345,7 +354,7 @@ describe("Test the CategoryForm component", () => {
test('A summed type has no line to configure', () => {
// Its chart is one bar per day, which has neither a trend nor an average
const steps = new MeasurementCategory(
- 'cccccccc-cccc-cccc-cccc-000000000045', 'Steps', 'steps', undefined, 'steps',
+ 'cccccccc-cccc-cccc-cccc-000000000045', 'Steps', 'steps', 'steps',
);
// Act
diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx
index 71735e970..5b6afe608 100644
--- a/src/components/Measurements/widgets/CategoryForm.tsx
+++ b/src/components/Measurements/widgets/CategoryForm.tsx
@@ -13,8 +13,8 @@ import {
} from "@/components/Measurements/models/Category";
import {
useAddMeasurementCategoryQuery,
- useEditMeasurementCategoryQuery,
- useMeasurementsCategoryQuery
+ useCategoryEntryFlagsQuery,
+ useEditMeasurementCategoryQuery
} from "@/components/Measurements/queries";
import { Button, MenuItem, Stack, TextField } from "@mui/material";
import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
@@ -49,31 +49,31 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
const [t] = useTranslation();
const useAddCategoryQuery = useAddMeasurementCategoryQuery();
const useEditCategoryQuery = useEditMeasurementCategoryQuery(category?.id || '');
- // The categories are read only to offer the groups this one can join, and
- // of their entries only whether there are any at all, so a single entry
- // per category is fetched instead of every one of them
- const categoryQuery = useMeasurementsCategoryQuery({ entries: 'probe' });
+ // The categories are read only to offer the groups this one can join, of
+ // which an entry-free one is one, so that is all that is asked of them
+ const categoryQuery = useCategoryEntryFlagsQuery();
// Name and unit belong to the user only for a free-form category. A typed
// one takes both from its metric type, which is also what is shown for it
const isCustom = (category?.metricType ?? 'custom') === 'custom';
+ // Asked of the category itself, which carries its components: the query
+ // returns the top-level ones only, so looking for a row whose parent is
+ // this one never finds anything
+ const hasChildren = category?.isGroup ?? false;
// Multi-value groups, e.g. blood pressure. Mirrors the server rules: only
// top-level, entry-free categories can be parents, a category that already
// has children cannot be nested, a typed category stays top-level, and a
// group takes only its own components. The current parent always stays
// selectable so editing something else doesn't silently drop it.
- const categories = categoryQuery.data ?? [];
- // Asked of the category itself, which carries its components: the query
- // returns the top-level ones only, so looking for a row whose parent is
- // this one never finds anything
- const hasChildren = category?.isGroup ?? false;
- const parentCandidates = categories.filter(c =>
- c.parentId === null
- && c.id !== category?.id
- && !isGroupMetricType(c.metricType)
- && (c.entries.length === 0 || c.id === category?.parentId)
- );
+ const parentCandidates = (categoryQuery.data ?? [])
+ .filter(({ category: c, hasEntries }) =>
+ c.parentId === null
+ && c.id !== category?.id
+ && !isGroupMetricType(c.metricType)
+ && (!hasEntries || c.id === category?.parentId)
+ )
+ .map(({ category: c }) => c);
// Match the backend column limits. We do NOT enforce a minimum length:
// many users have legitimate 1-2 char names (e.g. CJK abbreviations
// like 体重 / 体脂), and the backend allows them.
@@ -149,7 +149,6 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => {
null,
values.name,
values.unit,
- undefined,
values.metricType,
false,
parentId,
diff --git a/src/components/Measurements/widgets/EntryForm.test.tsx b/src/components/Measurements/widgets/EntryForm.test.tsx
index d8524b1c9..1af26bfae 100644
--- a/src/components/Measurements/widgets/EntryForm.test.tsx
+++ b/src/components/Measurements/widgets/EntryForm.test.tsx
@@ -145,10 +145,10 @@ describe("Test the GroupEntryForm component", () => {
const queryClient = new QueryClient();
let mutate = vi.fn();
- const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', undefined, 'blood_pressure');
+ const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure');
group.children = [
- new MeasurementCategory('c-sys', 'Systolic', 'mmHg', undefined, 'blood_pressure_systolic', false, 'g-1'),
- new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', undefined, 'blood_pressure_diastolic', false, 'g-1'),
+ new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1'),
+ new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1'),
];
beforeEach(() => {
diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx
index 5648399f7..1180d0d30 100644
--- a/src/components/Measurements/widgets/MeasurementChart.test.tsx
+++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx
@@ -1,6 +1,6 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from '@testing-library/react';
-import { mockChartQueries } from "@/tests/chartQueries";
+import { CategorySeed, mockChartQueries } from "@/tests/chartQueries";
import { testQueryClient } from "@/tests/queryClient";
import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -10,10 +10,9 @@ import { describe, test } from 'vitest';
vi.mock("@/components/Measurements/queries");
-/** The chart reads its points from the aggregated queries, not from the
- * entries the category carries */
-const renderChart = (element: React.ReactElement, categories: MeasurementCategory[]) => {
- mockChartQueries(categories);
+/** The chart reads its points from the aggregated queries, not as entries */
+const renderChart = (element: React.ReactElement, seeds: CategorySeed[]) => {
+ mockChartQueries(seeds);
return render({element} );
};
@@ -21,58 +20,63 @@ const renderChart = (element: React.ReactElement, categories: MeasurementCategor
const entry = (id: string, date: Date, value: number) =>
new MeasurementEntry(id, 'c-1', date, value, '');
+/** A category with the history the server holds for it */
+const seed = (category: MeasurementCategory, entries: MeasurementEntry[] = []): CategorySeed =>
+ ({ category: category, entries: entries });
+
// Recharts only paints SVG content once a ResizeObserver entry reports real
// dimensions, which jsdom does not provide. We therefore only assert the
// charts mount; the aggregation logic is covered separately below.
describe('MeasurementChart', () => {
test('mounts a line chart for a custom category', () => {
- const category = new MeasurementCategory('c-1', 'Biceps', 'cm', [
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm');
+
+ renderChart( , [seed(category, [
entry('d-1', new Date(2023, 1, 1), 30),
entry('d-2', new Date(2023, 1, 2), 31),
- ]);
-
- renderChart( , [category]);
+ ])]);
});
test('mounts a bar chart for a summed-per-day category', () => {
- const category = new MeasurementCategory('c-1', 'Steps', 'steps', [
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', 'steps');
+
+ renderChart( , [seed(category, [
entry('d-1', new Date(2023, 1, 1, 8), 4000),
entry('d-2', new Date(2023, 1, 1, 18), 6000),
- ], 'steps');
-
- renderChart( , [category]);
+ ])]);
});
test('mounts with no entries', () => {
const empty = new MeasurementCategory('c-1', 'Biceps', 'cm');
- const emptySummed = new MeasurementCategory('c-2', 'Steps', 'steps', [], 'steps');
+ const emptySummed = new MeasurementCategory('c-2', 'Steps', 'steps', 'steps');
- renderChart( , [empty]);
- renderChart( , [emptySummed]);
+ renderChart( , [seed(empty)]);
+ renderChart( , [seed(emptySummed)]);
});
test('mounts a combined chart for a group', () => {
const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg');
- const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', [], 'blood_pressure', false, 'g-1');
- systolic.entries = [
- new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''),
- new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''),
- ];
- const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', [], 'blood_pressure', false, 'g-1');
- diastolic.entries = [
- new MeasurementEntry('d-3', 'c-dia', new Date(2023, 1, 1, 8), 80, ''),
- ];
+ 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');
group.children = [systolic, diastolic];
- renderChart( , [group]);
+ renderChart( , [
+ seed(group),
+ seed(systolic, [
+ new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''),
+ new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''),
+ ]),
+ seed(diastolic, [new MeasurementEntry('d-3', 'c-dia', new Date(2023, 1, 1, 8), 80, '')]),
+ ]);
});
test('draws a heatmap when the category asks for one', () => {
- const category = new MeasurementCategory('c-1', 'Steps', 'steps', [
- entry('d-1', new Date(2023, 1, 1), 4000),
- ], 'steps', false, null, 0, 'heatmap');
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap');
- renderChart( , [category]);
+ renderChart(
+ ,
+ [seed(category, [entry('d-1', new Date(2023, 1, 1), 4000)])],
+ );
// Unlike the recharts charts, the grid is plain elements and does
// render in jsdom
@@ -81,38 +85,38 @@ describe('MeasurementChart', () => {
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', [
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'delta');
+
+ renderChart( , [seed(category, [
entry('d-1', new Date(2026, 0, 5), 30),
entry('d-2', new Date(2026, 0, 12), 31),
- ], 'custom', false, null, 0, 'delta');
-
- renderChart( , [category]);
+ ])]);
expect(screen.getByText(/overallChangeWeight/)).toBeInTheDocument();
});
test('a summed metric has no level to change, so no overall change', () => {
- const category = new MeasurementCategory('c-1', 'Steps', 'steps', [
+ const category = new MeasurementCategory('c-1', 'Steps', 'steps', 'steps', false, null, 0, 'delta');
+
+ renderChart( , [seed(category, [
entry('d-1', new Date(2026, 0, 5), 4000),
entry('d-2', new Date(2026, 0, 12), 6000),
- ], 'steps', false, null, 0, 'delta');
-
- renderChart( , [category]);
+ ])]);
expect(screen.queryByText(/overallChangeWeight/)).not.toBeInTheDocument();
});
test('draws a distribution histogram when the category asks for one', () => {
const category = new MeasurementCategory(
- 'c-1', 'Biceps', 'cm',
+ 'c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'distribution',
+ );
+
+ renderChart( , [seed(category,
Array.from(
{ length: 20 },
(_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30 + i % 3),
),
- 'custom', false, null, 0, 'distribution',
- );
-
- renderChart( , [category]);
+ )]);
// Plain elements like the heatmap, so the bars render in jsdom
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
@@ -125,15 +129,15 @@ describe('MeasurementChart', () => {
test('a summed distribution counts days and reads out as days', () => {
const category = new MeasurementCategory(
- 'c-1', 'Steps', 'steps',
+ 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'distribution',
+ );
+
+ renderChart( , [seed(category,
Array.from(
{ length: 20 },
(_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 4000 + 100 * (i % 5)),
),
- 'steps', false, null, 0, 'distribution',
- );
-
- renderChart( , [category]);
+ )]);
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
fireEvent.mouseEnter(chart.firstChild!.firstChild as Element);
@@ -143,18 +147,17 @@ describe('MeasurementChart', () => {
test('a selection from before the data changed is dropped, not read out of range', () => {
// 20 distinct values spread over 20 bins, then the same category
// shrunk to a single bin while the last bin is still hovered
- const wide = new MeasurementCategory(
- 'c-1', 'Biceps', 'cm',
+ const category = new MeasurementCategory(
+ 'c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'distribution',
+ );
+ const wide = seed(category,
Array.from({ length: 20 }, (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30 + i)),
- 'custom', false, null, 0, 'distribution',
);
- const narrow = new MeasurementCategory(
- 'c-1', 'Biceps', 'cm',
+ const narrow = seed(category,
Array.from({ length: 20 }, (_, i) => entry(`d-${i}`, new Date(2026, 0, 1 + i), 30)),
- 'custom', false, null, 0, 'distribution',
);
- const { rerender } = renderChart( , [wide]);
+ const { rerender } = renderChart( , [wide]);
const chart = screen.getByRole('img', { name: 'measurements.chartTypes.distribution' });
fireEvent.mouseEnter(chart.firstChild!.lastChild as Element);
expect(screen.getByText(/distributionEntryCount/)).toBeInTheDocument();
@@ -162,7 +165,7 @@ describe('MeasurementChart', () => {
mockChartQueries([narrow]);
rerender(
-
+
,
);
@@ -171,12 +174,14 @@ describe('MeasurementChart', () => {
});
test('too few values fall back to the derived chart instead of a noise histogram', () => {
- const category = new MeasurementCategory('c-1', 'Biceps', 'cm', [
+ const category = new MeasurementCategory(
+ 'c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'distribution',
+ );
+
+ renderChart( , [seed(category, [
entry('d-1', new Date(2026, 0, 5), 30),
entry('d-2', new Date(2026, 0, 12), 31),
- ], 'custom', false, null, 0, 'distribution');
-
- renderChart( , [category]);
+ ])]);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
@@ -185,7 +190,10 @@ describe('MeasurementChart', () => {
// 30 samples on 4 days are 4 daily totals: not enough for a
// histogram, whatever the sample count says
const category = new MeasurementCategory(
- 'c-1', 'Steps', 'steps',
+ 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'distribution',
+ );
+
+ renderChart( , [seed(category,
Array.from(
{ length: 30 },
(_, i) => entry(
@@ -194,10 +202,7 @@ describe('MeasurementChart', () => {
500,
),
),
- 'steps', false, null, 0, 'distribution',
- );
-
- renderChart( , [category]);
+ )]);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
@@ -205,28 +210,29 @@ describe('MeasurementChart', () => {
test('keeps the derived chart when the pick does not fit the metric type', () => {
// Bars are not offered for a sample type, and a pick that does not fit
// falls back to the derived chart instead of being drawn anyway
- const category = new MeasurementCategory('c-1', 'Biceps', 'cm', [
- entry('d-1', new Date(2023, 1, 1), 30),
- ], 'custom', false, null, 0, 'bar');
+ const category = new MeasurementCategory('c-1', 'Biceps', 'cm', 'custom', false, null, 0, 'bar');
- renderChart( , [category]);
+ renderChart(
+ ,
+ [seed(category, [entry('d-1', new Date(2023, 1, 1), 30)])],
+ );
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
test('mounts a stacked chart for a sleep group', () => {
- const group = new MeasurementCategory('g-s', 'Sleep', 'min', [], 'sleep');
- const stage = (id: string, name: string, type: MetricType, value: number) => {
- const category = new MeasurementCategory(id, name, 'min', [], type, false, 'g-s');
- category.entries = [new MeasurementEntry(`d-${id}`, id, new Date(2023, 1, 2), value, '')];
- return category;
- };
- group.children = [
+ const group = new MeasurementCategory('g-s', 'Sleep', 'min', 'sleep');
+ const stage = (id: string, name: string, type: MetricType, value: number) => seed(
+ new MeasurementCategory(id, name, 'min', type, false, 'g-s'),
+ [new MeasurementEntry(`d-${id}`, id, new Date(2023, 1, 2), value, '')],
+ );
+ const stages = [
stage('total', 'Total sleep', 'sleep_total', 480),
stage('deep', 'Deep sleep', 'sleep_deep', 90),
stage('rem', 'REM sleep', 'sleep_rem', 60),
];
+ group.children = stages.map(stage => stage.category);
- renderChart( , [group]);
+ renderChart( , [seed(group), ...stages]);
});
});
diff --git a/src/components/Measurements/widgets/MetricPicker.test.tsx b/src/components/Measurements/widgets/MetricPicker.test.tsx
index a48169863..982044eb5 100644
--- a/src/components/Measurements/widgets/MetricPicker.test.tsx
+++ b/src/components/Measurements/widgets/MetricPicker.test.tsx
@@ -3,6 +3,7 @@ import { render, screen } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import {
useAddMeasurementCategoryQuery,
+ useCategoryEntryFlagsQuery,
useMeasurementsCategoryQuery
} from "@/components/Measurements/queries";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
@@ -27,6 +28,8 @@ describe("Test the NewCategoryPicker component", () => {
mutate = vi.fn();
(useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({ mutate: mutate }));
+ // read by the form the custom entry leads into
+ (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ data: [] }));
(useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({
data: [TEST_MEASUREMENT_CATEGORY_1]
}));
@@ -69,7 +72,6 @@ describe("Test the NewCategoryPicker component", () => {
null,
'Resting heart rate',
'bpm',
- undefined,
'resting_heart_rate',
), expect.anything());
});
@@ -82,7 +84,6 @@ describe("Test the NewCategoryPicker component", () => {
'cccccccc-cccc-cccc-cccc-000000000099',
'Distance',
'km',
- undefined,
'distance',
);
mutate.mockImplementation((_category, options) => options.onSuccess(created));
diff --git a/src/components/Measurements/widgets/MetricPicker.tsx b/src/components/Measurements/widgets/MetricPicker.tsx
index 1ae7a4108..4fde6403e 100644
--- a/src/components/Measurements/widgets/MetricPicker.tsx
+++ b/src/components/Measurements/widgets/MetricPicker.tsx
@@ -32,7 +32,7 @@ export const NewCategoryPicker = ({ closeFn }: { closeFn?: () => void }) => {
const [t, i18n] = useTranslation();
const navigate = useNavigate();
const [isCustom, setIsCustom] = React.useState(false);
- const categoryQuery = useMeasurementsCategoryQuery({ entries: 'none' });
+ const categoryQuery = useMeasurementsCategoryQuery();
const addCategoryQuery = useAddMeasurementCategoryQuery();
if (isCustom) {
@@ -52,7 +52,7 @@ export const NewCategoryPicker = ({ closeFn }: { closeFn?: () => void }) => {
key={metricType}
disabled={taken.has(metricType)}
onClick={() => addCategoryQuery.mutate(
- new MeasurementCategory(null, defaults.name, defaults.unit, undefined, metricType),
+ new MeasurementCategory(null, defaults.name, defaults.unit, metricType),
{
// Straight to the new category: the overview is long
// enough that a row appearing somewhere in it does not
diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts
index e96ad134e..4a92a98de 100644
--- a/src/core/lib/consts.ts
+++ b/src/core/lib/consts.ts
@@ -62,6 +62,7 @@ export enum QueryKey {
// the body weight queries)
MEASUREMENTS = 'measurements',
MEASUREMENTS_CATEGORIES = 'measurements-categories',
+ MEASUREMENT_ENTRIES = 'measurement-entries',
MEASUREMENT_BUCKETS = 'measurement-buckets',
MEASUREMENT_VALUE_COUNTS = 'measurement-value-counts',
diff --git a/src/tests/chartQueries.ts b/src/tests/chartQueries.ts
index 6a0a221b8..b6b989aa7 100644
--- a/src/tests/chartQueries.ts
+++ b/src/tests/chartQueries.ts
@@ -7,18 +7,26 @@ import {
} from "@/components/Measurements/queries";
import type { Mock } from 'vitest';
+/** A category and the entries the server holds for it */
+export type CategorySeed = {
+ category: MeasurementCategory,
+ entries?: MeasurementEntry[],
+}
+
/**
- * Answers the aggregated chart reads from the entries the [categories] carry.
+ * Answers the aggregated chart reads from the entries of the seeded categories.
+ *
+ * The charts read those condensed rather than as entries, and a test that
+ * seeds a history would otherwise have to build the condensed shapes by hand.
+ * One bucket per entry, which is what the server returns for a series short
+ * enough not to be condensed, and daily totals for the summed metrics, which
+ * it condenses whatever the count.
*
- * The charts read those separately from the categories in production; a test
- * that seeds one list would otherwise have to build the condensed shapes by
- * hand. One bucket per entry, which is what the server returns for a series
- * short enough not to be condensed, and daily totals for the summed metrics,
- * which it condenses whatever the count.
+ * The components of a group are seeded like any other category: they are ones,
+ * and the chart asks for them by id.
*/
-export const mockChartQueries = (categories: MeasurementCategory[]) => {
- const flat = categories.flatMap(category => [category, ...category.children]);
- const byId = new Map(flat.map(category => [category.id, category]));
+export const mockChartQueries = (seeds: CategorySeed[]) => {
+ const byId = new Map(seeds.map(seed => [seed.category.id, seed]));
(useMeasurementBucketsQuery as Mock).mockImplementation((ids: string[]) => ({
data: ids.flatMap(id => bucketsFor(byId.get(id))),
@@ -50,15 +58,15 @@ const groupBy = (items: T[], key: (item: T) => number): Map => {
* The buckets the server returns for a category: one per entry, or daily
* totals for the summed metrics, which it condenses whatever the count.
*/
-export const bucketsFor = (category: MeasurementCategory | undefined): MeasurementBucket[] => {
- if (category === undefined) {
+export const bucketsFor = (seed: CategorySeed | undefined): MeasurementBucket[] => {
+ if (seed === undefined) {
return [];
}
- const summed = isSummedPerDay(category.metricType);
+ const summed = isSummedPerDay(seed.category.metricType);
- return [...groupBy(category.entries, entry => startOf(entry, summed)).entries()]
+ return [...groupBy(seed.entries ?? [], entry => startOf(entry, summed)).entries()]
.map(([start, entries]) => new MeasurementBucket(
- category.id!,
+ seed.category.id!,
new Date(start),
(entries[0].extraData.unit as string) ?? null,
entries.length,
@@ -69,24 +77,25 @@ export const bucketsFor = (category: MeasurementCategory | undefined): Measureme
.sort((a, b) => a.start.getTime() - b.start.getTime());
};
-const valueCountsFor = (category: MeasurementCategory | undefined): MeasurementValueCount[] => {
- if (category === undefined) {
+const valueCountsFor = (seed: CategorySeed | undefined): MeasurementValueCount[] => {
+ if (seed === undefined) {
return [];
}
- const summed = isSummedPerDay(category.metricType);
+ const entries = seed.entries ?? [];
+ const summed = isSummedPerDay(seed.category.metricType);
// A summed metric distributes its daily totals, the sample types every
// reading, which is the split the server makes
const values = summed
- ? [...groupBy(category.entries, entry => startOf(entry, true)).values()].map(entries => ({
+ ? [...groupBy(entries, entry => startOf(entry, true)).values()].map(entries => ({
value: entries.reduce((sum, entry) => sum + entry.value, 0),
newest: new Date(Math.max(...entries.map(entry => entry.date.getTime()))),
}))
- : category.entries.map(entry => ({ value: entry.value, newest: entry.date }));
+ : entries.map(entry => ({ value: entry.value, newest: entry.date }));
return [...groupBy(values, item => item.value).entries()].map(([value, items]) =>
new MeasurementValueCount(
- category.id!,
+ seed.category.id!,
value,
null,
items.length,
diff --git a/src/tests/measurementsTestData.ts b/src/tests/measurementsTestData.ts
index 40df955ef..8c161efe1 100644
--- a/src/tests/measurementsTestData.ts
+++ b/src/tests/measurementsTestData.ts
@@ -33,7 +33,6 @@ export const TEST_MEASUREMENT_CATEGORY_1 = new MeasurementCategory(
CATEGORY_1,
"Biceps",
"cm",
- TEST_MEASUREMENT_ENTRIES_1,
);
@@ -41,5 +40,15 @@ export const TEST_MEASUREMENT_CATEGORY_2 = new MeasurementCategory(
CATEGORY_2,
"Body fat",
"%",
- TEST_MEASUREMENT_ENTRIES_2
);
+
+/** A category with its history, for the mocked chart reads */
+export const TEST_MEASUREMENT_SEED_1 = {
+ category: TEST_MEASUREMENT_CATEGORY_1,
+ entries: TEST_MEASUREMENT_ENTRIES_1,
+};
+
+export const TEST_MEASUREMENT_SEED_2 = {
+ category: TEST_MEASUREMENT_CATEGORY_2,
+ entries: TEST_MEASUREMENT_ENTRIES_2,
+};
diff --git a/src/tests/weight/testData.ts b/src/tests/weight/testData.ts
index 097f669e3..9d4e1eac4 100644
--- a/src/tests/weight/testData.ts
+++ b/src/tests/weight/testData.ts
@@ -7,7 +7,6 @@ export const testBodyWeightCategory = new MeasurementCategory(
TEST_BODY_WEIGHT_CATEGORY_UUID,
'Body weight',
'kg',
- undefined,
METRIC_TYPE_BODY_WEIGHT,
true,
);
From ab4e04db4ce328d6275b9c0b00663857aa332df6 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 11:07:14 +0200
Subject: [PATCH 57/71] Read the measurement table one page at a time
---
.../Measurements/api/measurements.test.ts | 69 +++++++++++++++++
.../Measurements/api/measurements.ts | 66 ++++++++++++++++
src/components/Measurements/queries/index.ts | 35 +++++++++
.../MeasurementCategoryDetail.test.tsx | 17 ++--
.../screens/MeasurementCategoryDetail.tsx | 48 ++++++++++--
.../widgets/CategoryDetailDataGrid.test.tsx | 44 +++++++++++
.../widgets/CategoryDetailDataGrid.tsx | 77 ++++++++++++++-----
7 files changed, 326 insertions(+), 30 deletions(-)
diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts
index 8d7d8bb99..777f9750f 100644
--- a/src/components/Measurements/api/measurements.test.ts
+++ b/src/components/Measurements/api/measurements.test.ts
@@ -9,6 +9,8 @@ import {
getMeasurementCategories,
getMeasurementCategory,
getMeasurementEntries,
+ getMeasurementEntryPage,
+ getOldestMeasurementEntry,
} from "@/components/Measurements/api/measurements";
import { MeasurementCategory } from "@/components/Measurements/models/Category";
import { MeasurementEntry } from "@/components/Measurements/models/Entry";
@@ -22,6 +24,7 @@ const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000001';
const CATEGORY_UUID_2 = 'cccccccc-cccc-cccc-cccc-000000000009';
const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001';
const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000005';
+const ENTRY_UUID_3 = 'dddddddd-dddd-dddd-dddd-000000000007';
describe('measurement service tests', () => {
const measurementEntryResponse = {
@@ -116,6 +119,72 @@ describe('measurement service tests', () => {
expect(result).toHaveLength(1);
});
+ test('a page is read with the row after it, which is no part of the page', async () => {
+
+ const entry = (id: string, value: number) => ({
+ "id": id,
+ "category": CATEGORY_UUID,
+ "value": value,
+ "date": "2021-01-01T08:00:00+01:00",
+ "notes": ""
+ });
+ (axios.get as Mock).mockImplementation(() => Promise.resolve({
+ data: {
+ count: 42,
+ next: null,
+ previous: null,
+ results: [entry(ENTRY_UUID, 80), entry(ENTRY_UUID_2, 79), entry(ENTRY_UUID_3, 78)],
+ }
+ }));
+
+ const page = await getMeasurementEntryPage(CATEGORY_UUID, 10, 2);
+
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining('limit=3'),
+ expect.anything()
+ );
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining('offset=10'),
+ expect.anything()
+ );
+ expect(page.entries.map(e => e.value)).toStrictEqual([80, 79]);
+ expect(page.next!.value).toBe(78);
+ // What the table pages through, not what it was handed
+ expect(page.count).toBe(42);
+ });
+
+ test('the last page of a history has no row after it', async () => {
+
+ const page = await getMeasurementEntryPage(CATEGORY_UUID, 0, 10);
+
+ expect(page.entries).toHaveLength(1);
+ expect(page.next).toBeNull();
+ });
+
+ test('the oldest entry is read as a single row, in a total order', async () => {
+
+ const result = await getOldestMeasurementEntry(CATEGORY_UUID);
+
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining(`ordering=${encodeURIComponent('date,id')}`),
+ expect.anything()
+ );
+ expect(axios.get).toHaveBeenCalledWith(
+ expect.stringContaining('limit=1'),
+ expect.anything()
+ );
+ expect(result!.id).toBe(ENTRY_UUID);
+ });
+
+ test('a category without entries has no oldest one', async () => {
+
+ (axios.get as Mock).mockImplementation(() => Promise.resolve({
+ data: { count: 0, next: null, previous: null, results: [] }
+ }));
+
+ expect(await getOldestMeasurementEntry(CATEGORY_UUID)).toBeNull();
+ });
+
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 88f1319e2..c1a9e6169 100644
--- a/src/components/Measurements/api/measurements.ts
+++ b/src/components/Measurements/api/measurements.ts
@@ -111,6 +111,72 @@ export const getMeasurementEntries = async (
return out;
};
+/** One page of a category's entries, newest first, as a table pages through them */
+export type MeasurementEntryPage = {
+ entries: MeasurementEntry[],
+ /** Entries the filter matches in total, i.e. how many pages there are */
+ count: number,
+ /**
+ * The entry right after the page, none at the end of the history: the row
+ * before it is a difference to it, and it is the one row a page is
+ * otherwise missing.
+ */
+ next: MeasurementEntry | null,
+};
+
+/**
+ * One page of a category's entries, rather than the history they are cut out
+ * of: a table shows ten rows at a time, and a synced category holds thousands.
+ */
+export const getMeasurementEntryPage = async (
+ categoryId: string,
+ offset: number,
+ limit: number,
+ filtersetQuery: object = {},
+): Promise => {
+ // One row past the page, which is what its last row is measured against
+ const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
+ query: {
+ category: categoryId,
+ limit: limit + 1,
+ offset: offset,
+ ...filtersetQuery,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+ const entries = data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData));
+
+ return {
+ entries: entries.slice(0, limit),
+ count: data.count,
+ next: entries.length > limit ? entries[limit] : null,
+ };
+};
+
+/**
+ * The oldest entry the filter matches, or none at all.
+ *
+ * The total change of a row is measured against it, so a table that holds a
+ * page rather than the whole history has to ask for it: ordered by id as well,
+ * since entries can share a date and the column would otherwise pick either.
+ */
+export const getOldestMeasurementEntry = async (
+ categoryId: string,
+ filtersetQuery: object = {},
+): Promise => {
+ const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, {
+ query: {
+ category: categoryId,
+ limit: 1,
+ ordering: 'date,id',
+ ...filtersetQuery,
+ }
+ });
+ const { data } = await axios.get(url, { headers: makeHeader() });
+
+ return data.results.length > 0 ? MeasurementEntry.fromJson(data.results[0]) : null;
+};
+
/**
* The entries of every category at once, for the callers that show a window
* of time rather than one category: asking per category would be one request
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index 7a289ff08..c694c5e40 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -12,7 +12,9 @@ import {
getMeasurementCategories,
getMeasurementCategory,
getMeasurementEntries,
+ getMeasurementEntryPage,
getMeasurementValueCounts,
+ getOldestMeasurementEntry,
MeasurementQueryOptions,
updateMeasurementCategoryOrder
} from "@/components/Measurements/api/measurements";
@@ -152,6 +154,39 @@ export function useMeasurementEntriesQuery(
});
}
+/**
+ * One page of a category's entries, for the tables that show a page at a time.
+ *
+ * Kept apart from the query above, which hands over a whole span: a table
+ * shows ten rows, and a synced category holds thousands of them.
+ */
+export function useMeasurementEntryPageQuery(
+ categoryId: string,
+ offset: number,
+ limit: number,
+ filtersetQuery: object = {},
+) {
+ return useQuery({
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), 'page', offset, limit],
+ queryFn: () => getMeasurementEntryPage(categoryId, offset, limit, filtersetQuery),
+ // Turning the page refetches, and the table would otherwise drop back
+ // to an empty grid while the next one arrives
+ placeholderData: keepPreviousData,
+ });
+}
+
+/**
+ * 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
+ * it again: it only changes with the range.
+ */
+export function useOldestMeasurementEntryQuery(categoryId: string, filtersetQuery: object = {}) {
+ return useQuery({
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), 'oldest'],
+ queryFn: () => getOldestMeasurementEntry(categoryId, filtersetQuery),
+ });
+}
+
/** The entries of every category in one read, for the views that show a window of time */
export function useAllMeasurementEntriesQuery(filtersetQuery: object = {}) {
return useQuery({
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
index f5ee51b09..ac1e07104 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx
@@ -1,6 +1,7 @@
import {
- useMeasurementEntriesQuery,
- useMeasurementsQuery
+ useMeasurementEntryPageQuery,
+ useMeasurementsQuery,
+ useOldestMeasurementEntryQuery
} from "@/components/Measurements/queries";
import { MeasurementCategoryDetail } from "@/components/Measurements/screens/MeasurementCategoryDetail";
import { mockChartQueries } from "@/tests/chartQueries";
@@ -33,11 +34,17 @@ describe("Test the MeasurementCategoryDetail component", () => {
data: TEST_MEASUREMENT_CATEGORY_1
}));
// The chart reads its points from the aggregated queries, the grid
- // under it the entries themselves
+ // under it one page of the entries themselves
mockChartQueries([TEST_MEASUREMENT_SEED_1]);
- (useMeasurementEntriesQuery as Mock).mockImplementation(() => ({
- data: TEST_MEASUREMENT_ENTRIES_1
+ (useMeasurementEntryPageQuery as Mock).mockImplementation(() => ({
+ data: {
+ entries: TEST_MEASUREMENT_ENTRIES_1,
+ count: TEST_MEASUREMENT_ENTRIES_1.length,
+ next: null,
+ },
+ isFetching: false,
}));
+ (useOldestMeasurementEntryQuery as Mock).mockImplementation(() => ({ data: null }));
});
afterEach(() => {
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 17709e3b8..176bb7925 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -7,11 +7,17 @@ import {
MeasurementCategory,
METRIC_TYPE_BODY_WEIGHT
} from "@/components/Measurements/models/Category";
-import { useMeasurementEntriesQuery, useMeasurementsQuery } from "@/components/Measurements/queries";
+import {
+ useMeasurementEntryPageQuery,
+ useMeasurementsQuery,
+ useOldestMeasurementEntryQuery
+} from "@/components/Measurements/queries";
import { useNutritionPlanPeriods } from "@/components/Nutrition";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
-import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
+import { ChartRange, DEFAULT_CHART_RANGE, displayFilterFor } from "@/components/Measurements/charts/range";
+import { PAGINATION_OPTIONS } from "@/core/lib/consts";
+import { GridPaginationModel } from "@mui/x-data-grid";
import { AddMeasurementEntryFab } from "@/components/Measurements/widgets/fab";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart";
@@ -21,15 +27,47 @@ import { useTranslation } from "react-i18next";
import { Navigate, useParams } from "react-router-dom";
/**
- * The grid of one category, over the entries the range covers.
+ * The grid of one category, a page at a time over the entries the range
+ * covers.
*
* A component of its own because a group renders one per component, and each
* of them reads its own entries.
*/
const CategoryEntriesGrid = (props: { category: MeasurementCategory, range: ChartRange }) => {
- const entriesQuery = useMeasurementEntriesQuery(props.category.id!, entryFilterFor(props.range));
+ // The range as it is labelled, not the chart's read: that one takes a
+ // month of lead so the moving average has something to average over, and
+ // the table would list those rows as if they 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 entries, and page seven of the last one
+ // says nothing about it
+ React.useEffect(
+ () => setPagination(model => ({ ...model, page: 0 })),
+ [props.range]
+ );
+
+ const pageQuery = useMeasurementEntryPageQuery(
+ props.category.id!,
+ pagination.page * pagination.pageSize,
+ pagination.pageSize,
+ filter,
+ );
+ const oldestQuery = useOldestMeasurementEntryQuery(props.category.id!, filter);
+ const page = pageQuery.data;
- return ;
+ return entry != null),
+ isLoading: pageQuery.isFetching,
+ }} />;
};
export const MeasurementCategoryDetail = () => {
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
index d03095b45..624733a27 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx
@@ -15,6 +15,8 @@ vi.mock("@/components/Measurements/queries");
const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000001';
const USER_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001';
const SYNCED_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000002';
+const NEXT_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000003';
+const OLDEST_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000004';
describe('CategoryDetailDataGrid', () => {
@@ -54,6 +56,48 @@ describe('CategoryDetailDataGrid', () => {
expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument();
});
+ test('a page measures its difference columns against the entries outside it', async () => {
+ const category = new MeasurementCategory(CATEGORY_UUID, 'Biceps', 'cm');
+ const page = [
+ new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 3), 12, ''),
+ new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 11, '', 'apple'),
+ ];
+ // The entry the page ends before, and the oldest one of the range
+ const neighbours = [
+ new MeasurementEntry(NEXT_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 10, ''),
+ new MeasurementEntry(OLDEST_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 0, 1), 5, ''),
+ ];
+
+ render(
+
+
+
+ );
+ await screen.findByText('12 cm');
+
+ const cell = (id: string, field: string) =>
+ document.querySelector(`[data-id="${id}"] [data-field="${field}"]`)?.textContent;
+
+ // The last row of the page differs from the entry after it, and both
+ // rows count from the oldest one there is
+ expect(cell(SYNCED_ENTRY_UUID, 'change')).toBe('1');
+ expect(cell(SYNCED_ENTRY_UUID, 'totalChange')).toBe('6');
+ expect(cell(USER_ENTRY_UUID, 'totalChange')).toBe('7');
+
+ // Neither of them is a row of its own
+ expect(document.querySelector(`[data-id="${NEXT_ENTRY_UUID}"]`)).toBeNull();
+ expect(document.querySelector(`[data-id="${OLDEST_ENTRY_UUID}"]`)).toBeNull();
+ });
+
test('a duration reads h:mm, in the value and in the change columns', async () => {
const category = new MeasurementCategory(CATEGORY_UUID, 'Total sleep', 'min');
const entries = [
diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
index 252e961d8..a7ae77e3b 100644
--- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
+++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx
@@ -20,6 +20,7 @@ import {
GridRowEditStopReasons,
GridRowId,
GridRowModel,
+ GridPaginationModel,
GridRowModes,
GridRowModesModel,
GridRowsProp,
@@ -31,17 +32,28 @@ import { useTranslation } from "react-i18next";
// Values are read through the unit helper, never off the raw column: a
// category can hold entries in mixed units, and the change columns are
// computed from the converted values
-const buildRows = (entries: MeasurementEntry[], unit: string, categoryUnit: string): GridRowsProp =>
- processTimeSeries(entries, e => e.valueIn(unit, categoryUnit)).map((row) => ({
- id: row.entry.id,
- date: row.entry.date,
- value: row.entry.valueIn(unit, categoryUnit),
- notes: row.entry.notes,
- isEditable: row.entry.isEditable,
- change: +row.change.toFixed(2),
- totalChange: +row.totalChange.toFixed(2),
- days: +row.days.toFixed(1),
- }));
+//
+// [neighbours] are entries the shown ones are measured against without being
+// rows themselves, see the pagination prop. They are always older, so the
+// rows stay at the front of the series and the extra ones cut off again
+const buildRows = (
+ entries: MeasurementEntry[],
+ neighbours: MeasurementEntry[],
+ unit: string,
+ categoryUnit: string,
+): GridRowsProp =>
+ processTimeSeries([...entries, ...neighbours], e => e.valueIn(unit, categoryUnit))
+ .slice(0, entries.length)
+ .map((row) => ({
+ id: row.entry.id,
+ date: row.entry.date,
+ value: row.entry.valueIn(unit, categoryUnit),
+ notes: row.entry.notes,
+ isEditable: row.entry.isEditable,
+ change: +row.change.toFixed(2),
+ totalChange: +row.totalChange.toFixed(2),
+ days: +row.days.toFixed(1),
+ }));
export const CategoryDetailDataGrid = (props: {
@@ -54,12 +66,34 @@ export const CategoryDetailDataGrid = (props: {
* stored in either; an edited value is then stamped with it.
*/
displayUnit?: string,
+ /**
+ * Reading one page at a time, for the histories that are too long to hold:
+ * the grid then shows what it was handed and asks the caller for the next
+ * page, rather than paging through a list of its own.
+ *
+ * [neighbours] are the entries outside the page the difference columns are
+ * measured against, i.e. the one after the page and the oldest there is.
+ * Sorting and filtering are off in this mode: both would only reach the
+ * page in hand, which is not what a sorted table means.
+ */
+ pagination?: {
+ rowCount: number,
+ model: GridPaginationModel,
+ onModelChange: (model: GridPaginationModel) => void,
+ neighbours: MeasurementEntry[],
+ isLoading: boolean,
+ },
}) => {
const [t, i18n] = useTranslation();
const entries = props.entries;
const unit = props.displayUnit ?? props.category.unit;
- const data: GridRowsProp = buildRows(entries, unit, props.category.unit);
+ const data: GridRowsProp = buildRows(
+ entries,
+ props.pagination?.neighbours ?? [],
+ unit,
+ props.category.unit,
+ );
const updateEntryQuery = useEditMeasurementEntryQuery();
const deleteEntryQuery = useDeleteMeasurementEntryQuery();
const [rowModesModel, setRowModesModel] = useState({});
@@ -284,14 +318,17 @@ export const CategoryDetailDataGrid = (props: {
({ ...column, sortable: false, filterable: false }))}
+ initialState={props.pagination === undefined
+ ? { pagination: { paginationModel: { pageSize: PAGINATION_OPTIONS.pageSize } } }
+ : undefined}
+ paginationMode={props.pagination === undefined ? 'client' : 'server'}
+ rowCount={props.pagination?.rowCount}
+ paginationModel={props.pagination?.model}
+ onPaginationModelChange={props.pagination?.onModelChange}
+ loading={props.pagination?.isLoading}
pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions}
disableRowSelectionOnClick
isCellEditable={(params) => params.row.isEditable}
From 986a53dd2714fc26e210dd603f143289d141c54a Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 11:41:21 +0200
Subject: [PATCH 58/71] Let the pages hand the plan periods to the measurement
screens
---
eslint.config.js | 24 ++++++++++++++-----
src/components/Measurements/index.ts | 1 +
.../Measurements/queries/bodyWeight.ts | 3 ---
src/components/Measurements/queries/index.ts | 3 ++-
.../Measurements/screens/BodyWeight.test.tsx | 3 ---
.../Measurements/screens/BodyWeight.tsx | 8 +++----
.../MeasurementCategoryDetail.test.tsx | 4 ----
.../screens/MeasurementCategoryDetail.tsx | 12 ++++------
src/pages/MeasurementDetail/index.tsx | 19 +++++++++++++++
src/pages/WeightOverview/index.tsx | 8 +++++--
src/pages/index.ts | 1 +
src/routes.tsx | 5 ++--
12 files changed, 58 insertions(+), 33 deletions(-)
create mode 100644 src/pages/MeasurementDetail/index.tsx
diff --git a/eslint.config.js b/eslint.config.js
index aaac74d21..7bf78eefe 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,12 @@ const DOMAINS = [
'User',
];
+// The key must not import the values: nutrition reads measurements, not the
+// other way round, or the two form a module initialisation cycle.
+const FORBIDDEN_DEPENDENCIES = {
+ Measurements: ['Nutrition'],
+};
+
const restrictAllDomains = {
"patterns": [{
"group": DOMAINS.map(d => `@/components/${d}/*`),
@@ -32,12 +38,18 @@ const domainOverrides = DOMAINS.map(domain => ({
files: [`src/components/${domain}/**/*.{ts,tsx}`],
rules: {
"no-restricted-imports": ["error", {
- "patterns": [{
- "group": DOMAINS
- .filter(d => d !== domain)
- .map(d => `@/components/${d}/*`),
- "message": `Import other domains via their public surface (e.g. '@/components/${DOMAINS[0]}'), not internal sub-paths.`,
- }]
+ "patterns": [
+ {
+ "group": DOMAINS
+ .filter(d => d !== domain)
+ .map(d => `@/components/${d}/*`),
+ "message": `Import other domains via their public surface (e.g. '@/components/${DOMAINS[0]}'), not internal sub-paths.`,
+ },
+ ...(FORBIDDEN_DEPENDENCIES[domain] ?? []).map(target => ({
+ "group": [`@/components/${target}`, `@/components/${target}/*`],
+ "message": `${domain} must not import ${target}: the dependency runs the other way. Take what you need as a prop, from the page that composes both.`,
+ })),
+ ]
}],
}
}));
diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts
index c377379f9..f32b521c1 100644
--- a/src/components/Measurements/index.ts
+++ b/src/components/Measurements/index.ts
@@ -40,6 +40,7 @@ export {
useMeasurementBucketsQuery,
useMeasurementEntriesQuery,
useMeasurementsCategoryQuery,
+ useMeasurementsQuery,
useMeasurementValueCountsQuery
} from "./queries";
export {
diff --git a/src/components/Measurements/queries/bodyWeight.ts b/src/components/Measurements/queries/bodyWeight.ts
index abb41c8a7..3062cf52d 100644
--- a/src/components/Measurements/queries/bodyWeight.ts
+++ b/src/components/Measurements/queries/bodyWeight.ts
@@ -21,9 +21,6 @@ const OFFICIAL_BODY_WEIGHT = 'official-body-weight';
*/
const bodyWeightCategoryQueryOptions = {
queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, OFFICIAL_BODY_WEIGHT],
- // Called through, not captured: this module sits in an import cycle
- // between the weight, measurement and nutrition domains, where a binding
- // read while the modules initialise can still be undefined
queryFn: () => getBodyWeightCategory(),
};
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index c694c5e40..815593182 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -125,10 +125,11 @@ export const useReorderMeasurementCategoriesQuery = () => {
});
};
-export function useMeasurementsQuery(id: string) {
+export function useMeasurementsQuery(id: string, enabled: boolean = true) {
return useQuery({
queryKey: [QueryKey.MEASUREMENTS, id],
queryFn: () => getMeasurementCategory(id),
+ enabled: enabled,
});
}
diff --git a/src/components/Measurements/screens/BodyWeight.test.tsx b/src/components/Measurements/screens/BodyWeight.test.tsx
index dda6c3709..7cc918ec8 100644
--- a/src/components/Measurements/screens/BodyWeight.test.tsx
+++ b/src/components/Measurements/screens/BodyWeight.test.tsx
@@ -8,9 +8,6 @@ import { BodyWeight } from "./BodyWeight";
import type { Mock } from 'vitest';
vi.mock("@/components/Measurements/api/bodyWeight");
-vi.mock('@/components/Nutrition/queries/plan', () => ({
- useNutritionPlanPeriods: () => [],
-}));
vi.mock('@/components/User/queries/profile', () => ({
useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }),
}));
diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx
index 3e007638d..9108f3ba5 100644
--- a/src/components/Measurements/screens/BodyWeight.tsx
+++ b/src/components/Measurements/screens/BodyWeight.tsx
@@ -2,7 +2,7 @@ import { Box, Stack } from "@mui/material";
import { ChartRange, DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements/charts/range";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector";
-import { useNutritionPlanPeriods } from "@/components/Nutrition";
+import { PlanPeriod } from "@/components/Measurements/charts/series";
import {
useBodyWeightCategoryQuery,
useBodyWeightQuery,
@@ -17,7 +17,8 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
-export const BodyWeight = () => {
+/** [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);
// Fetch what the range shows, rather than the whole history. The filter
@@ -27,7 +28,6 @@ export const BodyWeight = () => {
const weightyQuery = useBodyWeightQuery(entryFilterFor(range));
const categoryQuery = useBodyWeightCategoryQuery();
const displayUnit = useDisplayWeightUnit();
- const planPeriods = useNutritionPlanPeriods();
if (weightyQuery.isLoading || categoryQuery.isLoading) {
return ;
@@ -47,7 +47,7 @@ export const BodyWeight = () => {
unit={displayUnit}
categoryUnit={categoryUnit}
range={range}
- planPeriods={planPeriods}
+ planPeriods={props.planPeriods ?? []}
chartConfig={categoryQuery.data!.chartConfig} />
({
- useNutritionPlanPeriods: () => [],
-}));
-
vi.mock("@/components/Measurements/queries");
const queryClient = new QueryClient();
diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
index 176bb7925..a8481ab72 100644
--- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
+++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx
@@ -3,7 +3,6 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container";
import {
categoryDisplayName,
- correlatesWithNutrition,
MeasurementCategory,
METRIC_TYPE_BODY_WEIGHT
} from "@/components/Measurements/models/Category";
@@ -12,7 +11,7 @@ import {
useMeasurementsQuery,
useOldestMeasurementEntryQuery
} from "@/components/Measurements/queries";
-import { useNutritionPlanPeriods } from "@/components/Nutrition";
+import { PlanPeriod } from "@/components/Measurements/charts/series";
import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid";
import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown";
import { ChartRange, DEFAULT_CHART_RANGE, displayFilterFor } from "@/components/Measurements/charts/range";
@@ -70,7 +69,8 @@ const CategoryEntriesGrid = (props: { category: MeasurementCategory, range: Char
}} />;
};
-export const MeasurementCategoryDetail = () => {
+/** [planPeriods] come from the caller: measurements know nothing about nutrition */
+export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] }) => {
const params = useParams<{ categoryId: string }>();
const categoryId = params.categoryId ?? '';
if (!categoryId) {
@@ -82,10 +82,6 @@ export const MeasurementCategoryDetail = () => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const categoryQuery = useMeasurementsQuery(categoryId);
// eslint-disable-next-line react-hooks/rules-of-hooks
- const planPeriods = useNutritionPlanPeriods(
- correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
- );
- // eslint-disable-next-line react-hooks/rules-of-hooks
const [t, i18n] = useTranslation();
if (categoryQuery.isLoading) {
@@ -111,7 +107,7 @@ export const MeasurementCategoryDetail = () => {
+ planPeriods={props.planPeriods ?? []} />
{categoryQuery.data!.isGroup
? categoryQuery.data!.children.map(child =>
diff --git a/src/pages/MeasurementDetail/index.tsx b/src/pages/MeasurementDetail/index.tsx
new file mode 100644
index 000000000..403e8d905
--- /dev/null
+++ b/src/pages/MeasurementDetail/index.tsx
@@ -0,0 +1,19 @@
+import { correlatesWithNutrition, MeasurementCategoryDetail, useMeasurementsQuery } from "@/components/Measurements";
+import { useNutritionPlanPeriods } from "@/components/Nutrition";
+import React from 'react';
+import { useParams } from "react-router-dom";
+
+/**
+ * Reads the plans the detail chart shades. Whether they are worth reading
+ * depends on the category, asked for here as well and answered from the cache.
+ */
+export const MeasurementDetail = () => {
+ const params = useParams<{ categoryId: string }>();
+ const categoryId = params.categoryId ?? '';
+ const categoryQuery = useMeasurementsQuery(categoryId, categoryId !== '');
+ const planPeriods = useNutritionPlanPeriods(
+ correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'),
+ );
+
+ return ;
+};
diff --git a/src/pages/WeightOverview/index.tsx b/src/pages/WeightOverview/index.tsx
index ee0df45fa..a98c27bbc 100644
--- a/src/pages/WeightOverview/index.tsx
+++ b/src/pages/WeightOverview/index.tsx
@@ -1,6 +1,10 @@
import { BodyWeight } from "@/components/Measurements";
+import { useNutritionPlanPeriods } from "@/components/Nutrition";
import React from 'react';
+/** Reads the plans the weight chart shades */
export const WeightOverview = () => {
- return ;
-};
\ No newline at end of file
+ const planPeriods = useNutritionPlanPeriods();
+
+ return ;
+};
diff --git a/src/pages/index.ts b/src/pages/index.ts
index b21e19db3..bd12806e8 100644
--- a/src/pages/index.ts
+++ b/src/pages/index.ts
@@ -6,6 +6,7 @@ export { CaloriesCalculator } from './CaloriesCalculator';
export { Equipments } from './Equipments';
export { Ingredients } from './Ingredients';
export { Login } from './Login';
+export { MeasurementDetail } from './MeasurementDetail';
export { Preferences } from './Preferences';
export { ApiPage } from './ApiPage';
export { WeightOverview } from './WeightOverview';
diff --git a/src/routes.tsx b/src/routes.tsx
index f960002e6..693092036 100644
--- a/src/routes.tsx
+++ b/src/routes.tsx
@@ -1,6 +1,6 @@
import { ConfigurableDashboard } from "@/components/Dashboard/ConfigurableDashboard";
import { ExerciseOverview } from "@/components/Exercises";
-import { MeasurementCategoryDetail, MeasurementCategoryOverview } from "@/components/Measurements";
+import { MeasurementCategoryOverview } from "@/components/Measurements";
import { BmiCalculator, NutritionDiaryOverview, PlanDetail, PlansOverview } from "@/components/Nutrition";
import {
PrivateTemplateOverview,
@@ -27,6 +27,7 @@ import {
Equipments,
Ingredients,
Login,
+ MeasurementDetail,
Preferences,
WeightOverview,
} from "@/pages";
@@ -78,7 +79,7 @@ export const WgerRoutes = () => {
} />
} />
- } />
+ } />
} />
From 533a6880e14cd58418563d028538686559488417 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 12:50:36 +0200
Subject: [PATCH 59/71] Untangle the last import cycles
---
.../Calendar/Components/CalendarComponent.tsx | 2 +-
.../Calendar/Components/CalendarDay.tsx | 2 +-
.../Calendar/Components/CalendarDayGrid.tsx | 2 +-
.../Calendar/Components/Entries.tsx | 2 +-
.../Exercises/screens/Add/Step1Basics.tsx | 2 +-
.../Exercises/screens/Add/Step2Variations.tsx | 2 +-
.../screens/Add/Step3Description.tsx | 4 +-
.../screens/Add/Step4Translations.tsx | 4 +-
.../screens/Add/Step5Images.test.tsx | 6 +-
.../Exercises/screens/Add/Step5Images.tsx | 2 +-
.../Exercises/screens/Add/Step6Overview.tsx | 2 +-
.../state/exerciseSubmissionReducer.test.ts | 2 +-
.../Add/state/exerciseSubmissionReducer.ts | 7 ++-
.../Add/state/exerciseSubmissionState.tsx | 57 ++-----------------
.../Exercises/screens/Add/state/index.ts | 9 +--
.../Exercises/screens/Add/state/stateTypes.ts | 51 +++++++++++++++++
.../screens/Detail/ExerciseDetailEdit.tsx | 2 +-
.../screens/Detail/ExerciseDetailView.tsx | 2 +-
.../screens/Detail/ExerciseDetails.tsx | 7 +--
.../Exercises/widgets/PaddingBox.tsx | 7 +++
src/components/Muscles/MuscleOverview.tsx | 2 +-
src/components/Nutrition/models/consts.ts | 2 +
src/components/Nutrition/models/meal.ts | 2 +-
.../Nutrition/models/nutritionalPlan.test.ts | 3 +-
.../Nutrition/models/nutritionalPlan.ts | 4 +-
.../widgets/forms/SlotEntryForm.test.tsx | 3 +-
src/tests/unitsTestData.ts | 20 +++++++
src/tests/workoutLogsRoutinesTestData.ts | 2 +-
src/tests/workoutRoutinesTestData.ts | 12 ----
29 files changed, 121 insertions(+), 103 deletions(-)
create mode 100644 src/components/Exercises/widgets/PaddingBox.tsx
create mode 100644 src/components/Nutrition/models/consts.ts
create mode 100644 src/tests/unitsTestData.ts
diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx
index 337b5ee15..b6a1cd2c7 100644
--- a/src/components/Calendar/Components/CalendarComponent.tsx
+++ b/src/components/Calendar/Components/CalendarComponent.tsx
@@ -1,7 +1,6 @@
import CalendarDayGrid from "@/components/Calendar/Components/CalendarDayGrid";
import CalendarHeader from "@/components/Calendar/Components/CalendarHeader";
import { CalendarMeasurement } from "@/components/Calendar/Helpers/CalendarMeasurement";
-import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import {
categoryDisplayName,
MeasurementEntry,
@@ -12,6 +11,7 @@ import {
import { DiaryEntry, useNutritionDiaryQuery } from "@/components/Nutrition";
import { useSessionsQuery, WorkoutSession } from "@/components/Routines";
import { dateToYYYYMMDD, 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';
import React, { useEffect, useMemo, useState } from 'react';
diff --git a/src/components/Calendar/Components/CalendarDay.tsx b/src/components/Calendar/Components/CalendarDay.tsx
index 9dd607e02..84680fc93 100644
--- a/src/components/Calendar/Components/CalendarDay.tsx
+++ b/src/components/Calendar/Components/CalendarDay.tsx
@@ -1,7 +1,7 @@
import { useMediaQuery, useTheme } from '@mui/material';
import React from 'react';
import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date";
-import { DayProps } from "./CalendarComponent";
+import type { DayProps } from "./CalendarComponent";
interface CalendarDayProps {
day: DayProps;
diff --git a/src/components/Calendar/Components/CalendarDayGrid.tsx b/src/components/Calendar/Components/CalendarDayGrid.tsx
index 8f085140a..e2d236ec5 100644
--- a/src/components/Calendar/Components/CalendarDayGrid.tsx
+++ b/src/components/Calendar/Components/CalendarDayGrid.tsx
@@ -2,7 +2,7 @@ import { Typography } from '@mui/material';
import Grid from "@mui/material/Grid";
import React from 'react';
import { useTranslation } from "react-i18next";
-import { DayProps } from "./CalendarComponent";
+import type { DayProps } from "./CalendarComponent";
import CalendarDay from './CalendarDay';
interface CalendarDayGridProps {
diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx
index 6d5193c82..8149763d7 100644
--- a/src/components/Calendar/Components/Entries.tsx
+++ b/src/components/Calendar/Components/Entries.tsx
@@ -14,7 +14,7 @@ import React from 'react';
import { useTranslation } from "react-i18next";
import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements";
import { dateToLocale } from "@/core/lib/date";
-import { DayProps } from "./CalendarComponent";
+import type { DayProps } from "./CalendarComponent";
interface LogProps {
selectedDay: DayProps;
diff --git a/src/components/Exercises/screens/Add/Step1Basics.tsx b/src/components/Exercises/screens/Add/Step1Basics.tsx
index 1e78ec66c..1bd317732 100644
--- a/src/components/Exercises/screens/Add/Step1Basics.tsx
+++ b/src/components/Exercises/screens/Add/Step1Basics.tsx
@@ -1,7 +1,7 @@
import { Autocomplete, Box, Button, MenuItem, Stack, TextField, } from "@mui/material";
import Grid from '@mui/material/Grid';
import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget";
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases";
import { ExerciseEquipmentSelect } from "@/components/Exercises/forms/ExerciseEquipmentSelect";
import { ExerciseName } from "@/components/Exercises/forms/ExerciseName";
diff --git a/src/components/Exercises/screens/Add/Step2Variations.tsx b/src/components/Exercises/screens/Add/Step2Variations.tsx
index 90b42b4c4..2d7909700 100644
--- a/src/components/Exercises/screens/Add/Step2Variations.tsx
+++ b/src/components/Exercises/screens/Add/Step2Variations.tsx
@@ -1,7 +1,7 @@
import { Exercise } from "@/components/Exercises/models/exercise";
import { useExercisesQuery } from "@/components/Exercises/queries";
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
import { useExerciseSubmissionStateValue } from "@/components/Exercises/screens/Add/state";
import {
setNewBaseVariationId,
diff --git a/src/components/Exercises/screens/Add/Step3Description.tsx b/src/components/Exercises/screens/Add/Step3Description.tsx
index 0e62a6582..e0519a49e 100644
--- a/src/components/Exercises/screens/Add/Step3Description.tsx
+++ b/src/components/Exercises/screens/Add/Step3Description.tsx
@@ -1,8 +1,8 @@
import { Box, Button, Stack } from "@mui/material";
import Grid from '@mui/material/Grid';
import { useLanguageCheckQuery } from "@/core/queries";
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
-import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox";
import { MarkdownEditor } from "@/core/forms/MarkdownEditor";
import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes";
import { descriptionValidator, noteValidator } from "@/components/Exercises/forms/yupValidators";
diff --git a/src/components/Exercises/screens/Add/Step4Translations.tsx b/src/components/Exercises/screens/Add/Step4Translations.tsx
index 065961f17..0836c214a 100644
--- a/src/components/Exercises/screens/Add/Step4Translations.tsx
+++ b/src/components/Exercises/screens/Add/Step4Translations.tsx
@@ -14,8 +14,8 @@ import Grid from '@mui/material/Grid';
import { MarkdownEditor } from "@/core/forms/MarkdownEditor";
import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget";
import { useLanguageCheckQuery } from "@/core/queries";
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
-import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox";
import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases";
import { ExerciseName } from "@/components/Exercises/forms/ExerciseName";
import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes";
diff --git a/src/components/Exercises/screens/Add/Step5Images.test.tsx b/src/components/Exercises/screens/Add/Step5Images.test.tsx
index 7f72ee7a3..f5aa999ab 100644
--- a/src/components/Exercises/screens/Add/Step5Images.test.tsx
+++ b/src/components/Exercises/screens/Add/Step5Images.test.tsx
@@ -1,7 +1,5 @@
-import {
- exerciseSubmissionInitialState,
- ExerciseSubmissionStateContext
-} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState";
+import { exerciseSubmissionInitialState } from "@/components/Exercises/screens/Add/state/stateTypes";
+import { ExerciseSubmissionStateContext } from "@/components/Exercises/screens/Add/state/exerciseSubmissionState";
import { Step5Images } from "@/components/Exercises/screens/Add/Step5Images";
import { ImageFormData } from "@/components/Exercises/models/exercise";
import { ImageStyle } from "@/components/Exercises/models/image";
diff --git a/src/components/Exercises/screens/Add/Step5Images.tsx b/src/components/Exercises/screens/Add/Step5Images.tsx
index b472ffacd..d89e66750 100644
--- a/src/components/Exercises/screens/Add/Step5Images.tsx
+++ b/src/components/Exercises/screens/Add/Step5Images.tsx
@@ -11,7 +11,7 @@ import {
} from "@mui/material";
import Grid from '@mui/material/Grid';
import ImageList from '@mui/material/ImageList';
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
import { ImageFormModal } from "@/components/Exercises/forms/ImageModal";
import { ImageFormData } from "@/components/Exercises/models/exercise";
import { ImageStyle } from "@/components/Exercises/models/image";
diff --git a/src/components/Exercises/screens/Add/Step6Overview.tsx b/src/components/Exercises/screens/Add/Step6Overview.tsx
index e0e4f2d8a..19e3ae820 100644
--- a/src/components/Exercises/screens/Add/Step6Overview.tsx
+++ b/src/components/Exercises/screens/Add/Step6Overview.tsx
@@ -16,7 +16,7 @@ import Grid from '@mui/material/Grid';
import ImageList from "@mui/material/ImageList";
import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget";
import { FormQueryErrors } from "@/core/ui/Widgets/FormError";
-import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
+import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper";
import {
useAddExerciseFullQuery,
useAddExerciseImageQuery,
diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts
index ce29b923e..6df63da2a 100644
--- a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts
+++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts
@@ -22,7 +22,7 @@ import {
import {
ExerciseSubmissionAction,
ExerciseSubmissionState
-} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState";
+} from "@/components/Exercises/screens/Add/state/stateTypes";
import { ImageFormData } from "@/components/Exercises/models/exercise";
import { ImageStyle } from "@/components/Exercises/models/image";
diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts
index fa45f1b02..a425f3872 100644
--- a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts
+++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts
@@ -1,8 +1,9 @@
-import { exerciseSubmissionInitialState, SetExerciseSubmissionState } from '@/components/Exercises/screens/Add/state';
import {
+ exerciseSubmissionInitialState,
ExerciseSubmissionAction,
- ExerciseSubmissionState
-} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState";
+ ExerciseSubmissionState,
+ SetExerciseSubmissionState
+} from "@/components/Exercises/screens/Add/state/stateTypes";
import { ImageFormData } from "@/components/Exercises/models/exercise";
diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx b/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx
index 95b153a21..4abfc5860 100644
--- a/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx
+++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx
@@ -1,57 +1,10 @@
-import { ImageFormData } from "@/components/Exercises/models/exercise";
import React, { createContext, useContext, useReducer } from "react";
import { exerciseSubmissionReducer } from "@/components/Exercises/screens/Add/state/exerciseSubmissionReducer";
-import { SetExerciseSubmissionState } from "@/components/Exercises/screens/Add/state/stateTypes";
-
-export type ExerciseSubmissionAction = {
- type: SetExerciseSubmissionState,
- payload?: number | number[] | string | string[] | null | ImageFormData[],
-}
-
-export type ExerciseSubmissionState = {
- nameEn: string;
- descriptionEn: string;
- alternativeNamesEn: string[];
- notesEn: string[];
-
- languageId: number | null;
- nameI18n: string;
- alternativeNamesI18n: string[];
- descriptionI18n: string;
- notesI18n: string[];
-
- category: number | null;
- muscles: number[];
- musclesSecondary: number[];
- equipment: number[];
- variationGroup: string | null;
- newVariationExerciseId: number | null;
-
- images: ImageFormData[];
-}
-
-export const exerciseSubmissionInitialState: ExerciseSubmissionState = {
- category: null,
- muscles: [],
- musclesSecondary: [],
- variationGroup: null,
- newVariationExerciseId: null,
- languageId: null,
- equipment: [],
-
- nameEn: "",
- descriptionEn: "",
- alternativeNamesEn: [],
- notesEn: [],
-
- nameI18n: "",
- alternativeNamesI18n: [],
- descriptionI18n: "",
- notesI18n: [],
-
- images: [],
-};
-
+import {
+ ExerciseSubmissionAction,
+ exerciseSubmissionInitialState,
+ ExerciseSubmissionState
+} from "@/components/Exercises/screens/Add/state/stateTypes";
export const ExerciseSubmissionStateContext = createContext<[ExerciseSubmissionState, React.Dispatch]>([
exerciseSubmissionInitialState,
diff --git a/src/components/Exercises/screens/Add/state/index.ts b/src/components/Exercises/screens/Add/state/index.ts
index cd88d7954..2468550f5 100644
--- a/src/components/Exercises/screens/Add/state/index.ts
+++ b/src/components/Exercises/screens/Add/state/index.ts
@@ -1,9 +1,10 @@
-export { SetExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/stateTypes';
-
+export {
+ SetExerciseSubmissionState, exerciseSubmissionInitialState
+} from '@/components/Exercises/screens/Add/state/stateTypes';
+export type { ExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/stateTypes';
-export type { ExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/exerciseSubmissionState';
export {
- ExerciseSubmissionStateProvider, useExerciseSubmissionStateValue, exerciseSubmissionInitialState
+ ExerciseSubmissionStateProvider, useExerciseSubmissionStateValue
} from '@/components/Exercises/screens/Add/state/exerciseSubmissionState';
diff --git a/src/components/Exercises/screens/Add/state/stateTypes.ts b/src/components/Exercises/screens/Add/state/stateTypes.ts
index efd96e5fb..ef458ffde 100644
--- a/src/components/Exercises/screens/Add/state/stateTypes.ts
+++ b/src/components/Exercises/screens/Add/state/stateTypes.ts
@@ -1,3 +1,5 @@
+import { ImageFormData } from "@/components/Exercises/models/exercise";
+
export enum SetExerciseSubmissionState {
RESET,
@@ -18,3 +20,52 @@ export enum SetExerciseSubmissionState {
SET_NOTES_I18N,
SET_IMAGES
}
+
+export type ExerciseSubmissionAction = {
+ type: SetExerciseSubmissionState,
+ payload?: number | number[] | string | string[] | null | ImageFormData[],
+}
+
+export type ExerciseSubmissionState = {
+ nameEn: string;
+ descriptionEn: string;
+ alternativeNamesEn: string[];
+ notesEn: string[];
+
+ languageId: number | null;
+ nameI18n: string;
+ alternativeNamesI18n: string[];
+ descriptionI18n: string;
+ notesI18n: string[];
+
+ category: number | null;
+ muscles: number[];
+ musclesSecondary: number[];
+ equipment: number[];
+ variationGroup: string | null;
+ newVariationExerciseId: number | null;
+
+ images: ImageFormData[];
+}
+
+export const exerciseSubmissionInitialState: ExerciseSubmissionState = {
+ category: null,
+ muscles: [],
+ musclesSecondary: [],
+ variationGroup: null,
+ newVariationExerciseId: null,
+ languageId: null,
+ equipment: [],
+
+ nameEn: "",
+ descriptionEn: "",
+ alternativeNamesEn: [],
+ notesEn: [],
+
+ nameI18n: "",
+ alternativeNamesI18n: [],
+ descriptionI18n: "",
+ notesI18n: [],
+
+ images: [],
+};
diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx
index 6fbb37977..879dcfd69 100644
--- a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx
+++ b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx
@@ -1,4 +1,4 @@
-import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails";
+import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox";
import { EditExerciseCategory } from "@/components/Exercises/forms/Category";
import { EditExerciseEquipment } from "@/components/Exercises/forms/Equipment";
import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases";
diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx
index dfe247e1a..03a72b2b5 100644
--- a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx
+++ b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx
@@ -1,6 +1,6 @@
import { Box, Button, Divider, Typography } from "@mui/material";
import Grid from '@mui/material/Grid';
-import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails";
+import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox";
import { OverviewCard } from "@/components/Exercises/screens/Detail/OverviewCard";
import { SideGallery, SideVideoGallery } from "@/components/Exercises/screens/Detail/SideGallery";
import { Exercise } from "@/components/Exercises/models/exercise";
diff --git a/src/components/Exercises/screens/Detail/ExerciseDetails.tsx b/src/components/Exercises/screens/Detail/ExerciseDetails.tsx
index da55b6c49..8c7a44543 100644
--- a/src/components/Exercises/screens/Detail/ExerciseDetails.tsx
+++ b/src/components/Exercises/screens/Detail/ExerciseDetails.tsx
@@ -4,15 +4,12 @@ import { ExerciseDetailView } from "@/components/Exercises/screens/Detail/Exerci
import { getLanguageByShortName, Language } from "@/components/Exercises/models/language";
import { useExerciseQuery, useExercisesForVariationQuery, useLanguageQuery, } from "@/components/Exercises/queries";
import { ENGLISH_LANGUAGE_OBJ } from "@/core/lib/consts";
-import { Box, Container } from "@mui/material";
+import { Container } from "@mui/material";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { Head } from "./Head";
-
-export const PaddingBox = () => {
- return ;
-};
+import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox";
export const ExerciseDetails = () => {
const [language, setLanguage] = useState(ENGLISH_LANGUAGE_OBJ);
diff --git a/src/components/Exercises/widgets/PaddingBox.tsx b/src/components/Exercises/widgets/PaddingBox.tsx
new file mode 100644
index 000000000..86cbd96f2
--- /dev/null
+++ b/src/components/Exercises/widgets/PaddingBox.tsx
@@ -0,0 +1,7 @@
+import { Box } from "@mui/material";
+import React from "react";
+
+/** Vertical space between the blocks of the exercise detail pages */
+export const PaddingBox = () => {
+ return ;
+};
diff --git a/src/components/Muscles/MuscleOverview.tsx b/src/components/Muscles/MuscleOverview.tsx
index 27b44136c..78f121ff0 100644
--- a/src/components/Muscles/MuscleOverview.tsx
+++ b/src/components/Muscles/MuscleOverview.tsx
@@ -1,4 +1,4 @@
-import { Muscle } from "@/components/Exercises";
+import type { Muscle } from "@/components/Exercises";
import { PUBLIC_URL } from "@/config";
import React from "react";
diff --git a/src/components/Nutrition/models/consts.ts b/src/components/Nutrition/models/consts.ts
new file mode 100644
index 000000000..8d4043856
--- /dev/null
+++ b/src/components/Nutrition/models/consts.ts
@@ -0,0 +1,2 @@
+/** Id of the meal that stands in for the logs made outside any meal */
+export const PSEUDO_MEAL_ID = '00000000-0000-0000-0000-000000000000';
diff --git a/src/components/Nutrition/models/meal.ts b/src/components/Nutrition/models/meal.ts
index 325401610..ca36f4850 100644
--- a/src/components/Nutrition/models/meal.ts
+++ b/src/components/Nutrition/models/meal.ts
@@ -1,7 +1,7 @@
import { NutritionalValues } from "@/components/Nutrition/helpers/nutritionalValues";
import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry";
import { MealItem } from "@/components/Nutrition/models/mealItem";
-import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/nutritionalPlan";
+import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts";
import { Adapter } from "@/core/lib/Adapter";
import { dateTimeToHHMM, dateTimeToLocaleHHMM, HHMMToDateTime, isSameDay } from "@/core/lib/date";
diff --git a/src/components/Nutrition/models/nutritionalPlan.test.ts b/src/components/Nutrition/models/nutritionalPlan.test.ts
index 6b73bd439..46e494e21 100644
--- a/src/components/Nutrition/models/nutritionalPlan.test.ts
+++ b/src/components/Nutrition/models/nutritionalPlan.test.ts
@@ -1,4 +1,5 @@
-import { NutritionalPlan, PSEUDO_MEAL_ID } from "@/components/Nutrition/models/nutritionalPlan";
+import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts";
+import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan";
import { TEST_DIARY_ENTRY_3, TEST_DIARY_ENTRY_4 } from "@/tests/nutritionDiaryTestdata";
import { TEST_MEAL_1, TEST_NUTRITIONAL_PLAN_1 } from "@/tests/nutritionTestdata";
import { yyyymmddToDate } from "@/core/lib/date";
diff --git a/src/components/Nutrition/models/nutritionalPlan.ts b/src/components/Nutrition/models/nutritionalPlan.ts
index 3288ce532..4b52a207b 100644
--- a/src/components/Nutrition/models/nutritionalPlan.ts
+++ b/src/components/Nutrition/models/nutritionalPlan.ts
@@ -1,5 +1,6 @@
import { NutritionalValues } from "@/components/Nutrition/helpers/nutritionalValues";
import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry";
+import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts";
import { Meal } from "@/components/Nutrition/models/meal";
import { ApiNutritionalPlanType } from "@/types";
import { Adapter } from "@/core/lib/Adapter";
@@ -12,9 +13,6 @@ export type GroupedDiaryEntries = {
nutritionalValues: NutritionalValues;
}
-export const PSEUDO_MEAL_ID = '00000000-0000-0000-0000-000000000000';
-
-
type NutritionalPlanConstructorParams = {
id?: string | null,
creationDate?: Date,
diff --git a/src/components/Routines/widgets/forms/SlotEntryForm.test.tsx b/src/components/Routines/widgets/forms/SlotEntryForm.test.tsx
index d35c4231c..9e90d2ccf 100644
--- a/src/components/Routines/widgets/forms/SlotEntryForm.test.tsx
+++ b/src/components/Routines/widgets/forms/SlotEntryForm.test.tsx
@@ -14,7 +14,8 @@ import { getRoutineRepUnits, getRoutineWeightUnits } from "@/components/Routines
import { editProfile, getProfile } from "@/components/User/api/profile";
import { getTestQueryClient } from "@/tests/queryClient";
import { testProfileDataVerified } from "@/tests/userTestdata";
-import { testDayLegs, testRepetitionUnits, testWeightUnits } from "@/tests/workoutRoutinesTestData";
+import { testRepetitionUnits, testWeightUnits } from "@/tests/unitsTestData";
+import { testDayLegs } from "@/tests/workoutRoutinesTestData";
import { DEBOUNCE_ROUTINE_FORMS } from "@/core/lib/consts";
diff --git a/src/tests/unitsTestData.ts b/src/tests/unitsTestData.ts
new file mode 100644
index 000000000..3e2838f6f
--- /dev/null
+++ b/src/tests/unitsTestData.ts
@@ -0,0 +1,20 @@
+import { RepetitionUnit } from "@/components/Routines/models/RepetitionUnit";
+import { WeightUnit } from "@/components/Routines/models/WeightUnit";
+
+/*
+ * The units logs and slot entries are measured in. Their own module because
+ * both the routine and the log fixtures need them, and reading them out of
+ * each other left whichever loaded second with undefined units.
+ */
+
+export const testWeightUnitKg = new WeightUnit(1, "kg");
+export const testWeightUnitLb = new WeightUnit(2, "lb");
+export const testWeightUnitPlates = new WeightUnit(3, "Plates");
+
+export const testWeightUnits = [testWeightUnitKg, testWeightUnitLb, testWeightUnitPlates];
+
+export const testRepUnitRepetitions = new RepetitionUnit(1, "Repetitions");
+export const testRepUnitUnitFailure = new RepetitionUnit(2, "Unit failure");
+export const testRepUnitUnitMinutes = new RepetitionUnit(3, "Minutes");
+
+export const testRepetitionUnits = [testRepUnitRepetitions, testRepUnitUnitFailure, testRepUnitUnitMinutes];
diff --git a/src/tests/workoutLogsRoutinesTestData.ts b/src/tests/workoutLogsRoutinesTestData.ts
index 94338bde5..2bd90e78b 100644
--- a/src/tests/workoutLogsRoutinesTestData.ts
+++ b/src/tests/workoutLogsRoutinesTestData.ts
@@ -1,7 +1,7 @@
import { WorkoutLog } from "@/components/Routines/models/WorkoutLog";
import { WorkoutSession } from "@/components/Routines/models/WorkoutSession";
import { testExerciseSquats } from "@/tests/exerciseTestdata";
-import { testRepUnitRepetitions, testWeightUnitKg } from "@/tests/workoutRoutinesTestData";
+import { testRepUnitRepetitions, testWeightUnitKg } from "@/tests/unitsTestData";
const testWorkoutLog1 = new WorkoutLog({
id: 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005',
diff --git a/src/tests/workoutRoutinesTestData.ts b/src/tests/workoutRoutinesTestData.ts
index 0c619fac4..3915628e5 100644
--- a/src/tests/workoutRoutinesTestData.ts
+++ b/src/tests/workoutRoutinesTestData.ts
@@ -14,18 +14,6 @@ import { yyyymmddToDate } from "@/core/lib/date";
import { testExerciseBenchPress, testExerciseSquats } from "@/tests/exerciseTestdata";
import { testWorkoutLogs } from "@/tests/workoutLogsRoutinesTestData";
-export const testWeightUnitKg = new WeightUnit(1, "kg");
-export const testWeightUnitLb = new WeightUnit(2, "lb");
-export const testWeightUnitPlates = new WeightUnit(3, "Plates");
-
-export const testWeightUnits = [testWeightUnitKg, testWeightUnitLb, testWeightUnitPlates];
-
-export const testRepUnitRepetitions = new RepetitionUnit(1, "Repetitions");
-export const testRepUnitUnitFailure = new RepetitionUnit(2, "Unit failure");
-export const testRepUnitUnitMinutes = new RepetitionUnit(3, "Minutes");
-
-export const testRepetitionUnits = [testRepUnitRepetitions, testRepUnitUnitFailure, testRepUnitUnitMinutes];
-
export const testDayLegs = new Day({
id: 5,
routineId: 1,
From f28af6eb54e10ab081130ba0517ce37e51fc3611 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 12:58:17 +0200
Subject: [PATCH 60/71] Put the filter objects into the query keys
Using JSON.stringify ties the cache identity to the order the keys
---
src/components/Measurements/queries/bodyWeight.ts | 2 +-
src/components/Measurements/queries/index.ts | 14 +++++++-------
src/components/Nutrition/queries/diary.ts | 2 +-
src/components/Routines/queries/sessions.ts | 4 ++--
4 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/src/components/Measurements/queries/bodyWeight.ts b/src/components/Measurements/queries/bodyWeight.ts
index 3062cf52d..1fbf93d6f 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, JSON.stringify(filtersetQueryEntries)],
+ queryKey: [QueryKey.MEASUREMENTS, OFFICIAL_BODY_WEIGHT, filtersetQueryEntries],
queryFn: async () => {
const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions);
return getWeights(category, filtersetQueryEntries);
diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts
index 815593182..b75d8ed16 100644
--- a/src/components/Measurements/queries/index.ts
+++ b/src/components/Measurements/queries/index.ts
@@ -49,7 +49,7 @@ const invalidateEntryReads = (queryClient: ReturnType) =>
export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, JSON.stringify(options || {})],
+ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, options ?? {}],
queryFn: () => getMeasurementCategories(options),
});
}
@@ -146,7 +146,7 @@ export function useMeasurementEntriesQuery(
enabled: boolean = true,
) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), limit ?? null],
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, limit ?? null],
queryFn: () => getMeasurementEntries(categoryId, filtersetQuery, limit),
enabled: enabled,
// Picking another range refetches, and the table would otherwise drop
@@ -168,7 +168,7 @@ export function useMeasurementEntryPageQuery(
filtersetQuery: object = {},
) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), 'page', offset, limit],
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, 'page', offset, limit],
queryFn: () => getMeasurementEntryPage(categoryId, offset, limit, filtersetQuery),
// Turning the page refetches, and the table would otherwise drop back
// to an empty grid while the next one arrives
@@ -183,7 +183,7 @@ export function useMeasurementEntryPageQuery(
*/
export function useOldestMeasurementEntryQuery(categoryId: string, filtersetQuery: object = {}) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, JSON.stringify(filtersetQuery), 'oldest'],
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, 'oldest'],
queryFn: () => getOldestMeasurementEntry(categoryId, filtersetQuery),
});
}
@@ -191,7 +191,7 @@ export function useOldestMeasurementEntryQuery(categoryId: string, filtersetQuer
/** The entries of every category in one read, for the views that show a window of time */
export function useAllMeasurementEntriesQuery(filtersetQuery: object = {}) {
return useQuery({
- queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'all', JSON.stringify(filtersetQuery)],
+ queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'all', filtersetQuery],
queryFn: () => getAllMeasurementEntries(filtersetQuery),
});
}
@@ -253,7 +253,7 @@ export function useMeasurementBucketsQuery(
QueryKey.MEASUREMENT_BUCKETS,
categoryIds.join(','),
level,
- JSON.stringify(filtersetQuery),
+ filtersetQuery,
],
queryFn: () => getMeasurementBuckets(categoryIds, level, filtersetQuery),
enabled: enabled && categoryIds.length > 0,
@@ -275,7 +275,7 @@ export function useMeasurementValueCountsQuery(
QueryKey.MEASUREMENT_VALUE_COUNTS,
categoryId,
summedPerDay,
- JSON.stringify(filtersetQuery),
+ filtersetQuery,
],
queryFn: () => getMeasurementValueCounts(categoryId, summedPerDay, filtersetQuery),
enabled: enabled,
diff --git a/src/components/Nutrition/queries/diary.ts b/src/components/Nutrition/queries/diary.ts
index 710fc24b7..bbcd52e33 100644
--- a/src/components/Nutrition/queries/diary.ts
+++ b/src/components/Nutrition/queries/diary.ts
@@ -11,7 +11,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
export const useNutritionDiaryQuery = (options?: NutritionalDiaryEntriesOptions) => useQuery({
queryFn: () => getNutritionalDiaryEntries(options),
- queryKey: [QueryKey.NUTRITIONAL_PLAN_DIARY, JSON.stringify(options || {})],
+ queryKey: [QueryKey.NUTRITIONAL_PLAN_DIARY, options ?? {}],
});
export const useAddDiaryEntryQuery = (planId: string) => {
diff --git a/src/components/Routines/queries/sessions.ts b/src/components/Routines/queries/sessions.ts
index cf12b3a23..b3a158af4 100644
--- a/src/components/Routines/queries/sessions.ts
+++ b/src/components/Routines/queries/sessions.ts
@@ -13,7 +13,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const useFindSessionQuery = (routineId: number, queryParams: Record) => useQuery({
queryFn: () => searchSession(queryParams),
- queryKey: [QueryKey.SESSION_SEARCH, routineId, JSON.stringify(queryParams)],
+ queryKey: [QueryKey.SESSION_SEARCH, routineId, queryParams],
});
export const useAddSessionQuery = () => {
@@ -27,7 +27,7 @@ export const useAddSessionQuery = () => {
export const useSessionsQuery = (options?: SessionQueryOptions) => useQuery({
queryFn: () => getSessions(options),
- queryKey: [QueryKey.SESSIONS_FULL, JSON.stringify(options || {})],
+ queryKey: [QueryKey.SESSIONS_FULL, options ?? {}],
});
From e87a942ee0600412d9030cd84b553b3846895284 Mon Sep 17 00:00:00 2001
From: Roland Geider
Date: Fri, 7 Aug 2026 13:09:27 +0200
Subject: [PATCH 61/71] Give each measurement chart its own file
---
.../widgets/MeasurementBarChart.tsx | 51 ++
.../Measurements/widgets/MeasurementChart.tsx | 587 +-----------------
.../widgets/MeasurementDeltaBarChart.tsx | 54 ++
.../widgets/MeasurementDistributionChart.tsx | 145 +++++
.../widgets/MeasurementHeatmapChart.tsx | 147 +++++
.../widgets/MeasurementLineChart.tsx | 24 +
.../widgets/MeasurementRangeBarChart.tsx | 53 ++
.../widgets/MeasurementStackedBarChart.tsx | 69 ++
.../Measurements/widgets/chartFrames.tsx | 77 +++
9 files changed, 630 insertions(+), 577 deletions(-)
create mode 100644 src/components/Measurements/widgets/MeasurementBarChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementDeltaBarChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementDistributionChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementHeatmapChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementLineChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementRangeBarChart.tsx
create mode 100644 src/components/Measurements/widgets/MeasurementStackedBarChart.tsx
create mode 100644 src/components/Measurements/widgets/chartFrames.tsx
diff --git a/src/components/Measurements/widgets/MeasurementBarChart.tsx b/src/components/Measurements/widgets/MeasurementBarChart.tsx
new file mode 100644
index 000000000..0c2c27983
--- /dev/null
+++ b/src/components/Measurements/widgets/MeasurementBarChart.tsx
@@ -0,0 +1,51 @@
+import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category";
+import { aggregatePerDay, fillMissingDays } from "@/components/Measurements/charts/data";
+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 } from "recharts";
+import { theme } from "@/theme";
+
+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 &&
+ {categoryDisplayName(props.category, t)}
+ : {valueWithUnit(value.value, props.category.unit, i18n.language)}
+
}
+ ;
+};
+
+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 &&
- {categoryDisplayName(props.category, t)}
- : {valueWithUnit(value.value, props.category.unit, i18n.language)}
-
}
- ;
-};
-
-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 */}
-
- {valueOnly(high, props.unit, i18n.language)}/
- {valueWithUnit(low, props.unit, i18n.language)}
-
- ;
-};
-
-/**
- * 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
- {valueWithUnit(total, props.unit, i18n.language)}
- {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
- {parts.map((entry: any) =>
- {entry.dataKey}: {valueOnly(entry.value, props.unit, i18n.language)}
-
)}
- ;
-};
/**
- * 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 */}
- {value > 0 ? '+' : ''}{valueWithUnit(value, props.unit, i18n.language)}
- ;
-};
-
-/**
- * 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 */}
+ {value > 0 ? '+' : ''}{valueWithUnit(value, props.unit, i18n.language)}
+ ;
+};
+
+/**
+ * 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 */}
+
+ {valueOnly(high, props.unit, i18n.language)}/
+ {valueWithUnit(low, props.unit, i18n.language)}
+
+ ;
+};
+
+/**
+ * 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
+ {valueWithUnit(total, props.unit, i18n.language)}
+ {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
+ {parts.map((entry: any) =>
+ {entry.dataKey}: {valueOnly(entry.value, props.unit, i18n.language)}
+
)}
+ ;
+};
+
+/**
+ * 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 62/71] 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 63/71] 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 64/71] 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 65/71] 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 66/71] 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 67/71] 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 68/71] 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 69/71] 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 70/71] 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 71/71] 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
+ />
+ ;
+};