diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx index 9cebe159f..dfb3716af 100644 --- a/src/components/Calendar/Components/CalendarComponent.test.tsx +++ b/src/components/Calendar/Components/CalendarComponent.test.tsx @@ -54,6 +54,7 @@ describe('CalendarComponent', () => { 1, "Body Fat", "%", + "NONE", [new MeasurementEntry(1, 1, new Date(currentYear, currentMonth, 1, 12, 0), 20, "Normal")] ), ])); diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts index 913f7f58b..df23e5f3a 100644 --- a/src/components/Measurements/api/measurements.test.ts +++ b/src/components/Measurements/api/measurements.test.ts @@ -39,7 +39,8 @@ describe('measurement service tests', () => { { "id": 1, "name": "Weight", - "unit": "kg" + "unit": "kg", + "dynamic_type": "NONE" } ] }; @@ -47,7 +48,8 @@ describe('measurement service tests', () => { const measurementDetailResponse = { "id": 1, "name": "Weight", - "unit": "kg" + "unit": "kg", + "dynamic_type": "NONE" }; @@ -88,7 +90,7 @@ describe('measurement service tests', () => { expect(axios.get).toHaveBeenCalledTimes(2); expect(result).toStrictEqual([ - new MeasurementCategory(1, "Weight", "kg", [ + new MeasurementCategory(1, "Weight", "kg", "NONE", [ new MeasurementEntry(1, 1, new Date("2021-01-01"), 80, "") ]) ]); @@ -108,38 +110,38 @@ describe('measurement service tests', () => { expect(axios.get).toHaveBeenCalledTimes(2); expect(result).toStrictEqual( - new MeasurementCategory(1, "Weight", "kg", [ + new MeasurementCategory(1, "Weight", "kg", "NONE", [ new MeasurementEntry(1, 1, new Date("2021-01-01"), 80, "") ]) ); }); - test('addMeasurementCategory POSTs name + unit and returns the parsed category', async () => { + test('addMeasurementCategory POSTs name + unit + dynamic_type and returns the parsed category', async () => { (axios.post as Mock).mockResolvedValue({ - data: { id: 9, name: "Body fat", unit: "%" }, + data: { id: 9, name: "Body fat", unit: "%", dynamic_type: "NONE" }, }); - const result = await addMeasurementCategory({ name: "Body fat", unit: "%" }); + const result = await addMeasurementCategory({ name: "Body fat", unit: "%", dynamic_type: "NONE" }); 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: "%", dynamic_type: "NONE" }); expect(result).toBeInstanceOf(MeasurementCategory); expect(result.id).toBe(9); }); test('editMeasurementCategory PATCHes /measurement-category//', async () => { (axios.patch as Mock).mockResolvedValue({ - data: { id: 9, name: "Renamed", unit: "%" }, + data: { id: 9, name: "Renamed", unit: "%", dynamic_type: "NONE" }, }); - const result = await editMeasurementCategory({ id: 9, name: "Renamed", unit: "%" }); + const result = await editMeasurementCategory({ id: 9, name: "Renamed", unit: "%", dynamic_type: "NONE" }); expect(axios.patch).toHaveBeenCalledTimes(1); const [url, body] = (axios.patch as Mock).mock.calls[0]; expect(url).toMatch(/\/api\/v2\/measurement-category\/9\/$/); - expect(body).toEqual({ name: "Renamed", unit: "%" }); + expect(body).toEqual({ name: "Renamed", unit: "%", dynamic_type: "NONE" }); expect(result.name).toBe("Renamed"); }); diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts index 515915b1e..367ca23e8 100644 --- a/src/components/Measurements/api/measurements.ts +++ b/src/components/Measurements/api/measurements.ts @@ -1,5 +1,6 @@ import axios from 'axios'; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { useQuery } from '@tanstack/react-query'; +import { MeasurementCategory, DynamicMeasurementType } 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"; @@ -15,6 +16,26 @@ export type MeasurementQueryOptions = { filtersetQueryEntries?: object, } +export interface DynamicCategory { + id: number; + name: string; + unit: string; + dynamic_type: DynamicMeasurementType; +} + +export const getDynamicCategories = async (): Promise => { + const url = makeUrl(`${API_MEASUREMENTS_CATEGORY_PATH}/dynamic-types`); + const response = await axios.get(url, { headers: makeHeader() }); + return response.data; +}; + +export const useDynamicCategoriesQuery = () => { + return useQuery({ + queryKey: [API_MEASUREMENTS_CATEGORY_PATH, 'dynamic'], + queryFn: getDynamicCategories + }); +}; + export const getMeasurementCategories = async (options?: MeasurementQueryOptions): Promise => { const { filtersetQueryCategories = {}, filtersetQueryEntries = {} } = options || {}; @@ -44,10 +65,10 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions }); // Collect all pages of entries - for await (const page of fetchPaginated(url, makeHeader())) { - for (const entries of page) { - out.push(MeasurementEntry.fromJson(entries)); - } + for await (const page of fetchPaginated(url, makeHeader())) { + for (const entries of page) { + out.push(MeasurementEntry.fromJson(entries)); + } } return out; }); @@ -90,14 +111,16 @@ export const getMeasurementCategory = async (id: number): Promise => { const response = await axios.post( - makeUrl(API_MEASUREMENTS_CATEGORY_PATH,), + makeUrl(API_MEASUREMENTS_CATEGORY_PATH), { name: data.name, - unit: data.unit + unit: data.unit, + dynamic_type: data.dynamic_type // eslint-disable-line camelcase }, { headers: makeHeader() } ); @@ -109,6 +132,7 @@ export interface editMeasurementCategoryParams { id: number, name: string; unit: string; + dynamic_type: DynamicMeasurementType; } export const editMeasurementCategory = async (data: editMeasurementCategoryParams): Promise => { @@ -116,7 +140,8 @@ export const editMeasurementCategory = async (data: editMeasurementCategoryParam makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: data.id }), { name: data.name, - unit: data.unit + unit: data.unit, + dynamic_type: data.dynamic_type // eslint-disable-line camelcase }, { headers: makeHeader() } ); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index 08861e1b3..b81e3fb03 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -1,6 +1,13 @@ import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { Adapter } from "@/core/lib/Adapter"; +export type DynamicMeasurementType = 'NONE' | 'BMI'; // add future types when more dynamic types added to Measurement category in Django + +export const DYNAMIC_TYPE_DEFAULTS: Record = { + 'BMI': { name: 'BMI', unit: 'kg/m²' }, + // add future types here +}; + export class MeasurementCategory { entries: MeasurementEntry[] = []; @@ -9,6 +16,7 @@ export class MeasurementCategory { public id: number, public name: string, public unit: string, + public dynamic_type: DynamicMeasurementType = 'NONE', entries?: MeasurementEntry[] ) { if (entries) { @@ -26,14 +34,14 @@ export class MeasurementCategory { } } - class MeasurementCategoryAdapter implements Adapter { // eslint-disable-next-line @typescript-eslint/no-explicit-any fromJson(item: any) { return new MeasurementCategory( item.id, item.name, - item.unit + item.unit, + item.dynamic_type ); } @@ -42,6 +50,7 @@ class MeasurementCategoryAdapter implements Adapter { id: item.id, name: item.name, unit: item.unit, + dynamic_type: item.dynamic_type }; } } diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts index f958c16fb..25bafb51c 100644 --- a/src/components/Measurements/queries/index.ts +++ b/src/components/Measurements/queries/index.ts @@ -9,6 +9,7 @@ import { editMeasurementCategoryParams, editMeasurementEntry, editMeasurementParams, + getDynamicCategories, getMeasurementCategories, getMeasurementCategory, MeasurementQueryOptions @@ -24,6 +25,13 @@ export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) }); } +export function useDynamicCategoriesQuery() { + return useQuery({ + queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, 'dynamic'], + queryFn: getDynamicCategories + }); +} + export const useAddMeasurementCategoryQuery = () => { const queryClient = useQueryClient(); diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx index 64dff3749..11c60a11e 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx @@ -23,15 +23,21 @@ export const MeasurementCategoryDetail = () => { return ; } + // Safety check: if the query failed or returned nothing + if (categoryQuery.isError || !categoryQuery.data) { + return

Error loading category data. Check if category {categoryId} exists.

; + } + + // Now it is safe to access .name return } + title={categoryQuery.data.name} + optionsMenu={} mainContent={ - - + + } - fab={} + fab={categoryQuery.data.dynamic_type.includes('NONE') ? : undefined} />; }; diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx index 10d350532..d7717fb70 100644 --- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx @@ -34,10 +34,11 @@ export const CategoryList = (props: { category: MeasurementCategory }) => { {t("seeDetails")} - - - - + {props.category.dynamic_type === 'NONE' && ( + + + + )} diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx index 2e428ffcf..203d1ab48 100644 --- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx @@ -65,7 +65,7 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory }) }, }} > - {t("edit")} + {props.category.dynamic_type === 'NONE' && {t("edit")}} {t("delete")} diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index 1ffe18b51..9c0ac5d80 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,6 +1,6 @@ -import { Button, Stack, TextField } from "@mui/material"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; -import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; +import { Button, Stack, TextField, MenuItem, CircularProgress } from "@mui/material"; +import { MeasurementCategory, DYNAMIC_TYPE_DEFAULTS } from "@/components/Measurements/models/Category"; +import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery, useDynamicCategoriesQuery } from "@/components/Measurements/queries"; import { Form, Formik } from "formik"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -11,11 +11,18 @@ interface CategoryFormProps { closeFn?: () => void, } -export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { +interface DynamicTypeOption { + value: string; + label: string; +} +export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { const [t] = useTranslation(); - const useAddCategoryQuery = useAddMeasurementCategoryQuery(); - const useEditCategoryQuery = useEditMeasurementCategoryQuery(category?.id || 0); + const addCategoryQuery = useAddMeasurementCategoryQuery(); + const editCategoryQuery = useEditMeasurementCategoryQuery(category?.id || 0); + + const dynamicQuery = useDynamicCategoriesQuery(); + const validationSchema = yup.object({ name: yup .string() @@ -25,64 +32,103 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { unit: yup .string() .required(t('forms.fieldRequired')) - .max(5, t('forms.maxLength', { chars: '5' })) + .max(5, t('forms.maxLength', { chars: '5' })), + dynamic_type: yup + .string() + .required(t('forms.fieldRequired')) }); - return ( { - - // Edit existing weight entry if (category) { - useEditCategoryQuery.mutate({ ...values, id: category.id }); + editCategoryQuery.mutate({ ...values, id: category.id }); } else { - useAddCategoryQuery.mutate(values); - } - - // if closeFn is defined, close the modal (this form does not have to - // be displayed in a modal) - if (closeFn) { - closeFn(); + addCategoryQuery.mutate(values); } + if (closeFn) closeFn(); }} > - {formik => ( -
- - - - - + {formik => { + // extract the props so we can override onChange + const dynamicTypeProps = formik.getFieldProps('dynamic_type'); + + return ( + + + + + + + { + dynamicTypeProps.onChange(e); + + // check dynamic type default units + const selectedVal = e.target.value; + const defaults = DYNAMIC_TYPE_DEFAULTS[selectedVal]; + + if (defaults) { + formik.setFieldValue('unit', defaults.unit); + + // auto-fill the name only if the user hasn't typed anything yet + if (!formik.values.name) { + formik.setFieldValue('name', defaults.name); + } + } + }} + > + {dynamicQuery.isLoading && ( + + Loading types... + + )} + {!dynamicQuery.isLoading && (dynamicQuery.data as unknown as DynamicTypeOption[])?.map(type => ( + + {type.label} + + ))} + {!dynamicQuery.isLoading && (!dynamicQuery.data || dynamicQuery.data.length === 0) && ( + Standard (Manual Entry) + )} + + + + + - - - )} + + ); + }}
); -}; +}; \ No newline at end of file diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx index 4c011df01..701a5a99a 100644 --- a/src/components/Weight/widgets/Table/index.tsx +++ b/src/components/Weight/widgets/Table/index.tsx @@ -15,6 +15,7 @@ import { GridRowModesModel, GridRowsProp, } from "@mui/x-data-grid"; +import { useProfileQuery } from "@/components/User"; import { WeightEntry } from "@/components/Weight/models/WeightEntry"; import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab"; import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries"; @@ -29,19 +30,32 @@ export interface WeightTableProps { weights: WeightEntry[]; } -const buildRows = (weights: WeightEntry[]): GridRowsProp => +const buildRows = (weights: WeightEntry[], height?: number, isMetric: boolean = true): GridRowsProp => processTimeSeries(weights, e => e.weight).map((row) => ({ id: row.entry.id, date: row.entry.date, weight: row.entry.weight, + bmi: calculateBMI(row.entry.weight, height, isMetric), change: +row.change.toFixed(2), totalChange: +row.totalChange.toFixed(2), days: +row.days.toFixed(1), })); +export const calculateBMI = (weight: number, height?: number, isMetric: boolean = true): number | null => { + if (!height || height <= 0) return null; + + const weightInKg = isMetric ? weight : weight * 0.453592; + const heightInMeters = height / 100; + + return +(weightInKg / (heightInMeters ** 2)).toFixed(2); +}; + export const WeightTable = ({ weights }: WeightTableProps) => { const [t] = useTranslation(); - const rows = buildRows(weights); + const profileQuery = useProfileQuery(); + const height = profileQuery.data?.height; + const isMetric = profileQuery.data?.useMetric ?? true; + const rows = buildRows(weights, height, isMetric); const editEntryQuery = useEditWeightEntryQuery(); const deleteEntryQuery = useDeleteWeightEntryQuery(); const [rowModesModel, setRowModesModel] = useState({}); @@ -102,6 +116,13 @@ export const WeightTable = ({ weights }: WeightTableProps) => { width: 100, editable: true, }, + { + field: 'bmi', + headerName: 'BMI', + type: 'number', + width: 140, + editable: false, + }, { field: 'change', headerName: t('difference'), diff --git a/src/tests/measurementsTestData.ts b/src/tests/measurementsTestData.ts index 425d2f4e6..8c101bace 100644 --- a/src/tests/measurementsTestData.ts +++ b/src/tests/measurementsTestData.ts @@ -28,6 +28,7 @@ export const TEST_MEASUREMENT_CATEGORY_1 = new MeasurementCategory( 1, "Biceps", "cm", + "NONE", TEST_MEASUREMENT_ENTRIES_1, ); @@ -36,5 +37,6 @@ export const TEST_MEASUREMENT_CATEGORY_2 = new MeasurementCategory( 2, "Body fat", "%", + "NONE", TEST_MEASUREMENT_ENTRIES_2 ); \ No newline at end of file