diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx index 56f7bffe7..e1e477a8b 100644 --- a/src/components/Calendar/Components/CalendarComponent.tsx +++ b/src/components/Calendar/Components/CalendarComponent.tsx @@ -25,7 +25,7 @@ export interface DayProps { const CalendarComponent = (props: { isStandalone?: boolean }) => { const [t] = useTranslation(); - const currentDate = new Date(); + const currentDate = useMemo(() => new Date(), []); const [currentMonth, setCurrentMonth] = useState(currentDate.getMonth()); const [currentYear, setCurrentYear] = useState(currentDate.getFullYear()); @@ -133,7 +133,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { setSelectedDay(todayWithData); } } - }, [isSuccess]); + }, [currentDate, days, isSuccess]); useEffect(() => { @@ -246,4 +246,4 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { ); }; -export default CalendarComponent; \ No newline at end of file +export default CalendarComponent; diff --git a/src/components/Calendar/Components/CalendarDayGrid.tsx b/src/components/Calendar/Components/CalendarDayGrid.tsx index 8f085140a..32a565aad 100644 --- a/src/components/Calendar/Components/CalendarDayGrid.tsx +++ b/src/components/Calendar/Components/CalendarDayGrid.tsx @@ -27,16 +27,16 @@ const CalendarDayGrid: React.FC = ({ return ( - {weekDays.map((day, index) => ( - + {weekDays.map((day) => ( + {day} ))} - {days.map((day, index) => ( - + {days.map((day) => ( + = ({ ); }; -export default CalendarDayGrid; \ No newline at end of file +export default CalendarDayGrid; diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx index 9b5b23497..9066376ec 100644 --- a/src/components/Calendar/Components/Entries.tsx +++ b/src/components/Calendar/Components/Entries.tsx @@ -93,8 +93,8 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => { - {selectedDay.measurements.map((measurement, key) => ( - + {selectedDay.measurements.map((measurement) => ( + = ({ selectedDay, isStandalone }) => { ); }; -export default Entries; \ No newline at end of file +export default Entries; diff --git a/src/components/Dashboard/RoutineCard.tsx b/src/components/Dashboard/RoutineCard.tsx index 0ba527dac..12e9b1ee4 100644 --- a/src/components/Dashboard/RoutineCard.tsx +++ b/src/components/Dashboard/RoutineCard.tsx @@ -78,11 +78,13 @@ const DayListItem = (props: { dayData: RoutineDayData }) => { {props.dayData.slots.map((slotData, index) => ( + // The API doesn't expose an id for the slots and they are not reordered here + // eslint-disable-next-line @eslint-react/no-array-index-key
- {slotData.setConfigs.map((setConfigData, index) => ( + {slotData.setConfigs.map((setConfigData) => ( diff --git a/src/components/Exercises/forms/ExerciseAliases.tsx b/src/components/Exercises/forms/ExerciseAliases.tsx index 45ee349be..200e81ca8 100644 --- a/src/components/Exercises/forms/ExerciseAliases.tsx +++ b/src/components/Exercises/forms/ExerciseAliases.tsx @@ -9,10 +9,22 @@ export function ExerciseAliases(props: { fieldName: string }) { const [t] = useTranslation(); const [field, meta, helpers] = useField(props.fieldName); - const normalize = (items: (AliasItem | string)[] | null | undefined): AliasItem[] => - (items || []).map(item => - typeof item === "string" ? { alias: item } : ("alias" in item ? (item as AliasItem) : { alias: String(item) }) - ); + const normalize = (items: (AliasItem | string)[] | null | undefined): AliasItem[] => { + const seen = new Set(); + + return (items || []) + .map(item => + typeof item === "string" ? { alias: item } : ("alias" in item ? (item as AliasItem) : { alias: String(item) }) + ) + .filter(item => { + if (seen.has(item.alias)) { + return false; + } + seen.add(item.alias); + + return true; + }); + }; /** * Extract a human-readable error string from the Yup alias validator, which @@ -68,7 +80,7 @@ export function ExerciseAliases(props: { fieldName: string }) { newVal.splice(index, 1); helpers.setValue(newVal); }} - key={option.id ?? `${option.alias}-${index}`} + key={option.id ?? option.alias} /> )); @@ -98,4 +110,4 @@ export function ExerciseAliases(props: { fieldName: string }) { ); }} />; -} \ No newline at end of file +} diff --git a/src/components/Exercises/forms/ExerciseNotes.tsx b/src/components/Exercises/forms/ExerciseNotes.tsx index 5b45efc6c..74c170bb2 100644 --- a/src/components/Exercises/forms/ExerciseNotes.tsx +++ b/src/components/Exercises/forms/ExerciseNotes.tsx @@ -3,15 +3,18 @@ import DeleteIcon from '@mui/icons-material/Delete'; import { IconButton, InputAdornment, TextField } from "@mui/material"; import Grid from '@mui/material/Grid'; import { useField } from "formik"; -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { randomUUID } from "@/core/lib/uuid"; export function ExerciseNotes(props: { fieldName: string }) { const [t] = useTranslation(); const [field, meta, helpers] = useField(props.fieldName); const [newNoteValue, setNewNoteValue] = useState(''); + const noteKeys = useRef(field.value.map(() => randomUUID())); const deleteAtIndex = (index: number) => { + noteKeys.current.splice(index, 1); helpers.setValue(field.value.filter((_: string, b: number) => b !== index)); }; @@ -20,6 +23,7 @@ export function ExerciseNotes(props: { fieldName: string }) { helpers.setValue(field.value); }; const addEntry = () => { + noteKeys.current.push(randomUUID()); field.value.push(newNoteValue); helpers.setValue(field.value); setNewNoteValue(''); @@ -51,7 +55,7 @@ export function ExerciseNotes(props: { fieldName: string }) { {field.value.map((note: string, index: number) => setNoteValueIndex(index, event.target.value)} @@ -73,4 +77,4 @@ export function ExerciseNotes(props: { fieldName: string }) { /> )} ; -} \ No newline at end of file +} diff --git a/src/components/Exercises/forms/Variation.tsx b/src/components/Exercises/forms/Variation.tsx index 63399028c..e58cfb0f1 100644 --- a/src/components/Exercises/forms/Variation.tsx +++ b/src/components/Exercises/forms/Variation.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { VariationSelect } from "@/components/Exercises/forms/VariationSelect"; import { useEditExerciseQuery } from "@/components/Exercises/queries"; import { useProfileQuery } from "@/components/User"; +import { randomUUID } from "@/core/lib/uuid"; export function EditExerciseVariation(props: { exerciseId: number, initial: string | null }) { const [selectedVariationId, setSelectedVariationId] = useState(props.initial); @@ -31,7 +32,7 @@ export function EditExerciseVariation(props: { exerciseId: number, initial: stri if (id !== null) { // Generate a new variation group UUID and assign both exercises to it - const variationGroup = crypto.randomUUID(); + const variationGroup = randomUUID(); try { await editMutation.mutateAsync({ id: props.exerciseId, diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx index dfe247e1a..bc1768b1b 100644 --- a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx +++ b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx @@ -102,7 +102,7 @@ export const ExerciseDetailView = ({ } {t("exercises.description")} -
+
{currentTranslation?.notes.length > 0 && {t("exercises.notes")}} diff --git a/src/components/Exercises/screens/Detail/Head/index.tsx b/src/components/Exercises/screens/Detail/Head/index.tsx index a157b417c..685aeec2f 100644 --- a/src/components/Exercises/screens/Detail/Head/index.tsx +++ b/src/components/Exercises/screens/Detail/Head/index.tsx @@ -28,7 +28,7 @@ export interface HeadProp { languages: Language[] changeLanguage: (lang: Language) => void, language: Language | undefined // language displayed in the head since it's not found in the translations - setEditMode: Function, + setEditMode: (editMode: boolean) => void, editMode: boolean } diff --git a/src/components/Exercises/widgets/Overview/ExerciseGridLoadingSkeleton.tsx b/src/components/Exercises/widgets/Overview/ExerciseGridLoadingSkeleton.tsx index 7ba38509b..86dab2c00 100644 --- a/src/components/Exercises/widgets/Overview/ExerciseGridLoadingSkeleton.tsx +++ b/src/components/Exercises/widgets/Overview/ExerciseGridLoadingSkeleton.tsx @@ -4,10 +4,12 @@ import React from "react"; export const ExerciseGridSkeleton = () => { + const skeletonIds = Array.from({ length: 21 }, (_, id) => `exercise-skeleton-${id + 1}`); + return ( ( - {[...Array(21)].map((skeletonBase, idx) => ( - + {skeletonIds.map((skeletonId) => ( + diff --git a/src/components/Nutrition/screens/BmiCalculator.tsx b/src/components/Nutrition/screens/BmiCalculator.tsx index 7dfab7182..b13b293a2 100644 --- a/src/components/Nutrition/screens/BmiCalculator.tsx +++ b/src/components/Nutrition/screens/BmiCalculator.tsx @@ -144,7 +144,7 @@ export const BmiCalculator = () => { /> [Math.round(value as number), t('bmi.' + (name as string))]} /> @@ -202,4 +202,4 @@ export const BmiCalculator = () => { } /> ); -}; \ No newline at end of file +}; diff --git a/src/components/Nutrition/widgets/DiaryOverview.tsx b/src/components/Nutrition/widgets/DiaryOverview.tsx index a1bacf260..3bf9d5d32 100644 --- a/src/components/Nutrition/widgets/DiaryOverview.tsx +++ b/src/components/Nutrition/widgets/DiaryOverview.tsx @@ -27,7 +27,7 @@ export const DiaryOverview = (props: { - {Array.from(props.logged).map(([key]) => + {Array.from(props.logged).map(([key, diaryEntries]) => {t('nutrition.valueEnergyKcal', - { value: numberLocale(props.logged.get(key)?.nutritionalValues.energy!, i18n.language) } + { value: numberLocale(diaryEntries.nutritionalValues.energy, i18n.language) } )} - {numberLocale(props.logged.get(key)?.nutritionalValues.energy! - props.planned.energy, i18n.language)} + {numberLocale(diaryEntries.nutritionalValues.energy - props.planned.energy, i18n.language)} ) } ; -}; \ No newline at end of file +}; diff --git a/src/components/Nutrition/widgets/charts/MacrosPieChart.tsx b/src/components/Nutrition/widgets/charts/MacrosPieChart.tsx index bde145d6f..de9cc3682 100644 --- a/src/components/Nutrition/widgets/charts/MacrosPieChart.tsx +++ b/src/components/Nutrition/widgets/charts/MacrosPieChart.tsx @@ -54,8 +54,8 @@ export const MacrosPieChart = (props: { data: NutritionalValues }) => { fill="#8884d8" dataKey="value" > - {data.map((entry, index) => ( - + {data.map((entry) => ( + ))} {/**/} diff --git a/src/components/Nutrition/widgets/charts/NutritionalValuesDashboardChart.tsx b/src/components/Nutrition/widgets/charts/NutritionalValuesDashboardChart.tsx index c84a4aa79..c9d6c14a9 100644 --- a/src/components/Nutrition/widgets/charts/NutritionalValuesDashboardChart.tsx +++ b/src/components/Nutrition/widgets/charts/NutritionalValuesDashboardChart.tsx @@ -20,10 +20,12 @@ export const NutritionalValuesDashboardChart = (props: { const [t, i18n] = useTranslation(); const data = [ { + id: 'logged', name: '', value: energyPercentage, }, { + id: 'remaining', name: '', value: energyPercentage < 100 ? 100 - energyPercentage : 0, }, @@ -46,7 +48,7 @@ export const NutritionalValuesDashboardChart = (props: { dataKey="value" > {data.map((entry, index) => ( - + ))} diff --git a/src/components/Routines/models/WorkoutLog.ts b/src/components/Routines/models/WorkoutLog.ts index 7ffb9df4a..9f09a1985 100644 --- a/src/components/Routines/models/WorkoutLog.ts +++ b/src/components/Routines/models/WorkoutLog.ts @@ -6,6 +6,7 @@ import { WeightUnit } from "@/components/Routines/models/WeightUnit"; import { Adapter } from "@/core/lib/Adapter"; export interface LogEntryForm { + clientKey: string; exercise: Exercise | null; repetitionsUnit: RepetitionUnit | null; weightUnit: WeightUnit | null; @@ -159,4 +160,4 @@ export class WorkoutLogAdapter implements Adapter { rest: item.restTime, rest_target: item.restTimeTarget }); -} \ No newline at end of file +} diff --git a/src/components/Routines/screens/Detail/WorkoutStats.tsx b/src/components/Routines/screens/Detail/WorkoutStats.tsx index 99130626a..11a687098 100644 --- a/src/components/Routines/screens/Detail/WorkoutStats.tsx +++ b/src/components/Routines/screens/Detail/WorkoutStats.tsx @@ -133,11 +133,11 @@ export const WorkoutStats = () => { {statsData.data.map((row) => ( {row.key} - {row.values.map((value, index) => ( + {statsData.headers.map((header, index) => ( {value?.toFixed(selectedValueType === StatType.Intensity ? 2 : 0) || ""} + >{row.values[index]?.toFixed(selectedValueType === StatType.Intensity ? 2 : 0) || ""} ))} diff --git a/src/components/Routines/widgets/RoutineDetailsCard.tsx b/src/components/Routines/widgets/RoutineDetailsCard.tsx index 8d9436b75..aea8af05f 100644 --- a/src/components/Routines/widgets/RoutineDetailsCard.tsx +++ b/src/components/Routines/widgets/RoutineDetailsCard.tsx @@ -145,7 +145,7 @@ function SlotDataList(props: { slotData: SlotData }) { return ; })} @@ -248,6 +248,8 @@ export const DayDetailsCard = (props: { {slotData.length > 0 && {slotData.map((slotData, index) => ( + // The API doesn't expose an id for the slots and they are not reordered here + // eslint-disable-next-line @eslint-react/no-array-index-key
diff --git a/src/components/Routines/widgets/forms/BaseConfigForm.tsx b/src/components/Routines/widgets/forms/BaseConfigForm.tsx index 587a41e80..be85cec98 100644 --- a/src/components/Routines/widgets/forms/BaseConfigForm.tsx +++ b/src/components/Routines/widgets/forms/BaseConfigForm.tsx @@ -275,8 +275,8 @@ export const ConfigDetailsRequirementsField = (props: { open={Boolean(anchorEl)} onClose={() => setAnchorEl(null)} > - {...REQUIREMENTS_VALUES.map((e, index) => handleSelection(e as unknown as RequirementsType)}> {selectedElements.includes(e as unknown as RequirementsType) @@ -504,4 +504,4 @@ export const EntryDetailsStepField = (props: { ); }; -*/ \ No newline at end of file +*/ diff --git a/src/components/Routines/widgets/forms/ProgressionForm.tsx b/src/components/Routines/widgets/forms/ProgressionForm.tsx index 45abeb29a..1c76d34b4 100644 --- a/src/components/Routines/widgets/forms/ProgressionForm.tsx +++ b/src/components/Routines/widgets/forms/ProgressionForm.tsx @@ -349,7 +349,7 @@ export const ProgressionForm = (props: { {({ insert, remove }) => (<> {formik.values.entries.map((log, index) => ( - + } {log.requirements.length >= 0 &&
} - {log.requirements.length >= 0 && log.requirements.map((requirement, index) => ( - + {log.requirements.length >= 0 && log.requirements.map((requirement) => ( + {requirement}   ))} diff --git a/src/components/Routines/widgets/forms/SessionLogsForm.tsx b/src/components/Routines/widgets/forms/SessionLogsForm.tsx index 4dc86bae8..d904ec9cc 100644 --- a/src/components/Routines/widgets/forms/SessionLogsForm.tsx +++ b/src/components/Routines/widgets/forms/SessionLogsForm.tsx @@ -12,7 +12,7 @@ import { Alert, Button, IconButton, InputAdornment, MenuItem, Snackbar, TextFiel import Grid from '@mui/material/Grid'; import { FieldArray, Form, Formik, FormikProps } from "formik"; import { DateTime } from "luxon"; -import React, { useState } from 'react'; +import React, { useRef, useState } from 'react'; import { useTranslation } from "react-i18next"; import * as yup from "yup"; @@ -32,6 +32,9 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF const handleSnackbarClose = () => setSnackbarOpen(false); const [exerciseIdToSwap, setExerciseIdToSwap] = useState(null); + // Counter for the keys of the logs the user adds on top of the planned ones + const extraLogKey = useRef(0); + let language = undefined; if (languageQuery.isSuccess) { language = getLanguageByShortName( @@ -139,6 +142,7 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF for (let i = 0; i < config.nrOfSets; i++) { initialValues.logs.push({ + clientKey: `${dayData.iteration}-${config.slotEntryId}-${config.exerciseId}-${i}`, exercise: config.exercise!, repetitionsUnit: config.repetitionsUnit!, weightUnit: config.weightUnit!, @@ -174,7 +178,7 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF {({ insert, remove }) => (<> {formik.values.logs.map((log, index) => ( - + {/* Only show the exercise name the first time it appears */} {(index === 0 || (index > 0 && formik.values.logs[index - 1].exercise!.id !== formik.values.logs[index].exercise!.id)) && <> @@ -195,6 +199,7 @@ export const SessionLogsForm = ({ dayId, routineId, selectedDate }: SessionLogsF type="button" size="small" onClick={() => insert(index, { + clientKey: `extra-${extraLogKey.current++}`, exercise: formik.values.logs[index].exercise, repetitions: formik.values.logs[index].repetitions, weight: formik.values.logs[index].weight diff --git a/src/core/lib/uuid.test.ts b/src/core/lib/uuid.test.ts new file mode 100644 index 000000000..21f87167e --- /dev/null +++ b/src/core/lib/uuid.test.ts @@ -0,0 +1,33 @@ +import { randomUUID } from "@/core/lib/uuid"; + +describe("randomUUID", () => { + + const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test('uses the native implementation when it is available', () => { + const spy = vi.spyOn(globalThis.crypto, 'randomUUID') + .mockReturnValue('583281c7-2362-48e7-95d5-8fd6c455e0fb'); + + expect(randomUUID()).toEqual('583281c7-2362-48e7-95d5-8fd6c455e0fb'); + expect(spy).toHaveBeenCalled(); + }); + + test('falls back to getRandomValues outside of secure contexts', () => { + // Only randomUUID is restricted to secure contexts, getRandomValues stays available + const nativeCrypto = globalThis.crypto; + vi.stubGlobal('crypto', { + getRandomValues: (array: Uint8Array) => nativeCrypto.getRandomValues(array) + }); + + expect(randomUUID()).toMatch(UUID_V4); + }); + + test('returns different values on each call', () => { + expect(randomUUID()).not.toEqual(randomUUID()); + }); +}); diff --git a/src/core/lib/uuid.ts b/src/core/lib/uuid.ts new file mode 100644 index 000000000..7ec953c82 --- /dev/null +++ b/src/core/lib/uuid.ts @@ -0,0 +1,19 @@ +/** + * Returns a random UUID (version 4) + * + * crypto.randomUUID is only available in secure contexts, which leaves out the + * instances served over plain HTTP. getRandomValues has no such restriction. + */ +export function randomUUID(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); + + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} diff --git a/src/core/ui/Widgets/FormError.tsx b/src/core/ui/Widgets/FormError.tsx index 09e7b9b1c..97d007e74 100644 --- a/src/core/ui/Widgets/FormError.tsx +++ b/src/core/ui/Widgets/FormError.tsx @@ -17,6 +17,8 @@ export const FormQueryErrors = (props: { mutationQuery: any }) => { {props.mutationQuery.error?.message}
    {collectValidationErrors(props.mutationQuery.error.response?.data).map((error, index) => + // Two entries of the same list produce the same message, so the text alone is no key + // eslint-disable-next-line @eslint-react/no-array-index-key
  • {error}
  • )}
@@ -37,10 +39,12 @@ export const FormQueryErrorsSnackbar = (props: { mutationQuery: any }) => { {props.mutationQuery.error?.message}
    {collectValidationErrors(props.mutationQuery.error.response?.data).map((error, index) => + // Two entries of the same list produce the same message, so the text alone is no key + // eslint-disable-next-line @eslint-react/no-array-index-key
  • {error}
  • )}
); -}; \ No newline at end of file +}; diff --git a/src/core/ui/Widgets/RenderLoadingQuery.tsx b/src/core/ui/Widgets/RenderLoadingQuery.tsx index 9f29c58ec..e4fc86e00 100644 --- a/src/core/ui/Widgets/RenderLoadingQuery.tsx +++ b/src/core/ui/Widgets/RenderLoadingQuery.tsx @@ -16,12 +16,11 @@ export const RenderLoadingQuery = (props: { query: UseQueryResult, child: JSX.El sx={{ height: 200, alignItems: "center", mt: 2, justifyContent: "center" }} component={Stack} direction="column"> - {/*// @ts-ignore */} - Error while fetching data: {props.query.error!.message} + Error while fetching data: {props.query.error.message}
; } if (props.query.isSuccess) { return props.child; } -}; \ No newline at end of file +}; diff --git a/src/i18n.ts b/src/i18n.ts index b9a231667..0b625023a 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2,12 +2,12 @@ import { IS_PROD } from "@/config"; import i18n from "i18next"; import LanguageDetector from 'i18next-browser-languagedetector'; import Backend from 'i18next-http-backend'; -import common from "@/locales/en/translation.json"; +import type common from "@/locales/en/translation.json"; import { initReactI18next } from "react-i18next"; export const resources = { en: { - common, + common: null as unknown as typeof common, }, } as const; @@ -71,4 +71,4 @@ i18n //resources }); -export default i18n; \ No newline at end of file +export default i18n;