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 => (
- + + + { + if (newUnit) { + formik.setFieldValue('unit', newUnit); + } + }} + > + {t('server.kg')} + {t('server.lb')} + + { + + 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('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 index cf94d2b6a..c0b789e7e 100644 --- a/src/components/Weight/models/WeightEntry.ts +++ b/src/components/Weight/models/WeightEntry.ts @@ -1,8 +1,12 @@ import { Adapter } from "@/core/lib/Adapter"; +import { convertWeight, 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 { @@ -11,21 +15,34 @@ export class WeightEntry { public weight: number, public id?: string, public notes: string = '', + public unit: WeightUnit = 'kg', + public source: string = 'user', ) { } - static clone(other: WeightEntry, overrides?: Partial>): WeightEntry { + /** 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, ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any - static fromJson(json: any) { - return adapter.fromJson(json); + static fromJson(json: any, fallbackUnit: WeightUnit = 'kg') { + return adapter.fromJson(json, fallbackUnit); + } + + valueIn(unit: WeightUnit): number { + return convertWeight(this.weight, this.unit, unit); } toJson() { @@ -35,12 +52,14 @@ export class WeightEntry { class WeightAdapter implements Adapter { // eslint-disable-next-line @typescript-eslint/no-explicit-any - fromJson(item: any): WeightEntry { + fromJson(item: any, fallbackUnit: WeightUnit = 'kg'): WeightEntry { return new WeightEntry( new Date(item.date), parseFloat(item.value), item.id, item.notes ?? '', + item.extra_data?.unit ?? fallbackUnit, + item.source ?? 'user', ); } @@ -49,6 +68,8 @@ class WeightAdapter implements Adapter { date: item.date.toISOString(), value: item.weight, notes: item.notes, + // eslint-disable-next-line camelcase + extra_data: { unit: item.unit }, }; } } diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts index b353dbdfb..b882080a1 100644 --- a/src/components/Weight/queries/index.ts +++ b/src/components/Weight/queries/index.ts @@ -7,7 +7,9 @@ import { getWeights, updateWeight } from "@/components/Weight/api/weight"; +import { useProfileQuery } from "@/components/User"; import { QueryKey, } from "@/core/lib/consts"; +import { WeightUnit } from "@/core/lib/weightUnit"; import { FilterType } from "../widgets/FilterButtons"; /* @@ -23,6 +25,16 @@ export function useBodyWeightCategoryQuery() { return useQuery(bodyWeightCategoryQueryOptions); } +/* + * The unit weight values are displayed in — the user's profile weight unit. + * Entries keep the unit they were entered in, only the presentation converts. + */ +export function useDisplayWeightUnit(): WeightUnit { + const profileQuery = useProfileQuery(); + + return profileQuery.data?.useMetric === false ? 'lb' : 'kg'; +} + export function useBodyWeightQuery(filter: FilterType = 'lastWeek') { const queryClient = useQueryClient(); @@ -30,7 +42,7 @@ export function useBodyWeightQuery(filter: FilterType = 'lastWeek') { queryKey: [QueryKey.BODY_WEIGHT, filter], queryFn: async () => { const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions); - return getWeights(category.id!, filter); + return getWeights(category, filter); }, }); } diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx index 6b27af6aa..942da8d76 100644 --- a/src/components/Weight/screens/BodyWeight.test.tsx +++ b/src/components/Weight/screens/BodyWeight.test.tsx @@ -3,12 +3,15 @@ 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, TEST_BODY_WEIGHT_CATEGORY_UUID } from "@/tests/weight/testData"; +import { testBodyWeightCategory } 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/User/queries/profile', () => ({ + useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }), +})); console.log = vi.fn(); describe("Test BodyWeight component", () => { @@ -43,7 +46,7 @@ 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(TEST_BODY_WEIGHT_CATEGORY_UUID, 'lastYear'); + expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, 'lastYear'); }); test('changes filter and updates displayed data', async () => { @@ -74,7 +77,7 @@ describe("Test BodyWeight component", () => { // Expect getWeights to be called with 'lastMonth' await waitFor(() => { - expect(getWeights).toHaveBeenCalledWith(TEST_BODY_WEIGHT_CATEGORY_UUID, 'lastMonth'); + expect(getWeights).toHaveBeenCalledWith(testBodyWeightCategory, 'lastMonth'); }); // Check that entries for last year are no longer in the document diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx index be653382f..646e65bb2 100644 --- a/src/components/Weight/screens/BodyWeight.tsx +++ b/src/components/Weight/screens/BodyWeight.tsx @@ -1,5 +1,5 @@ import { Box, Stack } from "@mui/material"; -import { useBodyWeightQuery } from "@/components/Weight/queries"; +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"; @@ -15,6 +15,7 @@ export const BodyWeight = () => { const [t] = useTranslation(); const [filter, setFilter] = useState('lastYear'); const weightyQuery = useBodyWeightQuery(filter); + const displayUnit = useDisplayWeightUnit(); const handleFilterChange = (newFilter: FilterType) => { setFilter(newFilter); }; @@ -29,9 +30,9 @@ export const BodyWeight = () => { {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 b9483256e..5d4c53cf8 100644 --- a/src/components/Weight/widgets/Table/index.test.tsx +++ b/src/components/Weight/widgets/Table/index.test.tsx @@ -9,7 +9,7 @@ const renderTable = (weights: WeightEntry[]) => render( - + ); @@ -61,4 +61,37 @@ describe("Body weight table", () => { 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: 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'), + ]; + + 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('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'), + ]; + + 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 index 156fb9ad9..e9debbfcc 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -1,8 +1,9 @@ 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, @@ -19,6 +20,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; 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"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -27,21 +29,23 @@ import { luxonDateTimeToLocale } from "@/core/lib/date"; export interface WeightTableProps { weights: WeightEntry[]; + unit: WeightUnit; } -const buildRows = (weights: WeightEntry[]): GridRowsProp => - processTimeSeries(weights, e => e.weight).map((row) => ({ +const buildRows = (weights: WeightEntry[], unit: WeightUnit): GridRowsProp => + processTimeSeries(weights, e => e.valueIn(unit)).map((row) => ({ id: row.entry.id, date: row.entry.date, - weight: row.entry.weight, + weight: row.entry.valueIn(unit), + isEditable: row.entry.isEditable, change: +row.change.toFixed(2), totalChange: +row.totalChange.toFixed(2), days: +row.days.toFixed(1), })); -export const WeightTable = ({ weights }: WeightTableProps) => { +export const WeightTable = ({ weights, unit }: WeightTableProps) => { const [t] = useTranslation(); - const rows = buildRows(weights); + const rows = buildRows(weights, unit); const editEntryQuery = useEditWeightEntryQuery(); const deleteEntryQuery = useDeleteWeightEntryQuery(); const [rowModesModel, setRowModesModel] = useState({}); @@ -74,7 +78,8 @@ export const WeightTable = ({ weights }: WeightTableProps) => { const processRowUpdate = (newRow: GridRowModel) => { const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date); const entry = weights.find(w => w.id === newRow.id)!; - editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight) })); + // the edited value was displayed in the display unit, store it as such + editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight), unit: unit })); return newRow; }; @@ -98,7 +103,7 @@ export const WeightTable = ({ weights }: WeightTableProps) => { }, { field: 'weight', - headerName: t('weight'), + headerName: `${t('weight')} (${t(`server.${unit}`)})`, type: 'number', width: 100, editable: true, @@ -130,7 +135,19 @@ export const WeightTable = ({ weights }: WeightTableProps) => { 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) { @@ -187,6 +204,7 @@ export const WeightTable = ({ weights }: WeightTableProps) => { }} pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions} disableRowSelectionOnClick + isCellEditable={(params) => params.row.isEditable} rowModesModel={rowModesModel} onRowModesModelChange={handleRowModesModelChange} onRowEditStop={handleRowEditStop} diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx index d62e7da97..cb9cfd59c 100644 --- a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx +++ b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx @@ -12,7 +12,7 @@ describe("Body weight test", () => { ]; // 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'); @@ -21,4 +21,17 @@ describe("Body weight test", () => { const weightRow2 = await screen.findByText("90"); expect(weightRow2).toBeInTheDocument(); }); + + 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'), + ]; + + 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 c915dcf45..a39da1f1f 100644 --- a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx +++ b/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx @@ -4,6 +4,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; import React from 'react'; import { useTranslation } from "react-i18next"; import { dateTimeToLocale } from "@/core/lib/date"; +import { WeightUnit } from "@/core/lib/weightUnit"; const PREFIX = 'WeightTableDashboard'; @@ -26,9 +27,10 @@ const Root = styled('div')(() => { export interface WeightTableProps { weights: WeightEntry[]; + unit: WeightUnit; } -export const WeightTableDashboard = ({ weights }: WeightTableProps) => { +export const WeightTableDashboard = ({ weights, unit }: WeightTableProps) => { const [t] = useTranslation(); const WEIGHT_ENTRIES_TO_SHOW = 5; @@ -42,14 +44,14 @@ export const WeightTableDashboard = ({ weights }: WeightTableProps) => { {t('date')} - {t('weight')} + {`${t('weight')} (${t(`server.${unit}`)})`} {filteredWeight.map((row) => ( {dateTimeToLocale(row.date)} - {row.weight} + {row.valueIn(unit)} ))} diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Weight/widgets/WeightChart/index.test.tsx index 911df9c28..0591ceec0 100644 --- a/src/components/Weight/widgets/WeightChart/index.test.tsx +++ b/src/components/Weight/widgets/WeightChart/index.test.tsx @@ -4,7 +4,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; import React from 'react'; import { describe, test } from 'vitest'; import { testQueryClient } from "@/tests/queryClient"; -import { WeightChart } from "./index"; +import { buildWeightData, 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 @@ -14,7 +14,7 @@ import { WeightChart } from "./index"; const renderChart = (weights: WeightEntry[], height?: number) => render( - + ); @@ -52,3 +52,15 @@ 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'), + ]; + + expect(buildWeightData(weights, 'kg').map(d => d.weight)).toStrictEqual([80, 40.82]); + expect(buildWeightData(weights, 'lb').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 c8210b559..92d6e2751 100644 --- a/src/components/Weight/widgets/WeightChart/index.tsx +++ b/src/components/Weight/widgets/WeightChart/index.tsx @@ -1,6 +1,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; import { calculateEMA, EMADataPoint } from "@/components/Weight/widgets/WeightChart/ema"; import { dateToLocale } from "@/core/lib/date"; +import { WeightUnit } from "@/core/lib/weightUnit"; import { Paper, Stack, Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import { @@ -20,9 +21,22 @@ const NR_OF_WEIGHTS_CHART_DOT = 30; export interface WeightChartProps { weights: WeightEntry[], + unit: WeightUnit, 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 + */ +export const buildWeightData = (weights: WeightEntry[], unit: WeightUnit) => + [...weights] + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map(weight => ({ + date: weight.date.getTime(), + weight: weight.valueIn(unit), + })); + export interface TooltipProps { active?: boolean, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -89,15 +103,11 @@ const VarianceLines = ({ emaData }: { emaData: EMADataPoint[] }) => { ); }; -export const WeightChart = ({ weights, height = 300 }: WeightChartProps) => { +export const WeightChart = ({ weights, unit, height = 300 }: WeightChartProps) => { const theme = useTheme(); const [t] = useTranslation(); - const sortedWeights = [...weights].sort((a, b) => a.date.getTime() - b.date.getTime()); - const weightData = sortedWeights.map(weight => ({ - date: weight.date.getTime(), - weight: weight.weight, - })); + const weightData = buildWeightData(weights, unit); const emaData = calculateEMA(weightData, 10); diff --git a/src/core/lib/weightUnit.test.ts b/src/core/lib/weightUnit.test.ts new file mode 100644 index 000000000..a31f03094 --- /dev/null +++ b/src/core/lib/weightUnit.test.ts @@ -0,0 +1,19 @@ +import { convertWeight } from "./weightUnit"; + +describe('convertWeight', () => { + + test('returns the value unchanged for the same unit', () => { + expect(convertWeight(81.234, 'kg', 'kg')).toBe(81.234); + expect(convertWeight(180.5, 'lb', 'lb')).toBe(180.5); + }); + + test('converts lb to kg, quantized to 2 decimals', () => { + expect(convertWeight(90, 'lb', 'kg')).toBe(40.82); + expect(convertWeight(1, 'lb', 'kg')).toBe(0.45); + }); + + test('converts kg to lb, quantized to 2 decimals', () => { + expect(convertWeight(80, 'kg', 'lb')).toBe(176.37); + expect(convertWeight(1, 'kg', 'lb')).toBe(2.2); + }); +}); diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts new file mode 100644 index 000000000..b7d168678 --- /dev/null +++ b/src/core/lib/weightUnit.ts @@ -0,0 +1,17 @@ +export type WeightUnit = 'kg' | 'lb'; + +export const KG_PER_LB = 0.45359237; + +/* + * Converts a body weight value between kg and lb, quantized to 2 decimal + * places like the server. Free-text units of custom measurement categories + * are never converted, they are plain labels. + */ +export function convertWeight(value: number, from: WeightUnit, to: WeightUnit): number { + if (from === to) { + return value; + } + const converted = from === 'lb' ? value * KG_PER_LB : value / KG_PER_LB; + + return Math.round(converted * 100) / 100; +} diff --git a/src/types.ts b/src/types.ts index 931ed39d0..32a65a887 100644 --- a/src/types.ts +++ b/src/types.ts @@ -127,7 +127,9 @@ export interface ApiMeasurementEntryType { category: string, date: Date, value: number, - notes: string + notes: string, + source: string, + extra_data: { unit?: string }, } export interface ApiEquipmentType { From c3afd3e16354e7655439cb19961798af9617754e Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:14:36 +0200 Subject: [PATCH 03/71] Validate the weight form limits in the selected unit --- .../Weight/forms/WeightForm.test.tsx | 37 +++++++++++++++++++ src/components/Weight/forms/WeightForm.tsx | 15 ++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx index 24a174fea..1f84c2b51 100644 --- a/src/components/Weight/forms/WeightForm.test.tsx +++ b/src/components/Weight/forms/WeightForm.test.tsx @@ -145,4 +145,41 @@ describe("Test WeightForm component", () => { await waitFor(() => expect(weightInput).not.toHaveAttribute('aria-invalid', 'true')); }); + test('The validation limits follow the selected unit', async () => { + + // Arrange + const user = userEvent.setup(); + const mutateAddMock = vi.fn(); + (useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: mutateAddMock })); + render( + + + + ); + const weightInput = await screen.findByLabelText('weight'); + + // Act + Assert: 320 is over the 300 kg maximum... + await user.clear(weightInput); + await user.type(weightInput, '320'); + await user.tab(); + await waitFor(() => expect(weightInput).toHaveAttribute('aria-invalid', 'true')); + + // ...but a perfectly fine weight in lb + await user.click(screen.getByRole('button', { name: 'server.lb' })); + await waitFor(() => expect(weightInput).not.toHaveAttribute('aria-invalid', 'true')); + + // ...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); + + // Act + Assert: 35 lb is below the lb minimum of 66 + await user.clear(weightInput); + await user.type(weightInput, '35'); + await user.tab(); + await waitFor(() => expect(weightInput).toHaveAttribute('aria-invalid', 'true')); + }); + }); diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx index 44a6f2132..18f1ee8db 100644 --- a/src/components/Weight/forms/WeightForm.tsx +++ b/src/components/Weight/forms/WeightForm.tsx @@ -31,12 +31,21 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { const [dateValue, setDateValue] = useState(weightEntry ? DateTime.fromJSDate(weightEntry.date) : DateTime.now); const [t, i18n] = useTranslation(); + // 30 - 300 kg and the same range expressed in lb const validationSchema = yup.object({ + unit: yup.string().oneOf(['kg', 'lb']), weight: yup .number() - .min(30, 'Min weight is 30 kg') - .max(300, 'Max weight is 300 kg') - .required('Weight field is required'), + .required(t('forms.fieldRequired')) + .when('unit', { + is: 'lb', + then: schema => schema + .min(66, t('forms.minValue', { value: `66 ${t('server.lb')}` })) + .max(661, t('forms.maxValue', { value: `661 ${t('server.lb')}` })), + otherwise: schema => schema + .min(30, t('forms.minValue', { value: `30 ${t('server.kg')}` })) + .max(300, t('forms.maxValue', { value: `300 ${t('server.kg')}` })), + }), }); if (categoryQuery.isLoading) { From 8a4f0217ce2def2a99d66d153c773540ec0ae1a3 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:35:42 +0200 Subject: [PATCH 04/71] Keep the stored unit when inline edits do not touch the weight --- .../Weight/widgets/Table/index.test.tsx | 57 +++++++++++++++++++ src/components/Weight/widgets/Table/index.tsx | 14 ++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx index 5d4c53cf8..da44ba349 100644 --- a/src/components/Weight/widgets/Table/index.test.tsx +++ b/src/components/Weight/widgets/Table/index.test.tsx @@ -1,10 +1,15 @@ 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"; +import type { Mock } from 'vitest'; import { WeightTable } from './index'; +vi.mock("@/components/Weight/queries"); + const renderTable = (weights: WeightEntry[]) => render( @@ -19,6 +24,12 @@ const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000002'; 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() })); + }); + test('renders rows for all weight entries', async () => { const weights: WeightEntry[] = [ new WeightEntry(new Date('2021/12/10'), 80, ENTRY_UUID_1), @@ -82,6 +93,52 @@ describe("Body weight table", () => { 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(); + (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'), + ]; + + 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 WeightEntry; + expect(Number(submitted.weight)).toBe(90); + expect(submitted.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'), + ]; + + 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 WeightEntry; + expect(Number(submitted.weight)).toBe(41); + expect(submitted.unit).toBe('kg'); + }); + 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'), diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx index e9debbfcc..3e0bbcf8c 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -75,11 +75,19 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => { }); }; - const processRowUpdate = (newRow: GridRowModel) => { + 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 edited value was displayed in the display unit, store it as such - editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight), unit: unit })); + + // 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(WeightEntry.clone(entry, { date })); + } else { + editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight), unit: unit })); + } return newRow; }; From 3069d546ee484f4c960ed114cb8001b7a4a82356 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:38:38 +0200 Subject: [PATCH 05/71] Raise a clear error when the body weight category is missing --- src/components/Weight/api/weight.test.ts | 9 +++++++++ src/components/Weight/api/weight.ts | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts index ed506823e..d28a80b92 100644 --- a/src/components/Weight/api/weight.test.ts +++ b/src/components/Weight/api/weight.test.ts @@ -16,6 +16,15 @@ describe("weight service tests", () => { vi.clearAllMocks(); }); + test('an empty category response raises a clear error', async () => { + + (axios.get as Mock).mockImplementation(() => Promise.resolve({ + data: { count: 0, next: null, previous: null, results: [] } + })); + + await expect(getBodyWeightCategory()).rejects.toThrow('No official body weight category'); + }); + test('GET the official body weight category', async () => { const categoryResponse = { diff --git a/src/components/Weight/api/weight.ts b/src/components/Weight/api/weight.ts index 1ca244273..21d3160dc 100644 --- a/src/components/Weight/api/weight.ts +++ b/src/components/Weight/api/weight.ts @@ -26,6 +26,12 @@ export const getBodyWeightCategory = async (): Promise => { headers: makeHeader(), }); + // The server guarantees the category exists; still fail with a clear + // message instead of a TypeError should that ever break + if (data.results.length === 0) { + throw new Error('No official body weight category found'); + } + return MeasurementCategory.fromJson(data.results[0]); }; From 8feb6455e26d5c3689054c46ac4512b0a2d31972 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:42:11 +0200 Subject: [PATCH 06/71] Wait for the profile before rendering the weight form --- .../Weight/forms/WeightForm.test.tsx | 19 +++++++++++++++++++ src/components/Weight/forms/WeightForm.tsx | 6 +++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/components/Weight/forms/WeightForm.test.tsx b/src/components/Weight/forms/WeightForm.test.tsx index 1f84c2b51..5261fba71 100644 --- a/src/components/Weight/forms/WeightForm.test.tsx +++ b/src/components/Weight/forms/WeightForm.test.tsx @@ -1,6 +1,7 @@ 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 { @@ -15,6 +16,7 @@ import { testBodyWeightCategory } from "@/tests/weight/testData"; import type { Mock } from 'vitest'; vi.mock("@/components/Weight/queries"); +vi.mock("@/components/User/queries/profile"); const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001'; @@ -29,6 +31,23 @@ describe("Test WeightForm component", () => { (useAddWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() })); (useEditWeightEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() })); (useDisplayWeightUnit as Mock).mockReturnValue('kg'); + (useProfileQuery as Mock).mockImplementation(() => ({ isLoading: false })); + }); + + test('waits for the profile before rendering, the unit default depends on it', () => { + + // Arrange + (useProfileQuery as Mock).mockImplementation(() => ({ isLoading: true })); + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByLabelText('weight')).not.toBeInTheDocument(); }); diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx index 18f1ee8db..0216b3e9b 100644 --- a/src/components/Weight/forms/WeightForm.tsx +++ b/src/components/Weight/forms/WeightForm.tsx @@ -8,6 +8,7 @@ import { useDisplayWeightUnit, useEditWeightEntryQuery } from "@/components/Weight/queries"; +import { useProfileQuery } from "@/components/User"; import { WeightUnit } from "@/core/lib/weightUnit"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { Form, Formik } from "formik"; @@ -24,6 +25,7 @@ interface WeightFormProps { export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { const categoryQuery = useBodyWeightCategoryQuery(); + const profileQuery = useProfileQuery(); const addWeightQuery = useAddWeightEntryQuery(); const editWeightQuery = useEditWeightEntryQuery(); const displayUnit = useDisplayWeightUnit(); @@ -48,7 +50,9 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { }), }); - if (categoryQuery.isLoading) { + // Also wait for the profile: Formik freezes the initial values, and the + // unit default falls back to kg while the profile has not loaded yet + if (categoryQuery.isLoading || profileQuery.isLoading) { return ; } From a5e6a4cd5496c8d7ecfc4ff3f69b3fe0a0061c42 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:45:23 +0200 Subject: [PATCH 07/71] Preserve unknown extra_data keys on the edit round trip --- src/components/Weight/api/weight.test.ts | 6 ++++-- .../Weight/models/WeightEntry.test.ts | 21 +++++++++++++++++++ src/components/Weight/models/WeightEntry.ts | 7 ++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/components/Weight/api/weight.test.ts b/src/components/Weight/api/weight.test.ts index d28a80b92..da4a838bc 100644 --- a/src/components/Weight/api/weight.test.ts +++ b/src/components/Weight/api/weight.test.ts @@ -88,7 +88,7 @@ describe("weight service tests", () => { ); 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'), + new WeightEntry(new Date('2021-12-20'), 90, ENTRY_UUID_2, '', 'lb', 'apple', { unit: 'lb' }), ]); }); @@ -157,7 +157,9 @@ describe("weight service tests", () => { 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')); + expect(result).toStrictEqual( + new WeightEntry(new Date('2021-12-10'), 80, ENTRY_UUID, '', 'lb', 'user', { unit: 'lb' }) + ); }); }); diff --git a/src/components/Weight/models/WeightEntry.test.ts b/src/components/Weight/models/WeightEntry.test.ts index ffa7c718e..e41bb962f 100644 --- a/src/components/Weight/models/WeightEntry.test.ts +++ b/src/components/Weight/models/WeightEntry.test.ts @@ -12,6 +12,27 @@ describe('WeightEntry', () => { 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('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 index c0b789e7e..0632b96c9 100644 --- a/src/components/Weight/models/WeightEntry.ts +++ b/src/components/Weight/models/WeightEntry.ts @@ -17,6 +17,7 @@ export class WeightEntry { public notes: string = '', public unit: WeightUnit = 'kg', public source: string = 'user', + public extraData: Record = {}, ) { } @@ -33,6 +34,7 @@ export class WeightEntry { overrides?.notes ?? other.notes, overrides?.unit ?? other.unit, other.source, + other.extraData, ); } @@ -60,6 +62,7 @@ class WeightAdapter implements Adapter { item.notes ?? '', item.extra_data?.unit ?? fallbackUnit, item.source ?? 'user', + item.extra_data ?? {}, ); } @@ -68,8 +71,10 @@ class WeightAdapter implements Adapter { 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: { unit: item.unit }, + extra_data: { ...item.extraData, unit: item.unit }, }; } } From 9cab3791fce087374c02a67d54afca5171101345 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 20:59:55 +0200 Subject: [PATCH 08/71] Other cleanups --- .../widgets/CategoryDetailDataGrid.test.tsx | 52 +++++++++++++++++++ .../widgets/CategoryDetailDataGrid.tsx | 5 ++ .../Weight/models/WeightEntry.test.ts | 11 ++++ src/components/Weight/models/WeightEntry.ts | 9 +++- src/components/Weight/queries/index.ts | 2 +- src/components/Weight/widgets/Table/index.tsx | 5 ++ .../Weight/widgets/WeightChart/ema.ts | 2 +- .../Weight/widgets/WeightChart/index.tsx | 2 +- src/types.ts | 5 +- 9 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx new file mode 100644 index 000000000..2f701a35f --- /dev/null +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx @@ -0,0 +1,52 @@ +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 { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid"; +import React from 'react'; +import { testQueryClient } from "@/tests/queryClient"; +import type { Mock } from 'vitest'; + +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'; + +describe('CategoryDetailDataGrid', () => { + + beforeEach(() => { + (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() })); + (useDeleteMeasurementsQuery as Mock).mockImplementation(() => ({ mutate: vi.fn() })); + }); + + 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'), + ], + ); + + render( + + + + ); + await screen.findByText('10'); + + const userRow = document.querySelector(`[data-id="${USER_ENTRY_UUID}"]`) as HTMLElement; + const syncedRow = document.querySelector(`[data-id="${SYNCED_ENTRY_UUID}"]`) as HTMLElement; + + expect(within(userRow).getByRole('menuitem', { name: /edit/i })).toBeInTheDocument(); + expect(within(userRow).getByRole('menuitem', { name: /delete/i })).toBeInTheDocument(); + + expect(within(syncedRow).queryByRole('menuitem', { name: /edit/i })).not.toBeInTheDocument(); + expect(within(syncedRow).queryByRole('menuitem', { name: /delete/i })).not.toBeInTheDocument(); + expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument(); + }); +}); diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx index 877e47238..5170f62e8 100644 --- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx @@ -165,6 +165,11 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) icon={} 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' }} />, ]; } diff --git a/src/components/Weight/models/WeightEntry.test.ts b/src/components/Weight/models/WeightEntry.test.ts index e41bb962f..d135b15ad 100644 --- a/src/components/Weight/models/WeightEntry.test.ts +++ b/src/components/Weight/models/WeightEntry.test.ts @@ -33,6 +33,17 @@ describe('WeightEntry', () => { }); }); + 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 index 0632b96c9..b7d7b2d05 100644 --- a/src/components/Weight/models/WeightEntry.ts +++ b/src/components/Weight/models/WeightEntry.ts @@ -5,7 +5,7 @@ import { convertWeight, 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 + * The weight is stored in the unit it was entered in: read it through * valueIn(), never directly. */ export class WeightEntry { @@ -55,12 +55,17 @@ export class WeightEntry { 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 = serverUnit === 'kg' || serverUnit === 'lb' ? serverUnit : fallbackUnit; + return new WeightEntry( new Date(item.date), parseFloat(item.value), item.id, item.notes ?? '', - item.extra_data?.unit ?? fallbackUnit, + unit, item.source ?? 'user', item.extra_data ?? {}, ); diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts index b882080a1..97911707c 100644 --- a/src/components/Weight/queries/index.ts +++ b/src/components/Weight/queries/index.ts @@ -26,7 +26,7 @@ export function useBodyWeightCategoryQuery() { } /* - * The unit weight values are displayed in — the user's profile weight unit. + * The unit weight values are displayed in: the user's profile weight unit. * Entries keep the unit they were entered in, only the presentation converts. */ export function useDisplayWeightUnit(): WeightUnit { diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx index 3e0bbcf8c..6d71deea7 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -152,6 +152,11 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => { icon={} 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' }} />, ]; } diff --git a/src/components/Weight/widgets/WeightChart/ema.ts b/src/components/Weight/widgets/WeightChart/ema.ts index 34eeb2ae0..5ba20e88d 100644 --- a/src/components/Weight/widgets/WeightChart/ema.ts +++ b/src/components/Weight/widgets/WeightChart/ema.ts @@ -9,7 +9,7 @@ export interface EMADataPoint extends WeightDataPoint { /** * Exponentially weighted moving average over a chronologically ordered series. - * Smoothing factor is 2 / (period + 1) — e.g. period=10 gives ~0.18. + * Smoothing factor is 2 / (period + 1), e.g. period=10 gives ~0.18. */ export const calculateEMA = ( weights: T[], diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx index 92d6e2751..908966208 100644 --- a/src/components/Weight/widgets/WeightChart/index.tsx +++ b/src/components/Weight/widgets/WeightChart/index.tsx @@ -26,7 +26,7 @@ export interface WeightChartProps { } /* - * Chart data points in the display unit — entries may be stored in mixed + * 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) => diff --git a/src/types.ts b/src/types.ts index 32a65a887..1589890d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,11 +125,12 @@ export interface ApiNutritionalPlanType { export interface ApiMeasurementEntryType { id: string, category: string, - date: Date, + date: string, value: number, notes: string, source: string, - extra_data: { unit?: string }, + external_id: string | null, + extra_data: { unit?: string, [key: string]: unknown }, } export interface ApiEquipmentType { From 431947354994ee987a1d5e30beb1b1905abbc465 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 21:35:09 +0200 Subject: [PATCH 09/71] Validate inline weight edits against the plausibility bounds --- src/components/Weight/forms/WeightForm.tsx | 13 ++++---- .../Weight/widgets/Table/index.test.tsx | 31 +++++++++++++++++++ src/components/Weight/widgets/Table/index.tsx | 28 +++++++++++++++-- src/core/lib/weightUnit.ts | 8 +++++ 4 files changed, 71 insertions(+), 9 deletions(-) diff --git a/src/components/Weight/forms/WeightForm.tsx b/src/components/Weight/forms/WeightForm.tsx index 0216b3e9b..b04508c08 100644 --- a/src/components/Weight/forms/WeightForm.tsx +++ b/src/components/Weight/forms/WeightForm.tsx @@ -9,7 +9,7 @@ import { useEditWeightEntryQuery } from "@/components/Weight/queries"; import { useProfileQuery } from "@/components/User"; -import { WeightUnit } from "@/core/lib/weightUnit"; +import { weightBounds, WeightUnit } from "@/core/lib/weightUnit"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { Form, Formik } from "formik"; import { DateTime } from "luxon"; @@ -33,7 +33,8 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { const [dateValue, setDateValue] = useState(weightEntry ? DateTime.fromJSDate(weightEntry.date) : DateTime.now); const [t, i18n] = useTranslation(); - // 30 - 300 kg and the same range expressed in lb + const lb = weightBounds('lb'); + const kg = weightBounds('kg'); const validationSchema = yup.object({ unit: yup.string().oneOf(['kg', 'lb']), weight: yup @@ -42,11 +43,11 @@ export const WeightForm = ({ weightEntry, closeFn }: WeightFormProps) => { .when('unit', { is: 'lb', then: schema => schema - .min(66, t('forms.minValue', { value: `66 ${t('server.lb')}` })) - .max(661, t('forms.maxValue', { value: `661 ${t('server.lb')}` })), + .min(lb.min, t('forms.minValue', { value: `${lb.min} ${t('server.lb')}` })) + .max(lb.max, t('forms.maxValue', { value: `${lb.max} ${t('server.lb')}` })), otherwise: schema => schema - .min(30, t('forms.minValue', { value: `30 ${t('server.kg')}` })) - .max(300, t('forms.maxValue', { value: `300 ${t('server.kg')}` })), + .min(kg.min, t('forms.minValue', { value: `${kg.min} ${t('server.kg')}` })) + .max(kg.max, t('forms.maxValue', { value: `${kg.max} ${t('server.kg')}` })), }), }); diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx index da44ba349..295186e5b 100644 --- a/src/components/Weight/widgets/Table/index.test.tsx +++ b/src/components/Weight/widgets/Table/index.test.tsx @@ -139,6 +139,37 @@ describe("Body weight table", () => { expect(submitted.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'), + ]; + + 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 WeightEntry; + expect(Number(submitted.weight)).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'), diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx index 6d71deea7..92aa2372c 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -3,7 +3,7 @@ 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, @@ -20,7 +20,7 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; 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 { weightBounds, WeightUnit } from "@/core/lib/weightUnit"; import { DateTime } from "luxon"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; @@ -49,6 +49,7 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => { const editEntryQuery = useEditWeightEntryQuery(); const deleteEntryQuery = useDeleteWeightEntryQuery(); const [rowModesModel, setRowModesModel] = useState({}); + const [editError, setEditError] = useState(null); const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { if (params.reason === GridRowEditStopReasons.rowFocusOut) { @@ -86,11 +87,25 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => { if (Number(newRow.weight) === Number(oldRow.weight)) { editEntryQuery.mutate(WeightEntry.clone(entry, { date })); } else { - editEntryQuery.mutate(WeightEntry.clone(entry, { date, weight: Number(newRow.weight), unit: unit })); + // 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); + 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(WeightEntry.clone(entry, { date, weight, unit: unit })); } return newRow; }; + const onProcessRowUpdateError = (error: unknown) => { + setEditError(error instanceof Error ? error.message : String(error)); + }; + const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { setRowModesModel(newRowModesModel); }; @@ -222,8 +237,15 @@ export const WeightTable = ({ weights, unit }: WeightTableProps) => { onRowModesModelChange={handleRowModesModelChange} onRowEditStop={handleRowEditStop} processRowUpdate={processRowUpdate} + onProcessRowUpdateError={onProcessRowUpdateError} /> + setEditError(null)} + message={editError} + /> ); diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts index b7d168678..94915fd83 100644 --- a/src/core/lib/weightUnit.ts +++ b/src/core/lib/weightUnit.ts @@ -15,3 +15,11 @@ 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 256bc0e5796c8e2b6d766ac0d91fd355eccb2317 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 22:00:52 +0200 Subject: [PATCH 10/71] Add health-sync related fields to categories and their entries --- .../Measurements/api/measurements.test.ts | 15 ++++-- .../Measurements/models/Category.test.ts | 49 +++++++++++++++++++ .../Measurements/models/Category.ts | 36 ++++++++++++-- .../Measurements/models/Entry.test.ts | 41 ++++++++++++++++ src/components/Measurements/models/Entry.ts | 9 +++- .../widgets/CategoryDetailDataGrid.tsx | 16 +++--- src/types.ts | 2 + 7 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 src/components/Measurements/models/Category.test.ts create mode 100644 src/components/Measurements/models/Entry.test.ts diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts index a49e3ccc0..5b71cf1ba 100644 --- a/src/components/Measurements/api/measurements.test.ts +++ b/src/components/Measurements/api/measurements.test.ts @@ -153,7 +153,7 @@ describe('measurement service tests', () => { ); }); - test('addMeasurementCategory POSTs name + unit and returns the parsed category', async () => { + test('addMeasurementCategory POSTs the category and returns the parsed result', async () => { (axios.post as Mock).mockResolvedValue({ data: { id: CATEGORY_UUID_2, name: "Body fat", unit: "%" }, }); @@ -163,7 +163,8 @@ describe('measurement service tests', () => { expect(axios.post).toHaveBeenCalledTimes(1); const [url, body] = (axios.post as Mock).mock.calls[0]; expect(url).toMatch(/\/api\/v2\/measurement-category\/$/); - expect(body).toEqual({ name: "Body fat", unit: "%" }); + + expect(body).toEqual({ name: "Body fat", unit: "%", metric_type: "custom", parent: null, order: 0 }); expect(result).toBeInstanceOf(MeasurementCategory); expect(result.id).toBe(CATEGORY_UUID_2); }); @@ -178,7 +179,15 @@ describe('measurement service tests', () => { expect(axios.patch).toHaveBeenCalledTimes(1); const [url, body] = (axios.patch as Mock).mock.calls[0]; expect(url).toMatch(new RegExp(`/api/v2/measurement-category/${CATEGORY_UUID_2}/$`)); - expect(body).toEqual({ id: CATEGORY_UUID_2, name: "Renamed", unit: "%" }); + + expect(body).toEqual({ + id: CATEGORY_UUID_2, + name: "Renamed", + unit: "%", + metric_type: "custom", + parent: null, + order: 0 + }); expect(result.name).toBe("Renamed"); }); diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts new file mode 100644 index 000000000..98bec5729 --- /dev/null +++ b/src/components/Measurements/models/Category.test.ts @@ -0,0 +1,49 @@ +import { MeasurementCategory, metricTypeFromApi } from "./Category"; + +describe('MeasurementCategory', () => { + + test('fromJson reads the metric type, parent and order', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', + name: 'Systolic', + unit: 'mmHg', + metric_type: 'blood_pressure', + is_official: false, + parent: 'c-parent', + order: 3, + }); + + expect(category.metricType).toBe('blood_pressure'); + expect(category.parentId).toBe('c-parent'); + expect(category.order).toBe(3); + }); + + test('metric type, parent and order survive the json round trip', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', + name: 'Steps', + unit: 'steps', + metric_type: 'steps', + is_official: false, + parent: null, + order: 2, + }); + + const cloned = MeasurementCategory.clone(category, { name: 'Daily steps' }); + expect(cloned.toJson()).toStrictEqual({ + id: 'c-1', + name: 'Daily steps', + unit: 'steps', + + metric_type: 'steps', + parent: null, + order: 2, + }); + }); + + test('an unknown metric type from the server falls back to custom', () => { + expect(metricTypeFromApi('brain_waves')).toBe('custom'); + expect(metricTypeFromApi(undefined)).toBe('custom'); + expect(metricTypeFromApi('heart_rate')).toBe('heart_rate'); + }); +}); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index 0f77ad943..b34f43e36 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -1,8 +1,28 @@ import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { Adapter } from "@/core/lib/Adapter"; +/** Semantic category types, the values mirror the Django MetricType choices */ +export const METRIC_TYPES = [ + 'custom', + 'body_weight', + 'body_fat', + 'height', + 'blood_pressure', + 'heart_rate', + 'steps', + 'distance', + 'energy', + 'sleep', +] as const; +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 = 'body_weight'; +export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight'; + +/** 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'; +} export class MeasurementCategory { @@ -13,8 +33,10 @@ export class MeasurementCategory { public name: string, public unit: string, entries?: MeasurementEntry[], - public metricType: string = 'custom', + public metricType: MetricType = 'custom', public isOfficial: boolean = false, + public parentId: string | null = null, + public order: number = 0, ) { if (entries) { this.entries = entries; @@ -29,6 +51,8 @@ export class MeasurementCategory { other.entries, other.metricType, other.isOfficial, + other.parentId, + other.order, ); } @@ -51,8 +75,10 @@ class MeasurementCategoryAdapter implements Adapter { item.name, item.unit, undefined, - item.metric_type, + metricTypeFromApi(item.metric_type), item.is_official, + item.parent ?? null, + item.order ?? 0, ); } @@ -61,6 +87,10 @@ class MeasurementCategoryAdapter implements Adapter { ...(item.id != null ? { id: item.id } : {}), name: item.name, unit: item.unit, + // eslint-disable-next-line camelcase + metric_type: item.metricType, + parent: item.parentId, + order: item.order, }; } } diff --git a/src/components/Measurements/models/Entry.test.ts b/src/components/Measurements/models/Entry.test.ts new file mode 100644 index 000000000..460b24df5 --- /dev/null +++ b/src/components/Measurements/models/Entry.test.ts @@ -0,0 +1,41 @@ +import { MeasurementEntry } from "./Entry"; + +describe('MeasurementEntry', () => { + + test('extra_data survives the json round trip', () => { + const entry = MeasurementEntry.fromJson({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00Z', + value: 42, + notes: '', + source: 'apple', + extra_data: { unit: 'bpm', device: 'Watch' }, + }); + + const cloned = MeasurementEntry.clone(entry, { value: 43 }); + expect(cloned.source).toBe('apple'); + expect(cloned.toJson()).toStrictEqual({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00.000Z', + value: 43, + notes: '', + + extra_data: { unit: 'bpm', device: 'Watch' }, + }); + }); + + test('missing extra_data defaults to an empty object', () => { + const entry = MeasurementEntry.fromJson({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00Z', + value: 42, + notes: '', + }); + + expect(entry.extraData).toStrictEqual({}); + expect(entry.source).toBe('user'); + }); +}); diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts index 2f7532c5e..96f1228ec 100644 --- a/src/components/Measurements/models/Entry.ts +++ b/src/components/Measurements/models/Entry.ts @@ -9,6 +9,7 @@ export class MeasurementEntry { public value: number, public notes: string, public source: string = 'user', + public extraData: Record = {}, ) { } @@ -25,6 +26,7 @@ export class MeasurementEntry { overrides?.value ?? other.value, overrides?.notes ?? other.notes, other.source, + other.extraData, ); } @@ -50,6 +52,7 @@ class MeasurementEntryAdapter implements Adapter { item.value, item.notes, item.source, + item.extra_data ?? {}, ); } @@ -60,7 +63,11 @@ class MeasurementEntryAdapter implements Adapter { // the server field is a datetime, send the full timestamp date: item.date.toISOString(), value: item.value, - notes: item.notes + notes: item.notes, + // The server replaces extra_data as a whole on update, so send + // every stored key back + // eslint-disable-next-line camelcase + extra_data: item.extraData, }; } } diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx index 5170f62e8..cb6c05d35 100644 --- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx @@ -77,13 +77,15 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) const processRowUpdate = async (newRow: GridRowModel) => { const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date); - updateEntryQuery.mutate(new MeasurementEntry( - newRow.id, - props.category.id!, - date, - newRow.value, - newRow.notes, - )); + const entry = props.category.entries.find(e => e.id === newRow.id); + if (entry === undefined) { + throw new Error(`unknown entry id ${newRow.id}`); + } + updateEntryQuery.mutate(MeasurementEntry.clone(entry, { + date: date, + value: newRow.value, + notes: newRow.notes, + })); return { ...newRow, isNew: false }; }; diff --git a/src/types.ts b/src/types.ts index 1589890d6..9993787d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,8 @@ export interface ApiMeasurementCategoryType { unit: string, metric_type: string, is_official: boolean, + parent: string | null, + order: number, } export const NUTRI_SCORES = ['a', 'b', 'c', 'd', 'e'] as const; From 0897c7036f02bf8713d793a5a36a7f3692a267b5 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Thu, 30 Jul 2026 22:33:55 +0200 Subject: [PATCH 11/71] Route measurement charts by metric type --- .../Measurements/models/Category.test.ts | 14 ++- .../Measurements/models/Category.ts | 8 ++ .../widgets/MeasurementChart.test.tsx | 95 ++++++++++++++++ .../Measurements/widgets/MeasurementChart.tsx | 102 ++++++++++++++++-- .../Weight/widgets/WeightChart/index.tsx | 10 +- .../WeightChart => core/lib}/ema.test.ts | 13 ++- .../widgets/WeightChart => core/lib}/ema.ts | 20 ++-- 7 files changed, 233 insertions(+), 29 deletions(-) create mode 100644 src/components/Measurements/widgets/MeasurementChart.test.tsx rename src/{components/Weight/widgets/WeightChart => core/lib}/ema.test.ts (82%) rename src/{components/Weight/widgets/WeightChart => core/lib}/ema.ts (50%) diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts index 98bec5729..10ea8a2ba 100644 --- a/src/components/Measurements/models/Category.test.ts +++ b/src/components/Measurements/models/Category.test.ts @@ -1,4 +1,4 @@ -import { MeasurementCategory, metricTypeFromApi } from "./Category"; +import { isSummedPerDay, MeasurementCategory, metricTypeFromApi } from "./Category"; describe('MeasurementCategory', () => { @@ -46,4 +46,16 @@ describe('MeasurementCategory', () => { expect(metricTypeFromApi(undefined)).toBe('custom'); expect(metricTypeFromApi('heart_rate')).toBe('heart_rate'); }); + + test('only cumulative metric types are summed per day', () => { + expect(isSummedPerDay('steps')).toBe(true); + expect(isSummedPerDay('distance')).toBe(true); + expect(isSummedPerDay('energy')).toBe(true); + expect(isSummedPerDay('sleep')).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); + }); }); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index b34f43e36..b61ef95c1 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -24,6 +24,14 @@ export function metricTypeFromApi(value: unknown): MetricType { return METRIC_TYPES.includes(value as MetricType) ? value as MetricType : 'custom'; } +/** + * Metric types whose individual samples aren't meaningful on their own: + * 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'; +} + export class MeasurementCategory { entries: MeasurementEntry[] = []; diff --git a/src/components/Measurements/widgets/MeasurementChart.test.tsx b/src/components/Measurements/widgets/MeasurementChart.test.tsx new file mode 100644 index 000000000..6dba77210 --- /dev/null +++ b/src/components/Measurements/widgets/MeasurementChart.test.tsx @@ -0,0 +1,95 @@ +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 React from 'react'; +import { describe, expect, test } from 'vitest'; + +const entry = (id: string, date: Date, value: number) => + new MeasurementEntry(id, 'c-1', date, value, ''); + +// 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', [ + entry('d-1', new Date(2023, 1, 1), 30), + entry('d-2', new Date(2023, 1, 2), 31), + ]); + + render(); + }); + + test('mounts a bar chart for a summed-per-day category', () => { + const category = new MeasurementCategory('c-1', 'Steps', 'steps', [ + entry('d-1', new Date(2023, 1, 1, 8), 4000), + entry('d-2', new Date(2023, 1, 1, 18), 6000), + ], 'steps'); + + render(); + }); + + test('mounts with no entries', () => { + render(); + 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 1c6bea81c..f3febdbba 100644 --- a/src/components/Measurements/widgets/MeasurementChart.tsx +++ b/src/components/Measurements/widgets/MeasurementChart.tsx @@ -1,9 +1,12 @@ import { Box, Paper } from "@mui/material"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import React from "react"; -import { CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; +import { useTranslation } from "react-i18next"; +import { Bar, BarChart, CartesianGrid, Line, LineChart, Tooltip, XAxis, YAxis } from "recharts"; import { theme } from "@/theme"; import { dateToLocale } from "@/core/lib/date"; +import { calculateEMA } from "@/core/lib/ema"; export interface TooltipProps { active?: boolean, @@ -14,11 +17,19 @@ 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!))}

-

{category.name}: {payload[0].value} {category.unit}

+ {value &&

{category.name}: {value.value} {category.unit}

} + {trend &&

{t('trend')}: {trend.value.toFixed(1)} {category.unit}

}
); } @@ -26,11 +37,72 @@ 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); + } -export const MeasurementChart = (props: { category: MeasurementCategory }) => { + 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)); + + return + + + dateToLocale(new Date(timeStr))!} + /> + + )} /> + + + ; +}; + +const MeasurementLineChart = (props: { category: MeasurementCategory }) => { const NR_OF_ENTRIES_CHART_DOT = 30; - // map the list of weights to an array of objects with the date and weight + // 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(), @@ -38,16 +110,22 @@ export const MeasurementChart = (props: { category: MeasurementCategory }) => { entry: entry }; }); - + const emaData = calculateEMA(entryData, p => p.value); return - + + NR_OF_ENTRIES_CHART_DOT ? false : { strokeWidth: 1, r: 4 }} + dot={emaData.length > NR_OF_ENTRIES_CHART_DOT ? false : { strokeWidth: 1, r: 4 }} activeDot={{ stroke: 'black', strokeWidth: 1, @@ -68,4 +146,10 @@ export const MeasurementChart = (props: { category: MeasurementCategory }) => { {)} />} ; -}; \ No newline at end of file +}; + +export const MeasurementChart = (props: { category: MeasurementCategory }) => { + return isSummedPerDay(props.category.metricType) + ? + : ; +}; diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx index 908966208..92b9e6043 100644 --- a/src/components/Weight/widgets/WeightChart/index.tsx +++ b/src/components/Weight/widgets/WeightChart/index.tsx @@ -1,6 +1,6 @@ import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { calculateEMA, EMADataPoint } from "@/components/Weight/widgets/WeightChart/ema"; import { dateToLocale } from "@/core/lib/date"; +import { calculateEMA } from "@/core/lib/ema"; import { WeightUnit } from "@/core/lib/weightUnit"; import { Paper, Stack, Typography, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; @@ -19,6 +19,12 @@ import { const NR_OF_WEIGHTS_CHART_DOT = 30; +interface EMADataPoint { + date: number; + weight: number; + ema: number; +} + export interface WeightChartProps { weights: WeightEntry[], unit: WeightUnit, @@ -109,7 +115,7 @@ export const WeightChart = ({ weights, unit, height = 300 }: WeightChartProps) = const weightData = buildWeightData(weights, unit); - const emaData = calculateEMA(weightData, 10); + const emaData = calculateEMA(weightData, p => p.weight, 10); const meanWeight = weightData.length > 0 ? weightData.reduce((sum, w) => sum + w.weight, 0) / weightData.length diff --git a/src/components/Weight/widgets/WeightChart/ema.test.ts b/src/core/lib/ema.test.ts similarity index 82% rename from src/components/Weight/widgets/WeightChart/ema.test.ts rename to src/core/lib/ema.test.ts index 831b8f361..91d3050f2 100644 --- a/src/components/Weight/widgets/WeightChart/ema.test.ts +++ b/src/core/lib/ema.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from 'vitest'; import { calculateEMA } from './ema'; +const byWeight = (p: { weight: number }) => p.weight; + describe('calculateEMA', () => { test('returns an empty array for empty input', () => { - expect(calculateEMA([])).toEqual([]); + expect(calculateEMA([], byWeight)).toEqual([]); }); test('first point ema equals the first weight', () => { - const result = calculateEMA([{ date: 1, weight: 80 }]); + const result = calculateEMA([{ date: 1, weight: 80 }], byWeight); expect(result).toEqual([{ date: 1, weight: 80, ema: 80 }]); }); @@ -16,7 +18,7 @@ describe('calculateEMA', () => { { date: 1, weight: 100 }, { date: 2, weight: 100 }, { date: 3, weight: 100 }, - ]); + ], byWeight); expect(result.map(p => p.ema)).toEqual([100, 100, 100]); }); @@ -25,7 +27,7 @@ describe('calculateEMA', () => { const s = 2 / (period + 1); const weights = [{ date: 1, weight: 80 }, { date: 2, weight: 90 }]; - const result = calculateEMA(weights, period); + const result = calculateEMA(weights, byWeight, period); expect(result[1].ema).toBeCloseTo(90 * s + 80 * (1 - s), 10); }); @@ -33,6 +35,7 @@ describe('calculateEMA', () => { test('honors a custom period', () => { const result = calculateEMA( [{ date: 1, weight: 80 }, { date: 2, weight: 90 }], + byWeight, 2, ); // s = 2/3, ema[1] = 90 * 2/3 + 80 * 1/3 = 86.666... @@ -43,7 +46,7 @@ describe('calculateEMA', () => { const result = calculateEMA([ { date: 1, weight: 80, label: 'a' }, { date: 2, weight: 82, label: 'b' }, - ]); + ], byWeight); expect(result[0].label).toBe('a'); expect(result[1].label).toBe('b'); }); diff --git a/src/components/Weight/widgets/WeightChart/ema.ts b/src/core/lib/ema.ts similarity index 50% rename from src/components/Weight/widgets/WeightChart/ema.ts rename to src/core/lib/ema.ts index 5ba20e88d..2bc4d31cc 100644 --- a/src/components/Weight/widgets/WeightChart/ema.ts +++ b/src/core/lib/ema.ts @@ -1,30 +1,26 @@ -export interface WeightDataPoint { +export interface TimeSeriesPoint { date: number; - weight: number; -} - -export interface EMADataPoint extends WeightDataPoint { - ema: number; } /** * Exponentially weighted moving average over a chronologically ordered series. * Smoothing factor is 2 / (period + 1), e.g. period=10 gives ~0.18. */ -export const calculateEMA = ( - weights: T[], +export const calculateEMA = ( + points: T[], + getValue: (point: T) => number, period: number = 10, ): (T & { ema: number })[] => { - if (weights.length === 0) { + if (points.length === 0) { return []; } const smoothing = 2 / (period + 1); - let ema = weights[0].weight; + let ema = getValue(points[0]); - return weights.map((point, i) => { + return points.map((point, i) => { if (i > 0) { - ema = point.weight * smoothing + ema * (1 - smoothing); + ema = getValue(point) * smoothing + ema * (1 - smoothing); } return { ...point, ema }; }); From dacd419b187944efc26923f0284218a0cc64f176 Mon Sep 17 00:00:00 2001 From: Roland Geider Date: Fri, 31 Jul 2026 11:36:38 +0200 Subject: [PATCH 12/71] Add metric type and group selection to the category form --- public/locales/de/translation.json | 17 +- public/locales/en/translation.json | 771 +++++++++--------- public/locales/es/translation.json | 17 +- public/locales/fr/translation.json | 17 +- .../Measurements/models/Category.test.ts | 9 + .../Measurements/models/Category.ts | 16 +- .../widgets/CategoryForm.test.tsx | 109 ++- .../Measurements/widgets/CategoryForm.tsx | 83 +- 8 files changed, 649 insertions(+), 390 deletions(-) diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 7fc941c00..2011c54a4 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -252,7 +252,22 @@ "measurements": { "deleteInfo": "Dies wird die Kategorie sowie alle seine Einträge löschen", "unitFormHelpText": "Die Einheit, in der die Kategorie gemessen wird, wie cm oder %", - "measurements": "Messungen" + "measurements": "Messungen", + "metricType": "Metrik-Typ", + "partOfGroup": "Teil der Gruppe", + "noGroup": "Keine Gruppe", + "metricTypes": { + "custom": "Benutzerdefiniert", + "body_weight": "Körpergewicht", + "body_fat": "Körperfett", + "height": "Körpergröße", + "blood_pressure": "Blutdruck", + "heart_rate": "Herzfrequenz", + "steps": "Schritte", + "distance": "Distanz", + "energy": "Energie", + "sleep": "Schlaf" + } }, "timeOfDay": "Uhrzeit", "notes": "Notizen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c910cae33..ead6986df 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,383 +1,398 @@ { - "dashboard": { - "customizeDashboard": "Customize dashboard", - "dragWidgetsHelp": "Drag widgets to reposition them or resize using the bottom-right corner.", - "resetLayout": "Reset to the default layout" - }, - "core": { - "exitEditMode": "Exit edit mode", - "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", - "timeOfDay": "Time of day", - "submit": "Submit", - "edit": "Edit", - "preview": "Preview", - "editName": "Edit {{name}}", - "delete": "Delete", - "deleteConfirmation": "Are you sure you want to delete \"{{name}}\"?", - "add": "Add", - "close": "Close", - "difference": "Difference", - "useMarkdownHint": "You can use basic Markdown to format the text: *italic*, **bold**, - list", - "days": "Days", - "all": "All", - "lastYear": "Last Year", - "lastHalfYear": "Last 6 Months", - "lastMonth": "Last Month", - "lastWeek": "Last Week", - "start": "Start", - "end": "End", - "comment": "Comment", - "trophies": { - "trophies": "Trophies" - }, - "licenses": { - "authors": "Author(s)", - "authorProfile": "Link to author website or profile, if available", - "derivativeSourceUrl": "Link to the original source, if this is a derivative work", - "derivativeSourceUrlHelper": "Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.", - "originalObjectUrl": "Link to the source website, if available", - "originalTitle": "Title" - }, - "loading": "Loading...", - "nutritionalPlan": "Nutritional plan", - "addEntry": "Add entry", - "currentWeight": "Current weight", - "currentTrend": "Current trend", - "mean": "Mean", - "trend": "Trend", - "variance": "Variance", - "totalChange": "Total change", - "workout": "Workout", - "seeDetails": "See details", - "actions": "Actions", - "nothingHereYet": "Nothing here yet...", - "nothingHereYetAction": "Press the action button to begin", - "notes": "Notes", - "value": "Value", - "unit": "Unit", - "alsoSearchEnglish": "Also search for names in English", - "copyToClipboard": "Copy to clipboard", - "filters": "Filters", - "private": "Private", - "public": "Public", - "exercises": { - "replacements": "Replacements", - "replacementsInfoText": "Optionally, you can also select an exercise that should replace this one (e.g. because it was submitted twice, or similar). This will replace the exercise in routines as well as training logs, instead of just deleting it. These changes will also propagate to any instance that syncs the exercises from this one.", - "replacementsSearch": "Search for an exercise or copy and paste a known ID into the field and click on the \"load\" button.", - "noReplacementSelected": " No exercise selected for replacement", - "replacementCannotBeSame": "The replacement cannot be the same exercise that is being deleted.", - "transferMediaLabel": "Transfer media to the replacement exercise", - "transferTranslationsLabel": "Transfer translations to the replacement (skips languages already present)", - "contributeExercise": "Contribute an exercise", - "step1HeaderBasics": "Basics in English", - "variations": "Variations", - "notEnoughRightsHeader": "You can't contribute exercises", - "notEnoughRights": "You can only contribute exercises if your account is older than {{days}} days and have verified your email", - "muscles": "Muscles", - "secondaryMuscles": "Secondary muscles", - "whatVariationsExist": "Which variations of this exercise exist, if any?", - "filterVariations": "Enter exercise name to filter variations", - "identicalExercise": "Avoid duplicate exercises", - "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", - "translateExerciseNow": "Translate this exercise now", - "compatibleImagesCC": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", - "alternativeNames": "Alternative names", + "dashboard": { + "customizeDashboard": "Customize dashboard", + "dragWidgetsHelp": "Drag widgets to reposition them or resize using the bottom-right corner.", + "resetLayout": "Reset to the default layout" + }, + "core": { + "exitEditMode": "Exit edit mode", + "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", + "timeOfDay": "Time of day", + "submit": "Submit", + "edit": "Edit", + "preview": "Preview", + "editName": "Edit {{name}}", + "delete": "Delete", + "deleteConfirmation": "Are you sure you want to delete \"{{name}}\"?", + "add": "Add", + "close": "Close", + "difference": "Difference", + "useMarkdownHint": "You can use basic Markdown to format the text: *italic*, **bold**, - list", + "days": "Days", + "all": "All", + "lastYear": "Last Year", + "lastHalfYear": "Last 6 Months", + "lastMonth": "Last Month", + "lastWeek": "Last Week", + "start": "Start", + "end": "End", + "comment": "Comment", + "trophies": { + "trophies": "Trophies" + }, + "licenses": { + "authors": "Author(s)", + "authorProfile": "Link to author website or profile, if available", + "derivativeSourceUrl": "Link to the original source, if this is a derivative work", + "derivativeSourceUrlHelper": "Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.", + "originalObjectUrl": "Link to the source website, if available", + "originalTitle": "Title" + }, + "loading": "Loading...", + "nutritionalPlan": "Nutritional plan", + "addEntry": "Add entry", + "currentWeight": "Current weight", + "currentTrend": "Current trend", + "mean": "Mean", + "trend": "Trend", + "variance": "Variance", + "totalChange": "Total change", + "workout": "Workout", + "seeDetails": "See details", + "actions": "Actions", + "nothingHereYet": "Nothing here yet...", + "nothingHereYetAction": "Press the action button to begin", "notes": "Notes", - "equipment": "Equipment", - "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", - "cacheWarning": "Due to caching it might take some time till the changes are visible throughout the application.", - "submitExercise": "Submit exercise", - "successfullyUpdated": "The exercise was successfully updated. Due to caching it might take some time till the changes are visible throughout the application.", + "value": "Value", + "unit": "Unit", + "alsoSearchEnglish": "Also search for names in English", + "copyToClipboard": "Copy to clipboard", + "filters": "Filters", + "private": "Private", + "public": "Public", + "exercises": { + "replacements": "Replacements", + "replacementsInfoText": "Optionally, you can also select an exercise that should replace this one (e.g. because it was submitted twice, or similar). This will replace the exercise in routines as well as training logs, instead of just deleting it. These changes will also propagate to any instance that syncs the exercises from this one.", + "replacementsSearch": "Search for an exercise or copy and paste a known ID into the field and click on the \"load\" button.", + "noReplacementSelected": " No exercise selected for replacement", + "replacementCannotBeSame": "The replacement cannot be the same exercise that is being deleted.", + "transferMediaLabel": "Transfer media to the replacement exercise", + "transferTranslationsLabel": "Transfer translations to the replacement (skips languages already present)", + "contributeExercise": "Contribute an exercise", + "step1HeaderBasics": "Basics in English", + "variations": "Variations", + "notEnoughRightsHeader": "You can't contribute exercises", + "notEnoughRights": "You can only contribute exercises if your account is older than {{days}} days and have verified your email", + "muscles": "Muscles", + "secondaryMuscles": "Secondary muscles", + "whatVariationsExist": "Which variations of this exercise exist, if any?", + "filterVariations": "Enter exercise name to filter variations", + "identicalExercise": "Avoid duplicate exercises", + "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", + "translateExerciseNow": "Translate this exercise now", + "compatibleImagesCC": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", + "alternativeNames": "Alternative names", + "notes": "Notes", + "equipment": "Equipment", + "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", + "cacheWarning": "Due to caching it might take some time till the changes are visible throughout the application.", + "submitExercise": "Submit exercise", + "successfullyUpdated": "The exercise was successfully updated. Due to caching it might take some time till the changes are visible throughout the application.", + "description": "Description", + "basics": "Basics", + "exerciseNotTranslated": "No translation available", + "exerciseNotTranslatedBody": "This exercise is currently not available in the currently selected language. Do you want to contribute a translation?", + "alsoKnownAs": "Also known as:", + "primaryMuscles": "Primary muscles", + "deleteExerciseBody": "Do you want to delete the exercise \"{{name}}\"? You can either delete the current {{language}} translation or the complete exercise with all translations, images, etc.", + "deleteTranslation": "Delete translation", + "deleteExerciseFull": "Delete full exercise", + "deleteExerciseReplace": "Delete and replace", + "exercises": "Exercises", + "changeExerciseLanguage": "Change this exercise's language", + "noEquipment": "No equipment", + "missingExercise": "Missing a certain exercise?", + "missingExerciseDescription": "Help out the community by contributing it!", + "searchExerciseName": "Search by exercise name", + "exactMatch": "Exact match", + "newNote": "New note", + "notesHelpText": "Notes are short comments on how to perform the exercise such as \"keep your body straight\"", + "imageStylePhoto": "Photo", + "imageStyle3D": "3D", + "imageStyleLine": "Line", + "imageStyleLowPoly": "Low-Poly", + "imageStyleOther": "Other", + "imageDetails": "Image details", + "imageIsAiGenerated": "Image was generated with AI", + "dropOrClickImage": "Drop an image here or click to select", + "addImage": "Add image", + "swapExercise": "Swap exercise" + }, + "nutrition": { + "plans": "Nutritional plans", + "copyPlan": "Make a copy of this plan", + "plan": "Nutritional plan", + "onlyLoggingHelpText": "Only track calories. Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", + "goalsTitle": "Goals", + "useGoalsHelpText": "Add goals to this plan", + "useGoalsHelpTextLong": "This allows you to set general goals for energy, protein, carbohydrates or fat for the plan. Note that if you setup a detailed meal plan, these values will take precedence.", + "goalEnergy": "Energy goal", + "goalProtein": "Protein goal", + "goalCarbohydrates": "Carbohydrates goal", + "goalFiber": "Fiber goal", + "goalFat": "Fat goal", + "addNutritionalDiary": "Add nutrition diary entry", + "meal": "Meal", + "addMeal": "Add meal", + "addMealItem": "Add ingredient to meal", + "nutritionalDiary": "Nutrition diary", + "gramShort": "g", + "kcal": "kcal", + "valueEnergyKcal": "{{value}} kcal", + "valueEnergyKcalKj": "{{kcal}} kcal / {{kj}} kJ", + "searchIngredientName": "Search by ingredient name", + "languageFilterCurrentOnly": "Only in current language ({{lang}})", + "languageFilterCurrentAndEnglish": "Current language ({{lang}}) & English", + "languageFilterAll": "All languages", + "filterVegan": "Vegan", + "filterVegetarian": "Vegetarian", + "filterNutriscore": "Nutri-Score filter", + "filterNutriscoreOff": "Off", + "filterNutriscoreNoFilter": "No filter", + "filterNutriscoreOrBetter": "{{grade}} or better", + "macronutrient": "Macronutrient", + "percentEnergy": "Percent of energy", + "gPerBodyKg": "g per body-kg", + "planned": "Planned", + "logged": "Logged", + "loggedToday": "Logged today", + "difference": "Difference", + "today": "Today", + "7dayAvg": "7-day average", + "energy": "Energy", + "protein": "Protein", + "carbohydrates": "Carbohydrates", + "sugar": "Sugar", + "ofWhichSugars": "of which sugars", + "fat": "Fat", + "ofWhichSaturated": "of which saturated", + "saturatedFat": "Saturated fat", + "pseudoMealTitle": "Other logs", + "others": "Others", + "fibres": "Fibres", + "sodium": "Sodium", + "planDeleteInfo": "This will delete all nutrition diary entries as well", + "mealDeleteInfo": "Nutrition diary entries to this meal will not be deleted and will appear under \"other logs\"", + "diaryEntrySaved": "Diary entry successfully saved", + "logThisMeal": "Log this meal as-is to the nutrition diary", + "logThisMealItem": "Log this ingredient as-is to the nutrition diary", + "valueRemaining": "remaining", + "valueTooMany": "too many" + }, + "bmi": { + "calculator": "BMI calculator", + "overweight": "Overweight", + "obese": "Obese", + "normal": "Normal weight", + "underweight": "Underweight", + "result": "Your BMI is {{value}}" + }, + "downloadAsPdf": "Download as PDF", + "total": "Total", "description": "Description", - "basics": "Basics", - "exerciseNotTranslated": "No translation available", - "exerciseNotTranslatedBody": "This exercise is currently not available in the currently selected language. Do you want to contribute a translation?", - "alsoKnownAs": "Also known as:", - "primaryMuscles": "Primary muscles", - "deleteExerciseBody": "Do you want to delete the exercise \"{{name}}\"? You can either delete the current {{language}} translation or the complete exercise with all translations, images, etc.", - "deleteTranslation": "Delete translation", - "deleteExerciseFull": "Delete full exercise", - "deleteExerciseReplace": "Delete and replace", - "exercises": "Exercises", - "changeExerciseLanguage": "Change this exercise's language", - "noEquipment": "No equipment", - "missingExercise": "Missing a certain exercise?", - "missingExerciseDescription": "Help out the community by contributing it!", - "searchExerciseName": "Search by exercise name", - "exactMatch": "Exact match", - "newNote": "New note", - "notesHelpText": "Notes are short comments on how to perform the exercise such as \"keep your body straight\"", - "imageStylePhoto": "Photo", - "imageStyle3D": "3D", - "imageStyleLine": "Line", - "imageStyleLowPoly": "Low-Poly", - "imageStyleOther": "Other", - "imageDetails": "Image details", - "imageIsAiGenerated": "Image was generated with AI", - "dropOrClickImage": "Drop an image here or click to select", - "addImage": "Add image", - "swapExercise": "Swap exercise" - }, - "nutrition": { - "plans": "Nutritional plans", - "copyPlan": "Make a copy of this plan", - "plan": "Nutritional plan", - "onlyLoggingHelpText": "Only track calories. Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", - "goalsTitle": "Goals", - "useGoalsHelpText": "Add goals to this plan", - "useGoalsHelpTextLong": "This allows you to set general goals for energy, protein, carbohydrates or fat for the plan. Note that if you setup a detailed meal plan, these values will take precedence.", - "goalEnergy": "Energy goal", - "goalProtein": "Protein goal", - "goalCarbohydrates": "Carbohydrates goal", - "goalFiber": "Fiber goal", - "goalFat": "Fat goal", - "addNutritionalDiary": "Add nutrition diary entry", - "meal": "Meal", - "addMeal": "Add meal", - "addMealItem": "Add ingredient to meal", - "nutritionalDiary": "Nutrition diary", - "gramShort": "g", - "kcal": "kcal", - "valueEnergyKcal": "{{value}} kcal", - "valueEnergyKcalKj": "{{kcal}} kcal / {{kj}} kJ", - "searchIngredientName": "Search by ingredient name", - "languageFilterCurrentOnly": "Only in current language ({{lang}})", - "languageFilterCurrentAndEnglish": "Current language ({{lang}}) & English", - "languageFilterAll": "All languages", - "filterVegan": "Vegan", - "filterVegetarian": "Vegetarian", - "filterNutriscore": "Nutri-Score filter", - "filterNutriscoreOff": "Off", - "filterNutriscoreNoFilter": "No filter", - "filterNutriscoreOrBetter": "{{grade}} or better", - "macronutrient": "Macronutrient", - "percentEnergy": "Percent of energy", - "gPerBodyKg": "g per body-kg", - "planned": "Planned", - "logged": "Logged", - "loggedToday": "Logged today", - "difference": "Difference", - "today": "Today", - "7dayAvg": "7-day average", - "energy": "Energy", - "protein": "Protein", - "carbohydrates": "Carbohydrates", - "sugar": "Sugar", - "ofWhichSugars": "of which sugars", - "fat": "Fat", - "ofWhichSaturated": "of which saturated", - "saturatedFat": "Saturated fat", - "pseudoMealTitle": "Other logs", - "others": "Others", - "fibres": "Fibres", - "sodium": "Sodium", - "planDeleteInfo": "This will delete all nutrition diary entries as well", - "mealDeleteInfo": "Nutrition diary entries to this meal will not be deleted and will appear under \"other logs\"", - "diaryEntrySaved": "Diary entry successfully saved", - "logThisMeal": "Log this meal as-is to the nutrition diary", - "logThisMealItem": "Log this ingredient as-is to the nutrition diary", - "valueRemaining": "remaining", - "valueTooMany": "too many" - }, - "bmi": { - "calculator": "BMI calculator", - "overweight": "Overweight", - "obese": "Obese", - "normal": "Normal weight", - "underweight": "Underweight", - "result": "Your BMI is {{value}}" - }, - "downloadAsPdf": "Download as PDF", - "total": "Total", - "description": "Description", - "translation": "Translation", - "images": "Images", - "overview": "Overview", - "preferences": "Preferences", - "continue": "Continue", - "goBack": "Go back", - "language": "Language", - "forms": { - "supportedImageFormats": "Only JPEG, PNG, WEBP and AVIF files below 20Mb are supported", - "enterNumber": "Please enter a valid number", - "enterInteger": "Please enter a whole number", - "fieldRequired": "This field is required", - "maxLength": "Please enter less than {{chars}} characters", - "minLength": "Please enter more than {{chars}} characters", - "minValue": "The value for this field has to be higher than {{value}}", - "maxValue": "The value for this field has to be less than {{value}}", - "maxLessThanMin": "The max value has to be bigger than the minimum", - "endBeforeStart": "The end value cannot be before the start" - }, - "name": "Name", - "category": "Category", - "success": "Success!", - "English": "English", - "save": "Save", - "min": "Min", - "max": "Max", - "durationWeeks": "{{number}} weeks", - "durationWeeksDays": "{{nrWeeks}} weeks, {{nrDays}} days", - "videos": "Videos", - "undo": "Undo", - "successfullyDeleted": "Successfully deleted", - "cannotBeUndone": "This action can't be undone.", - "cancel": "Cancel", - "noResults": "No results", - "noResultsDescription": "No results found for this query, consider reducing the number of filters.", - "routines": { - "sets": "Sets", - "reps": "Reps", - "volume": "Volume", - "intensity": "Intensity", - "currentRoutine": "Current routine", - "iteration": "Iteration", - "weekly": "Weekly", - "daily": "Daily", - "restTime": "Rest time", - "workoutNr": "Workout Nr. {{number}}", - "weekNr": "Week {{number}}", - "iterationNr": "Iteration {{number}}", - "backToRoutine": "Back to routine", - "minLengthRoutine": "The routine needs to be at least {{number}} weeks long", - "maxLengthRoutine": "The routine can be at most {{number}} weeks long", - "resultingRoutine": "Resulting routine", - "addDay": "Add training day", - "deleteDayConfirmation": "This will remove all sets, exercises and progression rules", - "routineHasNoDays": "The routine has no days", - "setHasNoExercises": "This set has no exercises", - "fitDaysInWeek": "Fixed weekly schedule", - "fitDaysInWeekHelpText": "This setting controls how your routine's days are scheduled across multiple weeks. If enabled, the days will repeat in a weekly cycle. For example, a routine with workouts on Monday, Wednesday, and Friday will continue this pattern on the following Monday, Wednesday, and Friday. If disabled, the days will follow sequentially without regard to the start of a new week. This is useful for routines that don't follow a strict weekly schedule.", - "needsLogsToAdvance": "Needs logs to advance", - "needsLogsToAdvanceHelpText": "If you select this option, the routine will only progress to the next scheduled day if you've logged a workout for the current day. If this option is not selected, the routine will automatically advance to the next day regardless of whether you logged a workout or not.", - "addSuperset": "Add superset", - "addExercise": "Add exercise", - "addSet": "Add set", - "exerciseNr": "Exercise {{number}}", - "supersetNr": "Superset {{number}}", - "setNr": "Set {{number}}", - "editProgression": "Edit progression", - "progressionNeedsReplace": "One of the previous entries must have a replace operation", - "exerciseHasProgression": "This exercise has progression rules and can't be edited here. To do so, click the button.", - "exerciseNotAvailable": "Error while loading exercise", - "defaultRounding": "Default rounding", - "rounding": "Rounding (this exercise)", - "roundingHelp": "Set the default rounding for weight and repetitions (this is specially useful when using the percentage increase step in the progression). This will apply to all new sets but can be changed individually in the progression form. Leave empty to disable rounding.", - "newDay": "New day", - "addWeightLog": "Add training log", - "weightLogNotPlanned": "Saving logs to a date for which no workouts were planned.", - "logsOverview": "Logs overview", - "alsoShowLogs": "Also show logs", - "statsOverview": "Statistics", - "simpleMode": "Simple mode", - "logsHeader": "Training log for workout", - "logsFilterNote": "Note that only entries with a weight unit of kg or lb and repetitions are charted, other combinations such as time or until failure are ignored here", - "addLogToDay": "Add log to this day", - "routine": "Routine", - "routines": "Routines", - "workoutSession": "Workout session", - "rir": "RiR", - "restDay": "Rest day", - "confirmRestDay": "Confirm rest day change", - "confirmRestDayHelpText": "Please note that all sets and exercises will be removed when you mark a day as a rest day.", - "duplicate": "Duplicate routine", - "downloadPdfTable": "Download PDF (table)", - "downloadPdfLogs": "Download PDF (logs)", - "downloadIcal": "Download iCal file", - "impression": "General impression", - "impressionGood": "Good", - "impressionNeutral": "Neutral", - "impressionBad": "Bad", - "impressionHelpText": "This form records your workout results (reps, weight, etc.) for each exercise. Changes you make here, like removing or swapping exercises, only affect the specific logs you save and and won't change your overall routine. Only rows with values for either weight or repetitions are saved.", - "addAdditionalLog": "Add additional log", - "operation": "Operation", - "step": "Step", - "requirements": "Requirements", - "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect", - "repeat": "Repeat rule", - "repeatHelpText": "Check the check box if you want this rule to continue to apply to subsequent workouts until you define a new one", - "markAsTemplate": "Manage template", - "template": "Template", - "templates": "Templates", - "publicTemplate": "Public template", - "publicTemplates": "Public templates", - "templatesHelpText": "Templates are a way to save your routine for later use and as a starting point for further routines. You can't edit templates, but you can duplicate them and make changes to the copy (as well as converting them back to a regular routine, of course).", - "publicTemplateHelpText": "Public templates are available to all users.", - "copyAndUseTemplate": "Copy and use template", - "set": { - "type": "Type", - "normalSet": "Normal set", - "dropSet": "Drop set", - "myo": "MYO", - "partial": "Partial", - "forced": "Forced", - "tut": "Time under tension", - "iso": "Isometric hold", - "jump": "Jump", - "warmup": "Warmup" + "translation": "Translation", + "images": "Images", + "overview": "Overview", + "preferences": "Preferences", + "continue": "Continue", + "goBack": "Go back", + "language": "Language", + "forms": { + "supportedImageFormats": "Only JPEG, PNG, WEBP and AVIF files below 20Mb are supported", + "enterNumber": "Please enter a valid number", + "enterInteger": "Please enter a whole number", + "fieldRequired": "This field is required", + "maxLength": "Please enter less than {{chars}} characters", + "minLength": "Please enter more than {{chars}} characters", + "minValue": "The value for this field has to be higher than {{value}}", + "maxValue": "The value for this field has to be less than {{value}}", + "maxLessThanMin": "The max value has to be bigger than the minimum", + "endBeforeStart": "The end value cannot be before the start" + }, + "name": "Name", + "category": "Category", + "success": "Success!", + "English": "English", + "save": "Save", + "min": "Min", + "max": "Max", + "durationWeeks": "{{number}} weeks", + "durationWeeksDays": "{{nrWeeks}} weeks, {{nrDays}} days", + "videos": "Videos", + "undo": "Undo", + "successfullyDeleted": "Successfully deleted", + "cannotBeUndone": "This action can't be undone.", + "cancel": "Cancel", + "noResults": "No results", + "noResultsDescription": "No results found for this query, consider reducing the number of filters.", + "routines": { + "sets": "Sets", + "reps": "Reps", + "volume": "Volume", + "intensity": "Intensity", + "currentRoutine": "Current routine", + "iteration": "Iteration", + "weekly": "Weekly", + "daily": "Daily", + "restTime": "Rest time", + "workoutNr": "Workout Nr. {{number}}", + "weekNr": "Week {{number}}", + "iterationNr": "Iteration {{number}}", + "backToRoutine": "Back to routine", + "minLengthRoutine": "The routine needs to be at least {{number}} weeks long", + "maxLengthRoutine": "The routine can be at most {{number}} weeks long", + "resultingRoutine": "Resulting routine", + "addDay": "Add training day", + "deleteDayConfirmation": "This will remove all sets, exercises and progression rules", + "routineHasNoDays": "The routine has no days", + "setHasNoExercises": "This set has no exercises", + "fitDaysInWeek": "Fixed weekly schedule", + "fitDaysInWeekHelpText": "This setting controls how your routine's days are scheduled across multiple weeks. If enabled, the days will repeat in a weekly cycle. For example, a routine with workouts on Monday, Wednesday, and Friday will continue this pattern on the following Monday, Wednesday, and Friday. If disabled, the days will follow sequentially without regard to the start of a new week. This is useful for routines that don't follow a strict weekly schedule.", + "needsLogsToAdvance": "Needs logs to advance", + "needsLogsToAdvanceHelpText": "If you select this option, the routine will only progress to the next scheduled day if you've logged a workout for the current day. If this option is not selected, the routine will automatically advance to the next day regardless of whether you logged a workout or not.", + "addSuperset": "Add superset", + "addExercise": "Add exercise", + "addSet": "Add set", + "exerciseNr": "Exercise {{number}}", + "supersetNr": "Superset {{number}}", + "setNr": "Set {{number}}", + "editProgression": "Edit progression", + "progressionNeedsReplace": "One of the previous entries must have a replace operation", + "exerciseHasProgression": "This exercise has progression rules and can't be edited here. To do so, click the button.", + "exerciseNotAvailable": "Error while loading exercise", + "defaultRounding": "Default rounding", + "rounding": "Rounding (this exercise)", + "roundingHelp": "Set the default rounding for weight and repetitions (this is specially useful when using the percentage increase step in the progression). This will apply to all new sets but can be changed individually in the progression form. Leave empty to disable rounding.", + "newDay": "New day", + "addWeightLog": "Add training log", + "weightLogNotPlanned": "Saving logs to a date for which no workouts were planned.", + "logsOverview": "Logs overview", + "alsoShowLogs": "Also show logs", + "statsOverview": "Statistics", + "simpleMode": "Simple mode", + "logsHeader": "Training log for workout", + "logsFilterNote": "Note that only entries with a weight unit of kg or lb and repetitions are charted, other combinations such as time or until failure are ignored here", + "addLogToDay": "Add log to this day", + "routine": "Routine", + "routines": "Routines", + "workoutSession": "Workout session", + "rir": "RiR", + "restDay": "Rest day", + "confirmRestDay": "Confirm rest day change", + "confirmRestDayHelpText": "Please note that all sets and exercises will be removed when you mark a day as a rest day.", + "duplicate": "Duplicate routine", + "downloadPdfTable": "Download PDF (table)", + "downloadPdfLogs": "Download PDF (logs)", + "downloadIcal": "Download iCal file", + "impression": "General impression", + "impressionGood": "Good", + "impressionNeutral": "Neutral", + "impressionBad": "Bad", + "impressionHelpText": "This form records your workout results (reps, weight, etc.) for each exercise. Changes you make here, like removing or swapping exercises, only affect the specific logs you save and and won't change your overall routine. Only rows with values for either weight or repetitions are saved.", + "addAdditionalLog": "Add additional log", + "operation": "Operation", + "step": "Step", + "requirements": "Requirements", + "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect", + "repeat": "Repeat rule", + "repeatHelpText": "Check the check box if you want this rule to continue to apply to subsequent workouts until you define a new one", + "markAsTemplate": "Manage template", + "template": "Template", + "templates": "Templates", + "publicTemplate": "Public template", + "publicTemplates": "Public templates", + "templatesHelpText": "Templates are a way to save your routine for later use and as a starting point for further routines. You can't edit templates, but you can duplicate them and make changes to the copy (as well as converting them back to a regular routine, of course).", + "publicTemplateHelpText": "Public templates are available to all users.", + "copyAndUseTemplate": "Copy and use template", + "set": { + "type": "Type", + "normalSet": "Normal set", + "dropSet": "Drop set", + "myo": "MYO", + "partial": "Partial", + "forced": "Forced", + "tut": "Time under tension", + "iso": "Isometric hold", + "jump": "Jump", + "warmup": "Warmup" + }, + "day": { + "custom": "Custom", + "enom": "Every minute on the minute", + "amrap": "As many rounds as possible", + "hiit": "High intensity interval training", + "tabata": "Tabata", + "edt": "Escalating density training", + "rft": "Rounds for time", + "afap": "As fast as possible" + } + }, + "measurements": { + "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", + "metricType": "Metric Type", + "partOfGroup": "Part of group", + "noGroup": "No group", + "metricTypes": { + "custom": "Custom", + "body_weight": "Body weight", + "body_fat": "Body fat", + "height": "Height", + "blood_pressure": "Blood pressure", + "heart_rate": "Heart rate", + "steps": "Steps", + "distance": "Distance", + "energy": "Energy", + "sleep": "Sleep" + } + }, + "server": { + "abs": "Abs", + "arms": "Arms", + "back": "Back", + "barbell": "Barbell", + "bench": "Bench", + "biceps": "Biceps", + "body_weight": "Body weight", + "calves": "Calves", + "cardio": "Cardio", + "chest": "Chest", + "dumbbell": "Dumbbell", + "glutes": "Glutes", + "gym_mat": "Gym mat", + "hamstrings": "Hamstrings", + "incline_bench": "Incline bench", + "kettlebell": "Kettlebell", + "kilometers": "Kilometers", + "kilometers_per_hour": "Kilometers per hour", + "lats": "Lats", + "legs": "Legs", + "max_reps": "Max reps", + "miles": "Miles", + "miles_per_hour": "Miles per hour", + "minutes": "Minutes", + "plates": "Plates", + "pull_up_bar": "Pull up bar", + "quads": "Quads", + "repetitions": "Repetitions", + "sz_bar": "SZ bar", + "seconds": "Seconds", + "shoulders": "Shoulders", + "swiss_ball": "Swiss ball", + "triceps": "Triceps", + "until_failure": "Until failure", + "kg": "kg", + "lb": "lb", + "none__bodyweight_exercise_": "none (bodyweight exercise)" }, - "day": { - "custom": "Custom", - "enom": "Every minute on the minute", - "amrap": "As many rounds as possible", - "hiit": "High intensity interval training", - "tabata": "Tabata", - "edt": "Escalating density training", - "rft": "Rounds for time", - "afap": "As fast as possible" - } - }, - "measurements": { - "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" - }, - "server": { - "abs": "Abs", - "arms": "Arms", - "back": "Back", - "barbell": "Barbell", - "bench": "Bench", - "biceps": "Biceps", - "body_weight": "Body weight", - "calves": "Calves", - "cardio": "Cardio", - "chest": "Chest", - "dumbbell": "Dumbbell", - "glutes": "Glutes", - "gym_mat": "Gym mat", - "hamstrings": "Hamstrings", - "incline_bench": "Incline bench", - "kettlebell": "Kettlebell", - "kilometers": "Kilometers", - "kilometers_per_hour": "Kilometers per hour", - "lats": "Lats", - "legs": "Legs", - "max_reps": "Max reps", - "miles": "Miles", - "miles_per_hour": "Miles per hour", - "minutes": "Minutes", - "plates": "Plates", - "pull_up_bar": "Pull up bar", - "quads": "Quads", - "repetitions": "Repetitions", - "sz_bar": "SZ bar", - "seconds": "Seconds", - "shoulders": "Shoulders", - "swiss_ball": "Swiss ball", - "triceps": "Triceps", - "until_failure": "Until failure", - "kg": "kg", - "lb": "lb", - "none__bodyweight_exercise_": "none (bodyweight exercise)" - }, - "calendar": "Calendar", - "entries": "Entries", - "no_entries_for_day": "No entries for this day" + "calendar": "Calendar", + "entries": "Entries", + "no_entries_for_day": "No entries for this day" } diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index b096b0244..e125d4790 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -254,7 +254,22 @@ "measurements": { "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" + "deleteInfo": "Esto eliminará la categoría así como todas sus entradas", + "metricType": "Tipo de métrica", + "partOfGroup": "Parte del grupo", + "noGroup": "Sin grupo", + "metricTypes": { + "custom": "Personalizado", + "body_weight": "Peso corporal", + "body_fat": "Grasa corporal", + "height": "Altura", + "blood_pressure": "Presión arterial", + "heart_rate": "Frecuencia cardíaca", + "steps": "Pasos", + "distance": "Distancia", + "energy": "Energía", + "sleep": "Sueño" + } }, "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 1d862e830..45af4c0e8 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -337,7 +337,22 @@ "measurements": { "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" + "deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées", + "metricType": "Type de métrique", + "partOfGroup": "Fait partie du groupe", + "noGroup": "Aucun groupe", + "metricTypes": { + "custom": "Personnalisé", + "body_weight": "Poids corporel", + "body_fat": "Graisse corporelle", + "height": "Taille", + "blood_pressure": "Pression artérielle", + "heart_rate": "Fréquence cardiaque", + "steps": "Pas", + "distance": "Distance", + "energy": "Énergie", + "sleep": "Sommeil" + } }, "downloadAsPdf": "Télécharger en PDF", "calendar": "Calendrier", diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts index 10ea8a2ba..8b8de88f6 100644 --- a/src/components/Measurements/models/Category.test.ts +++ b/src/components/Measurements/models/Category.test.ts @@ -47,6 +47,15 @@ describe('MeasurementCategory', () => { expect(metricTypeFromApi('heart_rate')).toBe('heart_rate'); }); + 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); + + expect(MeasurementCategory.clone(category).parentId).toBe('c-parent'); + expect(MeasurementCategory.clone(category, { name: 'x' }).parentId).toBe('c-parent'); + expect(MeasurementCategory.clone(category, { parentId: null }).parentId).toBeNull(); + expect(MeasurementCategory.clone(category, { parentId: 'c-other' }).parentId).toBe('c-other'); + }); + test('only cumulative metric types are summed per day', () => { expect(isSummedPerDay('steps')).toBe(true); expect(isSummedPerDay('distance')).toBe(true); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index b61ef95c1..7c9863c90 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -32,6 +32,14 @@ export function isSummedPerDay(type: MetricType): boolean { return type === 'steps' || type === 'distance' || type === 'energy' || type === 'sleep'; } +/** + * Metric types reserved for the official categories the server manages: + * users cannot create categories of these types + */ +export function isOfficialMetricType(type: MetricType): boolean { + return type === METRIC_TYPE_BODY_WEIGHT; +} + export class MeasurementCategory { entries: MeasurementEntry[] = []; @@ -51,15 +59,17 @@ export class MeasurementCategory { } } - static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory { + static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory { return new MeasurementCategory( overrides?.id ?? other.id, overrides?.name ?? other.name, overrides?.unit ?? other.unit, other.entries, - other.metricType, + overrides?.metricType ?? other.metricType, other.isOfficial, - other.parentId, + // null is a meaningful override here (remove from group), so the + // usual ?? fallback doesn't work + overrides !== undefined && 'parentId' in overrides ? overrides.parentId ?? null : other.parentId, other.order, ); } diff --git a/src/components/Measurements/widgets/CategoryForm.test.tsx b/src/components/Measurements/widgets/CategoryForm.test.tsx index b26bef83f..dcf7e62be 100644 --- a/src/components/Measurements/widgets/CategoryForm.test.tsx +++ b/src/components/Measurements/widgets/CategoryForm.test.tsx @@ -1,7 +1,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from '@testing-library/react'; import userEvent from "@testing-library/user-event"; -import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; +import { + useAddMeasurementCategoryQuery, + useEditMeasurementCategoryQuery, + useMeasurementsCategoryQuery +} from "@/components/Measurements/queries"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm"; import React from 'react'; @@ -12,6 +16,12 @@ vi.mock("@/components/Weight/api/weight"); vi.mock("@/components/Measurements/queries"); +// an entry-free category, eligible as a group parent +const TEST_GROUP_CATEGORY = new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000042', + 'Blood pressure', + 'mmHg', +); describe("Test the CategoryForm component", () => { const queryClient = new QueryClient(); @@ -26,6 +36,9 @@ 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] + })); }); test('Passing an existing entry renders its values in the form', () => { @@ -91,4 +104,98 @@ describe("Test the CategoryForm component", () => { await user.click(submitButton); expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm')); }); + + test('The body weight metric type is not offered', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.click(screen.getByRole('combobox', { name: 'measurements.metricType' })); + + // Assert + expect(screen.getByRole('option', { name: 'measurements.metricTypes.steps' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'measurements.metricTypes.body_weight' })).toBeNull(); + }); + + test('Creating a category with a metric type and group', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.type(await screen.findByLabelText('name'), 'Systolic'); + 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' })); + + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert + expect(mutate).toHaveBeenCalledWith(new MeasurementCategory( + null, + 'Systolic', + 'mmHg', + undefined, + 'blood_pressure', + false, + TEST_GROUP_CATEGORY.id, + )); + }); + + test('Only entry-free top-level categories are offered as parents', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.click(screen.getByRole('combobox', { name: 'measurements.partOfGroup' })); + + // Assert - the categories with entries are not eligible + expect(screen.getByRole('option', { name: 'Blood pressure' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Biceps' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Body fat' })).toBeNull(); + }); + + test('The group dropdown is hidden for a category with children', () => { + // Arrange + const child = new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000043', + '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.partOfGroup' })).toBeNull(); + }); }); diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index b18001834..2a45ec027 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,6 +1,15 @@ -import { MeasurementCategory } from "@/components/Measurements/models/Category"; -import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; -import { Button, Stack, TextField } from "@mui/material"; +import { + isOfficialMetricType, + MeasurementCategory, + METRIC_TYPES, + MetricType +} from "@/components/Measurements/models/Category"; +import { + useAddMeasurementCategoryQuery, + useEditMeasurementCategoryQuery, + useMeasurementsCategoryQuery +} from "@/components/Measurements/queries"; +import { Button, MenuItem, Stack, TextField } from "@mui/material"; import { Form, Formik } from "formik"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -16,6 +25,22 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { const [t] = useTranslation(); const useAddCategoryQuery = useAddMeasurementCategoryQuery(); 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); + + // 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 + // 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 + && (c.entries.length === 0 || c.id === category?.parentId) + ); // 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. @@ -36,15 +61,33 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { initialValues={{ name: category ? category.name : "", unit: category ? category.unit : "", + metricType: category ? category.metricType : 'custom' as MetricType, + // the empty string stands in for "no group", MUI selects + // don't accept null values + parentId: category?.parentId ?? "", }} validationSchema={validationSchema} onSubmit={async (values) => { + const parentId = values.parentId === "" ? null : values.parentId; // Edit existing category if (category) { - useEditCategoryQuery.mutate(MeasurementCategory.clone(category, values)); + useEditCategoryQuery.mutate(MeasurementCategory.clone(category, { + name: values.name, + unit: values.unit, + metricType: values.metricType, + parentId: parentId, + })); } else { - useAddCategoryQuery.mutate(new MeasurementCategory(null, values.name, values.unit)); + useAddCategoryQuery.mutate(new MeasurementCategory( + null, + values.name, + values.unit, + undefined, + values.metricType, + false, + parentId, + )); } // if closeFn is defined, close the modal (this form does not have to @@ -77,6 +120,36 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { } {...formik.getFieldProps('unit')} /> + + {metricTypeChoices.map(metricType => + + {t(`measurements.metricTypes.${metricType}`)} + + )} + + {!hasChildren && parentCandidates.length > 0 && + + {t('measurements.noGroup')} + {parentCandidates.map(candidate => + + {candidate.name} + + )} + + } + + + + )} +
) + ); }; \ 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 }) => { - + - - - - - - ); -}; 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) => { )} } + 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 && - - + {/* 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 + /> + ; +};