From 33f8493723ee1cc6e4bee7e0bf14b279d0ddc282 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 11 May 2026 13:11:50 +0930 Subject: [PATCH 1/7] Add calculateBMI function inside weight table for now --- src/components/Weight/widgets/Table/index.tsx | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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'), From 70dc885af9fd0d9745f0717799107d40be283b6b Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 11 May 2026 17:03:55 +0930 Subject: [PATCH 2/7] Add switch statement for dynamic measurements when rendering chart and grid Adds ability to select a dynamic measurement from the dropdown which uses other measurements to auto calculate values. Note: Should create module for specific dynamic measurements. --- .../Measurements/api/measurements.ts | 8 +- .../Measurements/models/Category.ts | 6 +- .../screens/MeasurementCategoryDetail.tsx | 3 +- .../widgets/CategoryDetailDataGrid.tsx | 143 +++++++++--------- .../Measurements/widgets/CategoryForm.tsx | 58 ++++++- .../Measurements/widgets/MeasurementChart.tsx | 45 +++++- 6 files changed, 173 insertions(+), 90 deletions(-) diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts index 515915b1e..06478d104 100644 --- a/src/components/Measurements/api/measurements.ts +++ b/src/components/Measurements/api/measurements.ts @@ -90,6 +90,7 @@ export const getMeasurementCategory = async (id: number): Promise => { @@ -97,7 +98,8 @@ export const addMeasurementCategory = async (data: AddMeasurementCategoryParams) makeUrl(API_MEASUREMENTS_CATEGORY_PATH,), { name: data.name, - unit: data.unit + unit: data.unit, + is_dynamic: data.is_dynamic }, { headers: makeHeader() } ); @@ -109,6 +111,7 @@ export interface editMeasurementCategoryParams { id: number, name: string; unit: string; + is_dynamic: boolean; } export const editMeasurementCategory = async (data: editMeasurementCategoryParams): Promise => { @@ -116,7 +119,8 @@ export const editMeasurementCategory = async (data: editMeasurementCategoryParam makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: data.id }), { name: data.name, - unit: data.unit + unit: data.unit, + is_dynamic: data.is_dynamic }, { headers: makeHeader() } ); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index 08861e1b3..f7244f206 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -9,6 +9,7 @@ export class MeasurementCategory { public id: number, public name: string, public unit: string, + public is_dynamic: boolean = false, entries?: MeasurementEntry[] ) { if (entries) { @@ -26,14 +27,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.is_dynamic ?? false // Map from snake_case backend property ); } @@ -42,6 +43,7 @@ class MeasurementCategoryAdapter implements Adapter { id: item.id, name: item.name, unit: item.unit, + is_dynamic: item.is_dynamic // Map to snake_case for backend }; } } diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx index 64dff3749..6c12a9dcd 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx @@ -32,6 +32,7 @@ export const MeasurementCategoryDetail = () => { } - fab={} + // only show the FAB if the category is NOT dynamic + fab={!categoryQuery.data?.is_dynamic ? : undefined} />; }; diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx index 79d9fd13b..d97edfe2e 100644 --- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx @@ -2,6 +2,8 @@ import { processTimeSeries } from "@/core/lib/timeSeries"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries"; +import { useBodyWeightQuery } from "@/components/Weight"; +import { useProfileQuery } from "@/components/User"; import { PAGINATION_OPTIONS } from "@/core/lib/consts"; import { luxonDateTimeToLocale } from "@/core/lib/date"; import CancelIcon from "@mui/icons-material/Close"; @@ -25,9 +27,37 @@ 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) => ({ - id: row.entry.id, +export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) => { + const [t] = useTranslation(); + const weightQuery = useBodyWeightQuery(''); + const profileQuery = useProfileQuery(); + + let entries = [...props.category.entries]; + if (props.category.is_dynamic) { + switch (props.category.name) { + case "BMI": { + const height = profileQuery.data?.height; + const weights = weightQuery.data || []; + + if (height && height > 0) { + const hMeters = height / 100; + entries = weights.map(w => new MeasurementEntry( + null, + props.category.id, + new Date(w.date), + +(w.weight / (hMeters ** 2)).toFixed(2), + "Auto-generated" + )); + } + break; + } + default: + break; + } + } + + const data: GridRowsProp = processTimeSeries(entries, e => e.value).map((row) => ({ + id: row.entry.id ?? row.entry.date.getTime(), date: row.entry.date, value: row.entry.value, notes: row.entry.notes, @@ -36,17 +66,10 @@ const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp => days: +row.days.toFixed(1), })); - -export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) => { - - const [t] = useTranslation(); - const data: GridRowsProp = convertEntriesToObj(props.category.entries); const updateEntryQuery = useEditMeasurementEntryQuery(); const deleteEntryQuery = useDeleteMeasurementsQuery(); - const [rows, setRows] = useState(data); const [rowModesModel, setRowModesModel] = useState({}); - const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { if (params.reason === GridRowEditStopReasons.rowFocusOut) { event.defaultMuiPrevented = true; @@ -63,7 +86,6 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) const handleDeleteClick = (id: GridRowId) => async () => { deleteEntryQuery.mutate(parseInt(id.toString())); - setRows(rows.filter((row) => row.id !== id)); }; const handleCancelClick = (id: GridRowId) => () => { @@ -71,17 +93,9 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) ...rowModesModel, [id]: { mode: GridRowModes.View, ignoreModifications: true }, }); - - const editedRow = rows.find((row) => row.id === id); - //if (editedRow!.isNew) { - if (editedRow?.id === null) { - setRows(rows.filter((row) => row.id !== id)); - } }; - const processRowUpdate = async (newRow: GridRowModel) => { - updateEntryQuery.mutate({ id: newRow.id, categoryId: newRow.category, @@ -89,18 +103,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) value: newRow.value, notes: newRow.notes }); - - const updatedRow = { ...newRow, isNew: false }; - setRows(rows.map((row) => (row.id === newRow.id ? updatedRow : row))); - return updatedRow; - }; - - const onProcessRowUpdateError = (error: unknown) => { - console.error(error); - }; - - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); + return { ...newRow, isNew: false }; }; const columns: GridColDef[] = [ @@ -108,26 +111,16 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) field: 'value', headerName: t('value'), width: 80, - editable: true, - valueFormatter: (value?: number) => { - if (value == null) { - return ''; - } - return value + props.category.unit; - }, + editable: !props.category.is_dynamic, + valueFormatter: (value?: number) => value != null ? value + props.category.unit : '', }, { field: 'date', headerName: t('date'), type: 'date', width: 120, - editable: true, - valueFormatter: (value?: Date) => { - if (value == null) { - return ''; - } - return luxonDateTimeToLocale(DateTime.fromJSDate(value)); - }, + editable: !props.category.is_dynamic, + valueFormatter: (value?: Date) => value != null ? luxonDateTimeToLocale(DateTime.fromJSDate(value)) : '', }, { field: 'change', @@ -155,9 +148,12 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) headerName: t('notes'), type: 'string', flex: 1, - editable: true, + editable: !props.category.is_dynamic, }, - { + ]; + + if (!props.category.is_dynamic) { + columns.push({ field: 'actions', type: 'actions', headerName: t('actions'), @@ -165,7 +161,6 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) cellClassName: 'actions', getActions: ({ id }) => { const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - if (isInEditMode) { return [ } label="Cancel" - className="textPrimary" onClick={handleCancelClick(id)} color="inherit" />, ]; } - return [ } label="Edit" - className="textPrimary" onClick={handleEditClick(id)} color="inherit" />, @@ -199,29 +191,30 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) />, ]; }, - }, - ]; - - - return - + - ; + }} + pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions} + disableRowSelectionOnClick + rowModesModel={rowModesModel} + onRowModesModelChange={setRowModesModel} + onRowEditStop={handleRowEditStop} + processRowUpdate={processRowUpdate} + onProcessRowUpdateError={(error) => console.error(error)} + /> + + ); }; \ No newline at end of file diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index 1ffe18b51..a4855a2d9 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,8 +1,8 @@ -import { Button, Stack, TextField } from "@mui/material"; +import { Button, Stack, TextField, FormControlLabel, Switch, FormControl, InputLabel, MenuItem, Select } from "@mui/material"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; import { Form, Formik } from "formik"; -import React from 'react'; +import React, { useState } from 'react'; import { useTranslation } from "react-i18next"; import * as yup from 'yup'; @@ -16,6 +16,9 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { const [t] = useTranslation(); const useAddCategoryQuery = useAddMeasurementCategoryQuery(); const useEditCategoryQuery = useEditMeasurementCategoryQuery(category?.id || 0); + + const [preset, setPreset] = useState(category?.is_dynamic && category.name === 'BMI' ? 'bmi' : 'custom'); + const validationSchema = yup.object({ name: yup .string() @@ -28,16 +31,15 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { .max(5, t('forms.maxLength', { chars: '5' })) }); - return ( { - // Edit existing weight entry if (category) { useEditCategoryQuery.mutate({ ...values, id: category.id }); @@ -45,8 +47,6 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { useAddCategoryQuery.mutate(values); } - // if closeFn is defined, close the modal (this form does not have to - // be displayed in a modal) if (closeFn) { closeFn(); } @@ -55,11 +55,40 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { {formik => (
+ {!category && ( + + Template + + + )} + @@ -67,6 +96,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { fullWidth id="unit" label={t('unit')} + disabled={preset === 'bmi'} error={formik.touched.unit && Boolean(formik.errors.unit)} helperText={ formik.touched.unit && formik.errors.unit @@ -75,6 +105,18 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { } {...formik.getFieldProps('unit')} /> + + formik.setFieldValue('is_dynamic', e.target.checked)} + /> + } + label="Is Dynamic (Auto-calculated)" + /> + From 2a7ce6727f37dbf8933b1644ac42e1c010448f69 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Tue, 12 May 2026 18:16:18 +0930 Subject: [PATCH 4/7] Adapt frontend to work with dynamic measurement types --- .../Measurements/api/measurements.ts | 14 +-- .../Measurements/models/Category.ts | 8 +- .../screens/MeasurementCategoryDetail.tsx | 2 +- .../Measurements/widgets/CategoryForm.tsx | 103 +++++++----------- .../Measurements/widgets/MeasurementChart.tsx | 45 +------- 5 files changed, 56 insertions(+), 116 deletions(-) diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts index 77d0f4069..367ca23e8 100644 --- a/src/components/Measurements/api/measurements.ts +++ b/src/components/Measurements/api/measurements.ts @@ -1,6 +1,6 @@ import axios from 'axios'; import { useQuery } from '@tanstack/react-query'; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +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"; @@ -20,11 +20,11 @@ export interface DynamicCategory { id: number; name: string; unit: string; - is_dynamic: boolean; + dynamic_type: DynamicMeasurementType; } export const getDynamicCategories = async (): Promise => { - const url = makeUrl(`${API_MEASUREMENTS_CATEGORY_PATH}/dynamic`); + const url = makeUrl(`${API_MEASUREMENTS_CATEGORY_PATH}/dynamic-types`); const response = await axios.get(url, { headers: makeHeader() }); return response.data; }; @@ -111,7 +111,7 @@ export const getMeasurementCategory = async (id: number): Promise => { @@ -120,7 +120,7 @@ export const addMeasurementCategory = async (data: AddMeasurementCategoryParams) { name: data.name, unit: data.unit, - is_dynamic: data.is_dynamic // eslint-disable-line camelcase + dynamic_type: data.dynamic_type // eslint-disable-line camelcase }, { headers: makeHeader() } ); @@ -132,7 +132,7 @@ export interface editMeasurementCategoryParams { id: number, name: string; unit: string; - is_dynamic: boolean; + dynamic_type: DynamicMeasurementType; } export const editMeasurementCategory = async (data: editMeasurementCategoryParams): Promise => { @@ -141,7 +141,7 @@ export const editMeasurementCategory = async (data: editMeasurementCategoryParam { name: data.name, unit: data.unit, - is_dynamic: data.is_dynamic // eslint-disable-line camelcase + 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 f7244f206..9e4192661 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -1,6 +1,8 @@ import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { Adapter } from "@/core/lib/Adapter"; +export type DynamicMeasurementType = 'NONE' | 'BMI' | 'SQUAT_1RM'; + export class MeasurementCategory { entries: MeasurementEntry[] = []; @@ -9,7 +11,7 @@ export class MeasurementCategory { public id: number, public name: string, public unit: string, - public is_dynamic: boolean = false, + public dynamic_type: DynamicMeasurementType = 'NONE', entries?: MeasurementEntry[] ) { if (entries) { @@ -34,7 +36,7 @@ class MeasurementCategoryAdapter implements Adapter { item.id, item.name, item.unit, - item.is_dynamic ?? false // Map from snake_case backend property + item.dynamic_type ); } @@ -43,7 +45,7 @@ class MeasurementCategoryAdapter implements Adapter { id: item.id, name: item.name, unit: item.unit, - is_dynamic: item.is_dynamic // Map to snake_case for backend + dynamic_type: item.dynamic_type }; } } diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx index 5d521ba0d..11c60a11e 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx @@ -38,6 +38,6 @@ export const MeasurementCategoryDetail = () => { } - fab={!categoryQuery.data.is_dynamic ? : undefined} + fab={categoryQuery.data.dynamic_type.includes('NONE') ? : undefined} />; }; diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index 301d62846..fbff09d72 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,8 +1,8 @@ -import { Button, Stack, TextField, FormControl, InputLabel, MenuItem, Select, CircularProgress, FormControlLabel, Switch } from "@mui/material"; +import { Button, Stack, TextField, MenuItem, CircularProgress } from "@mui/material"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery, useDynamicCategoriesQuery } from "@/components/Measurements/queries"; import { Form, Formik } from "formik"; -import React, { useState } from 'react'; +import React from 'react'; import { useTranslation } from "react-i18next"; import * as yup from 'yup'; @@ -11,18 +11,17 @@ interface CategoryFormProps { closeFn?: () => void, } +interface DynamicTypeOption { + value: string; + label: string; +} + export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { const [t] = useTranslation(); const addCategoryQuery = useAddMeasurementCategoryQuery(); const editCategoryQuery = useEditMeasurementCategoryQuery(category?.id || 0); - - // Fetch dynamic options from the backend - const dynamicQuery = useDynamicCategoriesQuery(); - // Track if we are using a preset to disable manual editing - const [preset, setPreset] = useState( - category?.is_dynamic ? category.id : 'custom' - ); + const dynamicQuery = useDynamicCategoriesQuery(); const validationSchema = yup.object({ name: yup @@ -33,7 +32,10 @@ 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 ( @@ -41,7 +43,7 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { initialValues={{ name: category ? category.name : "", unit: category ? category.unit : "", - is_dynamic: category ? category.is_dynamic : false, + dynamic_type: category ? category.dynamic_type : 'NONE', }} validationSchema={validationSchema} onSubmit={async (values) => { @@ -56,73 +58,50 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { {formik => ( - {/* Only show template picker for new categories */} - {!category && ( - - Template - - - )} - + - - formik.setFieldValue('is_dynamic', e.target.checked)} - /> - } - label="Is Dynamic" - /> + + + {dynamicQuery.isLoading && ( + + Loading types... + + )} + {/* Force TS to accept the new shape temporarily */} + {!dynamicQuery.isLoading && (dynamicQuery.data as unknown as DynamicTypeOption[])?.map(type => ( + + {type.label} + + ))} + {/* Fallback to ensure MUI Select always has a valid matching child option */} + {!dynamicQuery.isLoading && (!dynamicQuery.data || dynamicQuery.data.length === 0) && ( + Standard (Manual Entry) + )} + - - - - + {props.category.dynamic_type === 'NONE' && ( + + + + )} diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx index 2e428ffcf..c217bacb8 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 fbff09d72..9c0ac5d80 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,5 +1,5 @@ import { Button, Stack, TextField, MenuItem, CircularProgress } from "@mui/material"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +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'; @@ -55,62 +55,80 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { if (closeFn) closeFn(); }} > - {formik => ( - - - - - + {formik => { + // extract the props so we can override onChange + const dynamicTypeProps = formik.getFieldProps('dynamic_type'); - - {dynamicQuery.isLoading && ( - - Loading types... - - )} - {/* Force TS to accept the new shape temporarily */} - {!dynamicQuery.isLoading && (dynamicQuery.data as unknown as DynamicTypeOption[])?.map(type => ( - - {type.label} - - ))} - {/* Fallback to ensure MUI Select always has a valid matching child option */} - {!dynamicQuery.isLoading && (!dynamicQuery.data || dynamicQuery.data.length === 0) && ( - Standard (Manual Entry) - )} - + 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 From bae305df69c98b498645c1d48bd2a0d89726260f Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Mon, 18 May 2026 20:21:21 +0930 Subject: [PATCH 6/7] Add dynamic type to measurement tests --- .../Components/CalendarComponent.test.tsx | 1 + .../Measurements/api/measurements.test.ts | 26 ++++++++++--------- src/tests/measurementsTestData.ts | 2 ++ 3 files changed, 17 insertions(+), 12 deletions(-) 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..650b9dc8e 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"); }); @@ -213,4 +215,4 @@ describe('measurement service tests', () => { expect.anything() ); }); -}); +}); \ No newline at end of file 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 From 9dc4aacb3cc896628530fa46bd7eb15249713c16 Mon Sep 17 00:00:00 2001 From: Dylan Smith Date: Fri, 22 May 2026 17:37:18 +0930 Subject: [PATCH 7/7] Fix linter errors --- src/components/Measurements/api/measurements.test.ts | 2 +- src/components/Measurements/widgets/CategoryDetailDropdown.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts index 650b9dc8e..df23e5f3a 100644 --- a/src/components/Measurements/api/measurements.test.ts +++ b/src/components/Measurements/api/measurements.test.ts @@ -215,4 +215,4 @@ describe('measurement service tests', () => { expect.anything() ); }); -}); \ No newline at end of file +}); diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx index c217bacb8..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 }) }, }} > - {props.category.dynamic_type == 'NONE' && {t("edit")}} + {props.category.dynamic_type === 'NONE' && {t("edit")}} {t("delete")}