├── .editorconfig ├── .env.TEMPLATE ├── .env.production ├── .github ├── contributing.md ├── dependabot.yml ├── pull_request_template.md ├── release.yml └── workflows │ ├── ci.yml │ ├── codeql-analysis.yml │ └── lint.yml ├── .gitignore ├── AUTHORS.md ├── LICENSE ├── README.md ├── __mocks__ ├── fileMock.js └── styleMock.js ├── eslint.config.js ├── i18next-parser.config.js ├── index.html ├── package.json ├── postbuild.sh ├── public ├── OpenSans-Bold.woff ├── OpenSans-Light.woff ├── favicon.ico ├── locales │ ├── README.md │ ├── ar │ │ └── translation.json │ ├── ca │ │ └── translation.json │ ├── cs │ │ └── translation.json │ ├── de │ │ └── translation.json │ ├── el │ │ └── translation.json │ ├── en │ │ └── translation.json │ ├── es │ │ └── translation.json │ ├── fr │ │ └── translation.json │ ├── hr │ │ └── translation.json │ ├── it │ │ └── translation.json │ ├── ko │ │ └── translation.json │ ├── nb_NO │ │ └── translation.json │ ├── nl │ │ └── translation.json │ ├── pl │ │ └── translation.json │ ├── pt │ │ └── translation.json │ ├── pt_BR │ │ └── translation.json │ ├── ru │ │ └── translation.json │ ├── sl │ │ └── translation.json │ ├── sr │ │ └── translation.json │ ├── sv │ │ └── translation.json │ ├── ta │ │ └── translation.json │ ├── tr │ │ └── translation.json │ ├── uk │ │ └── translation.json │ ├── uz │ │ └── translation.json │ ├── vi │ │ └── translation.json │ ├── zh_Hans │ │ └── translation.json │ └── zh_Hant │ │ └── translation.json ├── logo192.png ├── logo512.png ├── manifest.json ├── muscles │ ├── SOURCES │ ├── main │ │ ├── muscle-1.svg │ │ ├── muscle-10.svg │ │ ├── muscle-11.svg │ │ ├── muscle-12.svg │ │ ├── muscle-13.svg │ │ ├── muscle-14.svg │ │ ├── muscle-15.svg │ │ ├── muscle-16.svg │ │ ├── muscle-2.svg │ │ ├── muscle-3.svg │ │ ├── muscle-4.svg │ │ ├── muscle-5.svg │ │ ├── muscle-6.svg │ │ ├── muscle-7.svg │ │ ├── muscle-8.svg │ │ └── muscle-9.svg │ ├── muscular_system_back.svg │ ├── muscular_system_front.svg │ └── secondary │ │ ├── muscle-1.svg │ │ ├── muscle-10.svg │ │ ├── muscle-11.svg │ │ ├── muscle-12.svg │ │ ├── muscle-13.svg │ │ ├── muscle-14.svg │ │ ├── muscle-15.svg │ │ ├── muscle-16.svg │ │ ├── muscle-2.svg │ │ ├── muscle-3.svg │ │ ├── muscle-4.svg │ │ ├── muscle-5.svg │ │ ├── muscle-6.svg │ │ ├── muscle-7.svg │ │ ├── muscle-8.svg │ │ └── muscle-9.svg └── robots.txt ├── src ├── .sass-cache │ └── 166f82406a0edab65b7f8fd8256819ee2f34bebf │ │ └── App.module.scssc ├── @types │ └── i18next.d.ts ├── App.tsx ├── assets │ └── images │ │ ├── discord-logo.svg │ │ ├── get-it-on-google-play.svg │ │ ├── logo.png │ │ ├── twitter-logo-50.png │ │ └── twitter.svg ├── components │ ├── BodyWeight │ │ ├── Form │ │ │ ├── WeightForm.test.tsx │ │ │ └── WeightForm.tsx │ │ ├── Table │ │ │ ├── ActionButton │ │ │ │ ├── ActionButton.test.tsx │ │ │ │ └── ActionButton.tsx │ │ │ ├── Fab │ │ │ │ └── Fab.tsx │ │ │ ├── index.test.tsx │ │ │ ├── index.tsx │ │ │ ├── table.module.css │ │ │ ├── table.module.css.map │ │ │ ├── table.module.scss │ │ │ ├── table.mosule.css │ │ │ └── table.mosule.css.map │ │ ├── TableDashboard │ │ │ ├── TableDashboard.test.tsx │ │ │ └── TableDashboard.tsx │ │ ├── WeightChart │ │ │ ├── index.test.tsx │ │ │ └── index.tsx │ │ ├── body_weight.module.css │ │ ├── body_weight.module.css.map │ │ ├── body_weight.module.scss │ │ ├── index.test.tsx │ │ ├── index.tsx │ │ ├── model.ts │ │ ├── queries │ │ │ └── index.ts │ │ ├── utils.test.ts │ │ ├── utils.ts │ │ └── widgets │ │ │ ├── FilterButtons.test.tsx │ │ │ ├── FilterButtons.tsx │ │ │ └── fab.tsx │ ├── Calendar │ │ ├── Components │ │ │ ├── CalendarComponent.test.tsx │ │ │ ├── CalendarComponent.tsx │ │ │ ├── CalendarDay.tsx │ │ │ ├── CalendarDayGrid.tsx │ │ │ ├── CalendarHeader.tsx │ │ │ ├── Entries.test.tsx │ │ │ └── Entries.tsx │ │ └── Helpers │ │ │ └── CalendarMeasurement.tsx │ ├── Carousel │ │ ├── carousel.module.css │ │ ├── carousel.module.css.map │ │ ├── carousel.module.scss │ │ └── index.tsx │ ├── Common │ │ └── forms │ │ │ ├── LicenseAuthor.tsx │ │ │ ├── LicenseAuthorUrl.tsx │ │ │ ├── LicenseDerivativeSourceUrl.tsx │ │ │ ├── LicenseObjectUrl.tsx │ │ │ ├── LicenseTitle.tsx │ │ │ └── WgerTextField.tsx │ ├── Core │ │ ├── LoadingWidget │ │ │ └── LoadingWidget.tsx │ │ ├── Modals │ │ │ ├── DeleteConfirmationModal.test.tsx │ │ │ ├── DeleteConfirmationModal.tsx │ │ │ ├── WgerModal.test.tsx │ │ │ └── WgerModal.tsx │ │ ├── Notifications │ │ │ ├── index.tsx │ │ │ ├── notifications.module.css │ │ │ ├── notifications.module.css.map │ │ │ └── notifications.module.scss │ │ ├── Tooltips │ │ │ └── LightToolTip.tsx │ │ └── Widgets │ │ │ ├── Container.tsx │ │ │ ├── FormError.tsx │ │ │ ├── OverviewEmpty.tsx │ │ │ └── RenderLoadingQuery.tsx │ ├── Dashboard │ │ ├── Dashboard.tsx │ │ ├── EmptyCard.tsx │ │ ├── NutritionCard.test.tsx │ │ ├── NutritionCard.tsx │ │ ├── RoutineCard.test.tsx │ │ ├── RoutineCard.tsx │ │ ├── WeightCard.test.tsx │ │ └── WeightCard.tsx │ ├── Exercises │ │ ├── Add │ │ │ ├── AddExerciseStepper.test.tsx │ │ │ ├── AddExerciseStepper.tsx │ │ │ ├── NotEnoughRights.tsx │ │ │ ├── Step1Basics.test.tsx │ │ │ ├── Step1Basics.tsx │ │ │ ├── Step2Variations.test.tsx │ │ │ ├── Step2Variations.tsx │ │ │ ├── Step3Description.test.tsx │ │ │ ├── Step3Description.tsx │ │ │ ├── Step4Translation.test.tsx │ │ │ ├── Step4Translations.tsx │ │ │ ├── Step5Images.test.tsx │ │ │ ├── Step5Images.tsx │ │ │ ├── Step6Overview.test.tsx │ │ │ └── Step6Overview.tsx │ │ ├── Detail │ │ │ ├── ExerciseDetailEdit.test.tsx │ │ │ ├── ExerciseDetailEdit.tsx │ │ │ ├── ExerciseDetailView.test.tsx │ │ │ ├── ExerciseDetailView.tsx │ │ │ ├── ExerciseDetails.test.tsx │ │ │ ├── ExerciseDetails.tsx │ │ │ ├── ExerciseImageAvatar.tsx │ │ │ ├── ExerciseImagePlaceholder.tsx │ │ │ ├── Head │ │ │ │ ├── ExerciseDeleteDialog.test.tsx │ │ │ │ ├── ExerciseDeleteDialog.tsx │ │ │ │ ├── head.module.css │ │ │ │ ├── head.module.css.map │ │ │ │ ├── head.module.scss │ │ │ │ └── index.tsx │ │ │ ├── OverviewCard.test.tsx │ │ │ ├── OverviewCard.tsx │ │ │ └── SideGallery │ │ │ │ ├── index.tsx │ │ │ │ ├── side_gallery.module.css │ │ │ │ ├── side_gallery.module.css.map │ │ │ │ └── side_gallery.module.scss │ │ ├── ExerciseOverview.test.tsx │ │ ├── ExerciseOverview.tsx │ │ ├── Filter │ │ │ ├── CategoryFilter.test.tsx │ │ │ ├── CategoryFilter.tsx │ │ │ ├── EquipmentFilter.test.tsx │ │ │ ├── EquipmentFilter.tsx │ │ │ ├── ExerciseFiltersContext.tsx │ │ │ ├── FilterDrawer.tsx │ │ │ ├── MuscleFilter.test.tsx │ │ │ ├── MuscleFilter.tsx │ │ │ ├── NameAutcompleter.tsx │ │ │ └── NameAutocompleter.test.tsx │ │ ├── Overview │ │ │ ├── ExerciseGrid.tsx │ │ │ ├── ExerciseGridLoadingSkeleton.test.tsx │ │ │ └── ExerciseGridLoadingSkeleton.tsx │ │ ├── forms │ │ │ ├── Category.test.tsx │ │ │ ├── Category.tsx │ │ │ ├── Equipment.test.tsx │ │ │ ├── Equipment.tsx │ │ │ ├── ExerciseAliases.tsx │ │ │ ├── ExerciseDescription.tsx │ │ │ ├── ExerciseEquipmentSelect.tsx │ │ │ ├── ExerciseName.tsx │ │ │ ├── ExerciseNotes.tsx │ │ │ ├── ExerciseSelect.tsx │ │ │ ├── ImageCard.tsx │ │ │ ├── ImageStyle.tsx │ │ │ ├── Muscle.test.tsx │ │ │ ├── Muscle.tsx │ │ │ ├── VideoCard.tsx │ │ │ └── yupValidators.ts │ │ ├── models │ │ │ ├── alias.ts │ │ │ ├── category.ts │ │ │ ├── equipment.ts │ │ │ ├── exercise.test.ts │ │ │ ├── exercise.ts │ │ │ ├── image.ts │ │ │ ├── language.ts │ │ │ ├── muscle.test.ts │ │ │ ├── muscle.ts │ │ │ ├── note.ts │ │ │ ├── translation.test.ts │ │ │ ├── translation.ts │ │ │ └── video.ts │ │ └── queries │ │ │ └── index.ts │ ├── Header │ │ ├── SubMenus │ │ │ ├── BodyWeightSubMenu.tsx │ │ │ ├── MeasurementsSubMenu.tsx │ │ │ ├── NutritionSubMenu.tsx │ │ │ ├── TrainingSubMenu.tsx │ │ │ └── WorkoutSubMenu.tsx │ │ └── index.tsx │ ├── Measurements │ │ ├── Screens │ │ │ ├── MeasurementCategoryDetail.test.tsx │ │ │ ├── MeasurementCategoryDetail.tsx │ │ │ ├── MeasurementCategoryOverview.test.tsx │ │ │ └── MeasurementCategoryOverview.tsx │ │ ├── models │ │ │ ├── Category.ts │ │ │ └── Entry.ts │ │ ├── queries │ │ │ └── index.ts │ │ └── widgets │ │ │ ├── CategoryDetailDataGrid.tsx │ │ │ ├── CategoryDetailDropdown.tsx │ │ │ ├── CategoryForm.test.tsx │ │ │ ├── CategoryForm.tsx │ │ │ ├── EntryForm.test.tsx │ │ │ ├── EntryForm.tsx │ │ │ ├── MeasurementChart.tsx │ │ │ └── fab.tsx │ ├── Muscles │ │ └── MuscleOverview.tsx │ ├── Nutrition │ │ ├── components │ │ │ ├── BmiCalculator.test.tsx │ │ │ ├── BmiCalculator.tsx │ │ │ ├── IngredientSearch.tsx │ │ │ ├── NutritionDiaryOverview.test.tsx │ │ │ ├── NutritionDiaryOverview.tsx │ │ │ ├── PlanDetail.test.tsx │ │ │ ├── PlanDetail.tsx │ │ │ ├── PlanOverview.test.tsx │ │ │ └── PlansOverview.tsx │ │ ├── helpers │ │ │ ├── nutritionalValues.test.ts │ │ │ └── nutritionalValues.ts │ │ ├── models │ │ │ ├── Ingredient.ts │ │ │ ├── IngredientImage.ts │ │ │ ├── diaryEntry.ts │ │ │ ├── meal.test.ts │ │ │ ├── meal.ts │ │ │ ├── mealItem.test.ts │ │ │ ├── mealItem.ts │ │ │ ├── nutritionalPlan.test.ts │ │ │ ├── nutritionalPlan.ts │ │ │ └── weightUnit.ts │ │ ├── queries │ │ │ ├── diary.ts │ │ │ ├── index.ts │ │ │ ├── ingredient.ts │ │ │ ├── meal.ts │ │ │ ├── mealItem.ts │ │ │ └── plan.ts │ │ └── widgets │ │ │ ├── DiaryDetail.tsx │ │ │ ├── DiaryOverview.tsx │ │ │ ├── Fab.tsx │ │ │ ├── IngredientAutcompleter.tsx │ │ │ ├── IngredientAutocompleter.test.tsx │ │ │ ├── IngredientDetailTable.tsx │ │ │ ├── LoggedPlannedNutritionalValuesTable.tsx │ │ │ ├── MealDetail.tsx │ │ │ ├── MealDetailDropdown.tsx │ │ │ ├── NutritionalValuesTable.tsx │ │ │ ├── PlanDetailDropdown.tsx │ │ │ ├── PlanSidebar.tsx │ │ │ ├── charts │ │ │ ├── LinearPlannedLoggedChart.tsx │ │ │ ├── MacrosPieChart.tsx │ │ │ ├── NutritionDiaryChart.tsx │ │ │ ├── NutritionalValuesDashboardChart.tsx │ │ │ └── NutritionalValuesPlannedLoggedChart.tsx │ │ │ └── forms │ │ │ ├── MealForm.test.tsx │ │ │ ├── MealForm.tsx │ │ │ ├── MealItemForm.test.tsx │ │ │ ├── MealItemForm.tsx │ │ │ ├── NutritionDiaryEntryForm.test.tsx │ │ │ ├── NutritionDiaryEntryForm.tsx │ │ │ ├── PlanForm.test.tsx │ │ │ └── PlanForm.tsx │ ├── User │ │ ├── models │ │ │ └── profile.ts │ │ └── queries │ │ │ ├── contribute.test.ts │ │ │ ├── contribute.ts │ │ │ ├── permission.ts │ │ │ └── profile.ts │ ├── WorkoutRoutines │ │ ├── Detail │ │ │ ├── RoutineAdd.tsx │ │ │ ├── RoutineDetail.test.tsx │ │ │ ├── RoutineDetail.tsx │ │ │ ├── RoutineDetailsTable.test.tsx │ │ │ ├── RoutineDetailsTable.tsx │ │ │ ├── RoutineEdit.test.tsx │ │ │ ├── RoutineEdit.tsx │ │ │ ├── SessionAdd.test.tsx │ │ │ ├── SessionAdd.tsx │ │ │ ├── SlotProgressionEdit.test.tsx │ │ │ ├── SlotProgressionEdit.tsx │ │ │ ├── TemplateDetail.test.tsx │ │ │ ├── TemplateDetail.tsx │ │ │ ├── WorkoutLogs.test.tsx │ │ │ ├── WorkoutLogs.tsx │ │ │ ├── WorkoutStats.test.tsx │ │ │ └── WorkoutStats.tsx │ │ ├── Overview │ │ │ ├── Fab.tsx │ │ │ ├── PrivateTemplateOverview.test.tsx │ │ │ ├── PrivateTemplateOverview.tsx │ │ │ ├── PublicTemplateOverview.test.tsx │ │ │ ├── PublicTemplateOverview.tsx │ │ │ ├── RoutineOverview.test.tsx │ │ │ └── RoutineOverview.tsx │ │ ├── models │ │ │ ├── BaseConfig.ts │ │ │ ├── Day.ts │ │ │ ├── LogStats.test.ts │ │ │ ├── LogStats.ts │ │ │ ├── RepetitionUnit.ts │ │ │ ├── Routine.test.ts │ │ │ ├── Routine.ts │ │ │ ├── RoutineDayData.ts │ │ │ ├── RoutineLogData.ts │ │ │ ├── SetConfigData.ts │ │ │ ├── Slot.ts │ │ │ ├── SlotData.ts │ │ │ ├── SlotEntry.test.ts │ │ │ ├── SlotEntry.ts │ │ │ ├── WeightUnit.ts │ │ │ ├── WorkoutLog.ts │ │ │ └── WorkoutSession.ts │ │ ├── queries │ │ │ ├── configs.ts │ │ │ ├── days.ts │ │ │ ├── index.ts │ │ │ ├── logs.ts │ │ │ ├── routines.ts │ │ │ ├── sessions.ts │ │ │ ├── slot_entries.ts │ │ │ ├── slots.ts │ │ │ └── units.ts │ │ └── widgets │ │ │ ├── DayDetails.test.tsx │ │ │ ├── DayDetails.tsx │ │ │ ├── LogWidgets.test.tsx │ │ │ ├── LogWidgets.tsx │ │ │ ├── RoutineDetailDropdown.test.tsx │ │ │ ├── RoutineDetailDropdown.tsx │ │ │ ├── RoutineDetailsCard.test.tsx │ │ │ ├── RoutineDetailsCard.tsx │ │ │ ├── RoutineStatistics.test.tsx │ │ │ ├── RoutineStatistics.tsx │ │ │ ├── SlotDetails.test.tsx │ │ │ ├── SlotDetails.tsx │ │ │ └── forms │ │ │ ├── BaseConfigForm.test.tsx │ │ │ ├── BaseConfigForm.tsx │ │ │ ├── DayForm.test.tsx │ │ │ ├── DayForm.tsx │ │ │ ├── ProgressionForm.test.tsx │ │ │ ├── ProgressionForm.tsx │ │ │ ├── RoutineForm.test.tsx │ │ │ ├── RoutineForm.tsx │ │ │ ├── RoutineTemplateForm.test.tsx │ │ │ ├── RoutineTemplateForm.tsx │ │ │ ├── SessionForm.test.tsx │ │ │ ├── SessionForm.tsx │ │ │ ├── SessionLogsForm.test.tsx │ │ │ ├── SessionLogsForm.tsx │ │ │ ├── SlotEntryForm.test.tsx │ │ │ ├── SlotEntryForm.tsx │ │ │ ├── SlotForm.test.tsx │ │ │ └── SlotForm.tsx │ └── index.ts ├── config.ts ├── i18n.ts ├── i18n.tsx ├── index.css ├── index.tsx ├── locales │ ├── README.md │ └── en │ │ └── translation.json ├── pages │ ├── About │ │ └── index.tsx │ ├── AddExercise │ │ └── index.tsx │ ├── AddWeight │ │ └── index.tsx │ ├── ApiPage │ │ └── index.tsx │ ├── Calendar │ │ └── index.tsx │ ├── CaloriesCalculator │ │ └── index.tsx │ ├── Equipments │ │ └── index.tsx │ ├── ExerciseDetails │ │ └── index.tsx │ ├── Gallery │ │ └── index.tsx │ ├── Ingrdedients │ │ └── index.tsx │ ├── Login │ │ └── index.tsx │ ├── Preferences │ │ └── index.tsx │ ├── PublicTemplate │ │ └── index.tsx │ ├── TemplatePage │ │ └── index.tsx │ ├── WeightOverview │ │ └── index.tsx │ ├── Workout │ │ └── index.tsx │ └── index.ts ├── permissions │ └── index.ts ├── reportWebVitals.ts ├── routes.tsx ├── services │ ├── alias.test.ts │ ├── alias.ts │ ├── base_config.test.ts │ ├── base_config.ts │ ├── category.test.ts │ ├── category.ts │ ├── config.test.ts │ ├── config.ts │ ├── day.test.ts │ ├── day.ts │ ├── equipment.test.ts │ ├── equipment.ts │ ├── exercise.test.ts │ ├── exercise.ts │ ├── exerciseTranslation.test.ts │ ├── exerciseTranslation.ts │ ├── image.test.ts │ ├── image.ts │ ├── index.ts │ ├── ingredient.test.ts │ ├── ingredient.ts │ ├── ingredientweightunit.ts │ ├── language.test.ts │ ├── language.ts │ ├── meal.ts │ ├── mealItem.ts │ ├── measurements.test.ts │ ├── measurements.ts │ ├── muscles.test.ts │ ├── muscles.ts │ ├── note.ts │ ├── nutritionalDiary.test.ts │ ├── nutritionalDiary.ts │ ├── nutritionalPlan.test.ts │ ├── nutritionalPlan.ts │ ├── permission.ts │ ├── permissions.test.ts │ ├── profile.test.ts │ ├── profile.ts │ ├── responseType.ts │ ├── routine.test.ts │ ├── routine.ts │ ├── session.test.ts │ ├── session.ts │ ├── slot.test.ts │ ├── slot.ts │ ├── slot_entry.ts │ ├── variation.test.ts │ ├── variation.ts │ ├── video.test.ts │ ├── video.ts │ ├── weight.test.ts │ ├── weight.ts │ ├── workoutLogs.test.ts │ ├── workoutLogs.ts │ └── workoutUnits.ts ├── setupTests.ts ├── state │ ├── exerciseSubmissionReducer.ts │ ├── exerciseSubmissionState.tsx │ ├── index.ts │ ├── notificationReducer.ts │ ├── notificationState.tsx │ └── stateTypes.ts ├── tests │ ├── api │ │ └── ingredientSearch.ts │ ├── exerciseTestdata.ts │ ├── exercises │ │ └── searchResponse.ts │ ├── ingredientTestdata.ts │ ├── measurementsTestData.ts │ ├── nutritionDiaryTestdata.ts │ ├── nutritionTestdata.ts │ ├── queryClient │ │ └── index.ts │ ├── responseApi.ts │ ├── setup.ts │ ├── slotEntryApiResponse.ts │ ├── userTestdata.ts │ ├── weight │ │ └── testData.ts │ ├── workoutLogsRoutinesTestData.ts │ ├── workoutRoutinesTestData.ts │ └── workoutStatisticsTestData.ts ├── theme.ts ├── types.ts └── utils │ ├── Adapter.ts │ ├── colors.test.ts │ ├── colors.ts │ ├── consts.ts │ ├── date.test.ts │ ├── date.ts │ ├── forms.test.ts │ ├── forms.ts │ ├── numbers.test.ts │ ├── numbers.ts │ ├── requests.test.ts │ ├── requests.ts │ ├── strings.test.ts │ ├── strings.ts │ ├── url.test.ts │ └── url.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.ts └── yarn.lock /.env.TEMPLATE: -------------------------------------------------------------------------------- 1 | # 2 | # Template environment file 3 | # 4 | # Copy to .env.development 5 | # 6 | 7 | # Enter your own values here if you don't want to use the test server. 8 | # Feel free to edit and change things, the db is reset daily. 9 | 10 | # wger instance 11 | VITE_API_SERVER=https://wger-master.rge.uber.space 12 | 13 | # API token for the user 14 | VITE_API_KEY=31e2ea0322c07b9df583a9b6d1e794f7139e78d4 -------------------------------------------------------------------------------- /.env.production: -------------------------------------------------------------------------------- 1 | # During production the wger instance is the same server from which the scripts 2 | # are executed. 3 | VITE_API_SERVER= 4 | 5 | # Don't generate source maps for production 6 | GENERATE_SOURCEMAP=false -------------------------------------------------------------------------------- /.github/contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing to wger 2 | 3 | 🎉 Thanks for showing interest in contributing! 🎉 4 | 5 | We have centralized the documentation for contributing to wger in the online 6 | docs, especially for non-code contributions such as documentation, translations, 7 | etc.: 8 | 9 | 10 | 11 | ## Questions 12 | 13 | Are you just using the software and have a question or improvement? Let us know! 14 | 15 | * Discord: 16 | * Mastodon: 17 | 18 | ## Issues 19 | 20 | If you run into a bug describe the problem as well as you can. 21 | 22 | - Steps and any useful information to reproduce the issue 23 | - If you use our server, the time when this happened, we might be able to get some 24 | information from the logs 25 | - Environment details (app type: web / mobile, installation method, OS, etc.) 26 | - Screenshots or logs if applicable 27 | - Git SHA1 if installed from source 28 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | 13 | # Allow up to 10 open pull requests 14 | open-pull-requests-limit: 10 15 | 16 | # Group dependencies 17 | groups: 18 | mui: 19 | patterns: 20 | - "@mui*" 21 | - ^@mui/.* 22 | - ^mui$ 23 | update-types: 24 | - "minor" 25 | - "patch" 26 | 27 | types: 28 | patterns: 29 | - "@types*" 30 | - ^@types/.* 31 | - ^types$ 32 | update-types: 33 | - "minor" 34 | - "patch" 35 | 36 | - package-ecosystem: "github-actions" 37 | directory: "/" 38 | schedule: 39 | interval: "daily" -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Proposed Changes 2 | 3 | - 4 | - 5 | 6 | ## Related Issue(s) 7 | 8 | If applicable, please link to any related issues (`Closes #123`, 9 | `Closes wger-project/other-repo#123`, `See also #123`, etc.) 10 | 11 | ## Please check that the PR fulfills these requirements 12 | 13 | - [ ] Tests for the changes have been added (for bug fixes / features) 14 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 2 | changelog: 3 | categories: 4 | - title: Features 5 | labels: 6 | - '*' 7 | exclude: 8 | labels: 9 | - dependencies 10 | - title: Dependencies 11 | labels: 12 | - dependencies -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version: 20 19 | cache: 'yarn' 20 | - name: Install modules 21 | run: yarn 22 | 23 | - name: Run tests 24 | run: yarn test --coverage --collectCoverageFrom='!src/pages/**/*.tsx' 25 | 26 | - name: Coveralls 27 | uses: coverallsapp/github-action@master 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint the code 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: 20 20 | cache: 'yarn' 21 | 22 | - name: Install modules 23 | run: yarn 24 | 25 | - name: Run ESLint 26 | run: yarn lint:quiet 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # IntelliJ related 12 | *.iml 13 | *.ipr 14 | *.iws 15 | .idea/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | .env.local 23 | .env.development.local 24 | .env.test.local 25 | .env.production.local 26 | 27 | npm-debug.log* 28 | yarn-debug.log* 29 | yarn-error.log* 30 | 31 | # Configuration for the application 32 | /.env.development 33 | /.env 34 | -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- 1 | module.exports = 'test-file-stub' -------------------------------------------------------------------------------- /__mocks__/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {} -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import eslint from '@eslint/js'; 3 | import tseslint from 'typescript-eslint'; 4 | import reactPlugin from 'eslint-plugin-react'; 5 | import reactHooksPlugin from 'eslint-plugin-react-hooks'; 6 | import importPlugin from 'eslint-plugin-import'; 7 | // import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; 8 | 9 | export default tseslint.config( 10 | eslint.configs.recommended, 11 | tseslint.configs.recommended, 12 | { 13 | files: ['**/*.{js,jsx,ts,tsx}'], 14 | plugins: { 15 | 'react': reactPlugin, 16 | 'react-hooks': reactHooksPlugin, 17 | 'import': importPlugin, 18 | //'jsx-a11y': jsxA11yPlugin 19 | }, 20 | rules: { 21 | 'react-hooks/rules-of-hooks': 'error', 22 | 'react-hooks/exhaustive-deps': 'warn', 23 | }, 24 | settings: { 25 | react: { 26 | version: 'detect' 27 | } 28 | } 29 | }, 30 | 31 | { 32 | files: ['**/*.ts', '**/*.tsx'], 33 | rules: { 34 | 'semi': ['error', 'always'], 35 | 'camelcase': ['warn'], 36 | "@typescript-eslint/no-unused-vars": ["warn"], 37 | "@typescript-eslint/no-explicit-any": ["warn"], 38 | "@typescript-eslint/no-non-null-asserted-optional-chain": ["warn"], 39 | "@typescript-eslint/no-unsafe-function-type": ["warn"], 40 | "@typescript-eslint/ban-ts-comment": [ 41 | "warn", // changed to warning 42 | { 43 | "ts-ignore": "allow-with-description", 44 | "ts-expect-error": "allow-with-description", 45 | "minimumDescriptionLength": 10 46 | } 47 | ], 48 | 49 | } 50 | }, 51 | { 52 | ignores: ['build/**/*'] 53 | } 54 | ); -------------------------------------------------------------------------------- /i18next-parser.config.js: -------------------------------------------------------------------------------- 1 | // See https://github.com/i18next/i18next-parser for more options 2 | 3 | export default { 4 | contextSeparator: '_', 5 | 6 | // Only set English here so only the english file gets updated, the rest happens on weblate 7 | locales: ['en'], 8 | 9 | input: ['src/**/*.{ts,tsx}'], 10 | output: 'public/locales/$LOCALE/$NAMESPACE.json' 11 | } -------------------------------------------------------------------------------- /postbuild.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script assumes the wger backend is in a folder called "server" 4 | STATIC_FOLDER=../server/wger/core/static/react 5 | 6 | RED='\033[0;31m' 7 | GREEN='\033[0;32m' 8 | NC='\033[0m' 9 | 10 | if [ -d "$STATIC_FOLDER" ]; then 11 | cp build/assets/index-*.js $STATIC_FOLDER/main.js 12 | cp build/assets/index-*.css $STATIC_FOLDER/main.css 13 | cp -r build/locales $STATIC_FOLDER/ 14 | cp -r build/muscles $STATIC_FOLDER/ 15 | echo -e "${GREEN}*** SUCCESS ***: Build files copied to django static folder: ${STATIC_FOLDER} ${NC}" 16 | else 17 | echo -e "${RED}*** ERROR ***: Django static folder ${STATIC_FOLDER} not found" 18 | echo -e "Build files could not be copied${NC}" 19 | fi 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/OpenSans-Bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/public/OpenSans-Bold.woff -------------------------------------------------------------------------------- /public/OpenSans-Light.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/public/OpenSans-Light.woff -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/public/favicon.ico -------------------------------------------------------------------------------- /public/locales/README.md: -------------------------------------------------------------------------------- 1 | i18n README 2 | =========== 3 | 4 | To extract new strings `yarn i18n`, actual translation occurs in weblate: 5 | 6 | 7 | More info: 8 | 9 | Please note that the contents of i18n.tsx contains the values of the exercise categories, equipment, etc. and is 10 | autogenerated on the wger repo by the extract-i18n.py script (python manage.py extract-i18n). -------------------------------------------------------------------------------- /public/locales/ca/translation.json: -------------------------------------------------------------------------------- 1 | { 2 | "add": "Afegeix", 3 | "addEntry": "Afegeix entrada", 4 | "close": "Tanca", 5 | "currentWeight": "Pes actual", 6 | "date": "Data", 7 | "days": "Dies", 8 | "delete": "Esborra", 9 | "difference": "Diferència", 10 | "edit": "Edita", 11 | "nutritionalPlan": "Pla nutricional", 12 | "submit": "Tramet", 13 | "weight": "Pes", 14 | "workout": "Entrenament", 15 | "language": "Llenguatge", 16 | "images": "Imatges", 17 | "translation": "Traducció", 18 | "timeOfDay": "Hora", 19 | "editName": "Editar {{name}}", 20 | "start": "Inici", 21 | "licenses": { 22 | "authors": "Autor(s)", 23 | "authorProfile": "Enllaç al lloc web de l'autor, si n'hi ha", 24 | "derivativeSourceUrl": "Enllaç a la font original, si es tracta d'una obra derivada" 25 | }, 26 | "comment": "Comentari", 27 | "deleteConfirmation": "Segur que vols esborrar \"{{name}}\"?", 28 | "lastYear": "Any passat", 29 | "lastHalfYear": "Darrers 6 mesos", 30 | "lastMonth": "Darrer mes", 31 | "height": "Alçada", 32 | "cm": "cm", 33 | "all": "Tot", 34 | "lastWeek": "Darrera setmana" 35 | } 36 | -------------------------------------------------------------------------------- /public/locales/sl/translation.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "wger React components", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/muscles/SOURCES: -------------------------------------------------------------------------------- 1 | https://commons.wikimedia.org/wiki/File:Muscular_system-back.svg 2 | https://commons.wikimedia.org/wiki/File:Muscular_system.svg 3 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/.sass-cache/166f82406a0edab65b7f8fd8256819ee2f34bebf/App.module.scssc: -------------------------------------------------------------------------------- 1 | 3.7.4 2 | da39a3ee5e6b4b0d3255bfef95601890afd80709 3 | o:Sass::Tree::RootNode :@children[:@filename0: @options{:@templateI":ET: 4 | @linei:@source_rangeo:Sass::Source::Range :@start_poso:Sass::Source::Position; i: @offseti: @end_poso;; i;i: 5 | @fileI"App.module.scss; 6 | T:@importero: Sass::Importers::Filesystem: 7 | @rootI"%/data/entwicklung/wger/react/src; 8 | T:@real_rootI"%/data/entwicklung/wger/react/src; 9 | T:@same_name_warningso:Set: 10 | @hash}F -------------------------------------------------------------------------------- /src/@types/i18next.d.ts: -------------------------------------------------------------------------------- 1 | import "i18next"; 2 | import { resources } from "i18n"; 3 | 4 | declare module "i18next" { 5 | 6 | // Extend CustomTypeOptions 7 | interface CustomTypeOptions { 8 | defaultNS: "common"; 9 | resources: { 10 | common: typeof resources.en.common; 11 | }; 12 | } 13 | } -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@mui/material/Grid'; 2 | import { Header, } from 'components'; 3 | import { Notifications } from 'components/Core/Notifications'; 4 | import React from 'react'; 5 | import { WgerRoutes } from "routes"; 6 | 7 | 8 | function App() { 9 | 10 | return ( 11 | ( 12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | ); 23 | } 24 | 25 | export default App; 26 | -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/assets/images/twitter-logo-50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/src/assets/images/twitter-logo-50.png -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/Fab/Fab.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Fab } from '@mui/material'; 3 | import AddIcon from '@mui/icons-material/Add'; 4 | import { WeightForm } from "components/BodyWeight/Form/WeightForm"; 5 | import { WgerModal } from "components/Core/Modals/WgerModal"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | 9 | export const WeightEntryFab = () => { 10 | 11 | const [t] = useTranslation(); 12 | const [openModal, setOpenModal] = React.useState(false); 13 | const handleOpenModal = () => setOpenModal(true); 14 | const handleCloseModal = () => setOpenModal(false); 15 | 16 | return ( 17 |
18 | theme.spacing(2), 26 | zIndex: 9, 27 | }}> 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | }; -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen } from '@testing-library/react'; 3 | import { WeightEntry } from "components/BodyWeight/model"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import { testQueryClient } from "tests/queryClient"; 6 | import { WeightTable } from './index'; 7 | 8 | describe("Body weight test", () => { 9 | test('renders without crashing', async () => { 10 | 11 | const weightsData: WeightEntry[] = [ 12 | new WeightEntry(new Date('2021/12/10'), 80, 1), 13 | new WeightEntry(new Date('2021/12/20'), 90, 2), 14 | ]; 15 | 16 | // since I used context api to provide state, also need it here 17 | render( 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | 26 | // Both weights are found in th document 27 | const weightRow = await screen.findByText('80'); 28 | expect(weightRow).toBeInTheDocument(); 29 | 30 | const weightRow2 = await screen.findByText("90"); 31 | expect(weightRow2).toBeInTheDocument(); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/table.module.css: -------------------------------------------------------------------------------- 1 | .table { 2 | overflow-x: visible; 3 | } 4 | 5 | /*# sourceMappingURL=table.module.css.map */ 6 | -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/table.module.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["table.module.scss"],"names":[],"mappings":"AAAA;EACI","file":"table.module.css"} -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/table.module.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wger-project/react/fcec7b75ad8aa7689b50f78a97d3b024e4921297/src/components/BodyWeight/Table/table.module.scss -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/table.mosule.css: -------------------------------------------------------------------------------- 1 | 2 | /*# sourceMappingURL=table.mosule.css.map */ 3 | -------------------------------------------------------------------------------- /src/components/BodyWeight/Table/table.mosule.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.mosule.css"} 2 | -------------------------------------------------------------------------------- /src/components/BodyWeight/TableDashboard/TableDashboard.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { WeightTableDashboard } from 'components/BodyWeight/TableDashboard/TableDashboard'; 4 | import { WeightEntry } from "components/BodyWeight/model"; 5 | 6 | describe("Body weight test", () => { 7 | test('renders without crashing', async () => { 8 | 9 | const weightsData: WeightEntry[] = [ 10 | new WeightEntry(new Date('2021/12/10'), 80, 1), 11 | new WeightEntry(new Date('2021/12/20'), 90, 2), 12 | ]; 13 | 14 | // since I used context api to provide state, also need it here 15 | render(); 16 | 17 | // Both weights are found in th document 18 | const weightRow = await screen.findByText('80'); 19 | expect(weightRow).toBeInTheDocument(); 20 | 21 | const weightRow2 = await screen.findByText("90"); 22 | expect(weightRow2).toBeInTheDocument(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/BodyWeight/body_weight.module.css: -------------------------------------------------------------------------------- 1 | .root { 2 | margin: 2rem; 3 | } 4 | .root .chart { 5 | margin: 2rem 0; 6 | } 7 | 8 | /*# sourceMappingURL=body_weight.module.css.map */ 9 | -------------------------------------------------------------------------------- /src/components/BodyWeight/body_weight.module.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["body_weight.module.scss"],"names":[],"mappings":"AAAA;EACI;;AAGA;EACI","file":"body_weight.module.css"} -------------------------------------------------------------------------------- /src/components/BodyWeight/body_weight.module.scss: -------------------------------------------------------------------------------- 1 | .root { 2 | margin: 2rem; 3 | // width: 60%; 4 | 5 | .chart { 6 | margin: 2rem 0; 7 | } 8 | } -------------------------------------------------------------------------------- /src/components/BodyWeight/index.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Stack } from "@mui/material"; 2 | import { useBodyWeightQuery } from "components/BodyWeight/queries"; 3 | import { WeightTable } from "components/BodyWeight/Table"; 4 | import { WeightChart } from "components/BodyWeight/WeightChart"; 5 | import { AddBodyWeightEntryFab } from "components/BodyWeight/widgets/fab"; 6 | import { FilterButtons, FilterType } from "components/BodyWeight/widgets/FilterButtons"; 7 | import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget"; 8 | import { WgerContainerRightSidebar } from "components/Core/Widgets/Container"; 9 | import { OverviewEmpty } from "components/Core/Widgets/OverviewEmpty"; 10 | import { useState } from "react"; 11 | import { useTranslation } from "react-i18next"; 12 | 13 | 14 | export const BodyWeight = () => { 15 | const [t] = useTranslation(); 16 | const [filter, setFilter] = useState('lastYear'); 17 | const weightyQuery = useBodyWeightQuery(filter); 18 | const handleFilterChange = (newFilter: FilterType) => { 19 | setFilter(newFilter); 20 | }; 21 | 22 | if (weightyQuery.isLoading) { 23 | return ; 24 | } 25 | 26 | return 29 | 30 | {weightyQuery.data!.length === 0 && } 31 | {weightyQuery.data!.length !== 0 && <> 32 | 33 | 34 | 35 | } 36 | 37 | } 38 | fab={} 39 | />; 40 | }; -------------------------------------------------------------------------------- /src/components/BodyWeight/model.ts: -------------------------------------------------------------------------------- 1 | import { dateToYYYYMMDD } from "utils/date"; 2 | import { Adapter } from "utils/Adapter"; 3 | 4 | export class WeightEntry { 5 | 6 | constructor( 7 | public date: Date, 8 | public weight: number, 9 | public id?: number, 10 | ) { 11 | } 12 | } 13 | 14 | export class WeightAdapter implements Adapter { 15 | fromJson(item: any): WeightEntry { 16 | return new WeightEntry( 17 | new Date(item.date), 18 | parseFloat(item.weight), 19 | item.id, 20 | ); 21 | } 22 | 23 | toJson(item: WeightEntry) { 24 | return { 25 | id: item.id, 26 | date: dateToYYYYMMDD(item.date), 27 | weight: item.weight, 28 | }; 29 | } 30 | } -------------------------------------------------------------------------------- /src/components/BodyWeight/queries/index.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { WeightEntry } from "components/BodyWeight/model"; 3 | import { createWeight, deleteWeight, getWeights, updateWeight, } from "services"; 4 | import { QueryKey, } from "utils/consts"; 5 | import { FilterType } from "../widgets/FilterButtons"; 6 | 7 | 8 | export function useBodyWeightQuery(filter: FilterType = 'lastWeek') { 9 | return useQuery({ 10 | queryKey: [QueryKey.BODY_WEIGHT, filter], 11 | queryFn: () => getWeights(filter), 12 | }); 13 | } 14 | 15 | export const useDeleteWeightEntryQuery = () => { 16 | const queryClient = useQueryClient(); 17 | 18 | return useMutation({ 19 | mutationFn: (id: number) => deleteWeight(id), 20 | onSuccess: () => queryClient.invalidateQueries({ 21 | queryKey: [QueryKey.BODY_WEIGHT] 22 | }) 23 | }); 24 | }; 25 | 26 | 27 | export const useAddWeightEntryQuery = () => { 28 | const queryClient = useQueryClient(); 29 | 30 | return useMutation({ 31 | mutationFn: (data: WeightEntry) => createWeight(data), 32 | onError: (error: any) => { 33 | console.log(error); 34 | }, 35 | onSuccess: () => queryClient.invalidateQueries({ 36 | queryKey: [QueryKey.BODY_WEIGHT,] 37 | }) 38 | }); 39 | }; 40 | 41 | export const useEditWeightEntryQuery = () => { 42 | const queryClient = useQueryClient(); 43 | 44 | return useMutation({ 45 | mutationFn: (data: WeightEntry) => updateWeight(data), 46 | onSuccess: () => { 47 | queryClient.invalidateQueries({ 48 | queryKey: [QueryKey.BODY_WEIGHT,] 49 | }); 50 | } 51 | }); 52 | }; -------------------------------------------------------------------------------- /src/components/BodyWeight/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { processWeight } from "./utils"; 2 | import { WeightEntry } from "components/BodyWeight/model"; 3 | 4 | describe("process_weight tests", () => { 5 | test('process some weight entries', () => { 6 | 7 | // Arrange 8 | // 9 | const entry1 = new WeightEntry(new Date('2021-12-10'), 80, 1); 10 | const entry2 = new WeightEntry(new Date('2021-12-20'), 95, 2); 11 | const entry3 = new WeightEntry(new Date('2021-12-25'), 70, 3); 12 | 13 | // Act 14 | // 15 | const result = processWeight([ 16 | entry1, 17 | entry2, 18 | entry3, 19 | ]); 20 | 21 | // Assert 22 | // 23 | expect(result[0]).toStrictEqual({ 24 | entry: entry1, 25 | change: 0, 26 | days: 0 27 | }); 28 | expect(result[1]).toStrictEqual({ 29 | entry: entry2, 30 | change: 15, 31 | days: 10 32 | }); 33 | expect(result[2]).toStrictEqual({ 34 | entry: entry3, 35 | change: -25, 36 | days: 5 37 | }); 38 | }); 39 | 40 | test('processing an empty weight entry list doesnt crash', () => { 41 | const result = processWeight([]); 42 | expect(result).toStrictEqual([]); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/components/BodyWeight/utils.ts: -------------------------------------------------------------------------------- 1 | import { WeightEntry } from "components/BodyWeight/model"; 2 | 3 | export const processWeight = (weights: WeightEntry[]) => { 4 | // go through weights, referencing the same weights to have days and weight changes 5 | return weights.map((entry, i) => { 6 | 7 | // since there is no day before day 1, changes are 0 8 | if (i === 0) { 9 | return { 10 | entry, 11 | change: 0, 12 | days: Math.abs(entry.date.getTime() - entry.date.getTime()) / (1000 * 60 * 60 * 24) 13 | }; 14 | } 15 | 16 | return { 17 | entry, 18 | change: weights[i].weight - weights[i - 1].weight, 19 | days: Math.abs(entry.date.getTime() - weights[i - 1].date.getTime()) / (1000 * 60 * 60 * 24) 20 | }; 21 | }); 22 | }; -------------------------------------------------------------------------------- /src/components/BodyWeight/widgets/fab.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from "@mui/icons-material/Add"; 2 | import { Fab } from "@mui/material"; 3 | import { WeightForm } from "components/BodyWeight/Form/WeightForm"; 4 | import { WgerModal } from "components/Core/Modals/WgerModal"; 5 | import { useState } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | export const AddBodyWeightEntryFab = () => { 9 | const [t] = useTranslation(); 10 | const [openModal, setOpenModal] = useState(false); 11 | const handleOpenModal = () => setOpenModal(true); 12 | const handleCloseModal = () => setOpenModal(false); 13 | 14 | return ( 15 |
16 | theme.spacing(2), 24 | zIndex: 9, 25 | }}> 26 | 27 | 28 | 29 | 30 | 31 |
32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /src/components/Calendar/Components/CalendarHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button, Typography } from '@mui/material'; 3 | import {useTranslation} from "react-i18next"; 4 | 5 | interface CalendarHeaderProps { 6 | currentMonth: number; 7 | currentYear: number; 8 | onPrevMonth: () => void; 9 | onNextMonth: () => void; 10 | } 11 | 12 | const CalendarHeader: React.FC = ({currentMonth, currentYear, onPrevMonth, onNextMonth,}) => { 13 | const { i18n } = useTranslation(); 14 | const months = Array.from({ length: 12 }, (_, index) => 15 | new Date(2024, index, 1).toLocaleString(i18n.language, { month: "long" }) 16 | ); 17 | 18 | return ( 19 |
26 | 29 | 30 | {months[currentMonth]} {currentYear} 31 | 32 | 35 |
36 | ); 37 | }; 38 | 39 | export default CalendarHeader; -------------------------------------------------------------------------------- /src/components/Calendar/Helpers/CalendarMeasurement.tsx: -------------------------------------------------------------------------------- 1 | export class CalendarMeasurement { 2 | 3 | constructor( 4 | public name: string, 5 | public unit: string, 6 | public value: number, 7 | public date: Date, 8 | ) { 9 | } 10 | } -------------------------------------------------------------------------------- /src/components/Carousel/carousel.module.css: -------------------------------------------------------------------------------- 1 | .carousel { 2 | overflow: hidden; 3 | margin: 2rem 0; 4 | } 5 | @media screen and (min-width: 700px) { 6 | .carousel { 7 | display: none; 8 | } 9 | } 10 | .carousel .inner { 11 | white-space: nowrap; 12 | transition: transform 0.3s; 13 | } 14 | 15 | .carousel_item { 16 | display: inline-flex; 17 | align-items: center; 18 | justify-content: center; 19 | height: 200px; 20 | background-color: #7795bd; 21 | color: #fff; 22 | border-radius: 10px; 23 | } 24 | .indicators { 25 | display: flex; 26 | justify-content: center; 27 | } 28 | 29 | .indicators > button { 30 | margin: 5px; 31 | border: none; 32 | background-color: #2a4c7d; 33 | color: #fff; 34 | border-radius: 50%; 35 | cursor: pointer; 36 | } 37 | 38 | .indicators > button.active { 39 | background-color: green; 40 | color: #fff; 41 | } 42 | 43 | /*# sourceMappingURL=carousel.module.css.map */ 44 | -------------------------------------------------------------------------------- /src/components/Carousel/carousel.module.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["carousel.module.scss"],"names":[],"mappings":"AAAA;EACI;EACA;;AAEA;EAJJ;IAKQ;;;AAGJ;EACI;EACA;;;AAIN;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAOF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA","file":"carousel.module.css"} -------------------------------------------------------------------------------- /src/components/Carousel/carousel.module.scss: -------------------------------------------------------------------------------- 1 | .carousel { 2 | overflow: hidden; 3 | margin: 2rem 0; 4 | 5 | @media screen and (min-width: 700px) { 6 | display: none; 7 | } 8 | 9 | .inner { 10 | white-space: nowrap; 11 | transition: transform 0.3s; 12 | } 13 | } 14 | 15 | .carousel_item { 16 | display: inline-flex; 17 | align-items: center; 18 | justify-content: center; 19 | height: 200px; 20 | background-color: rgb(119, 149, 189); 21 | color: #fff; 22 | border-radius: 10px; 23 | 24 | img { 25 | 26 | } 27 | } 28 | 29 | .indicators { 30 | display: flex; 31 | justify-content: center; 32 | } 33 | 34 | .indicators > button { 35 | margin: 5px; 36 | border: none; 37 | background-color: #2a4c7d; 38 | color: #fff; 39 | border-radius: 50%; 40 | cursor: pointer; 41 | } 42 | 43 | .indicators > button.active { 44 | background-color: green; 45 | color: #fff; 46 | } -------------------------------------------------------------------------------- /src/components/Common/forms/LicenseAuthor.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function LicenseAuthor(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 19 | } -------------------------------------------------------------------------------- /src/components/Common/forms/LicenseAuthorUrl.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function LicenseAuthorUrl(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 20 | } -------------------------------------------------------------------------------- /src/components/Common/forms/LicenseDerivativeSourceUrl.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function LicenseDerivativeSourceUrl(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 20 | } -------------------------------------------------------------------------------- /src/components/Common/forms/LicenseObjectUrl.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function LicenseObjectUrl(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 20 | } -------------------------------------------------------------------------------- /src/components/Common/forms/LicenseTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function LicenseTitle(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 19 | } -------------------------------------------------------------------------------- /src/components/Common/forms/WgerTextField.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@mui/material"; 2 | import { TextFieldProps } from "@mui/material/TextField/TextField"; 3 | import { useField } from "formik"; 4 | import React from "react"; 5 | 6 | interface WgerTextFieldProps { 7 | fieldName: string, 8 | title: string, 9 | fieldProps?: TextFieldProps, 10 | fullwidth?: boolean, 11 | } 12 | 13 | export function WgerTextField(props: WgerTextFieldProps) { 14 | const [field, meta] = useField(props.fieldName); 15 | const fullwidth = props.fullwidth ?? true; 16 | 17 | return ; 27 | } -------------------------------------------------------------------------------- /src/components/Core/LoadingWidget/LoadingWidget.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress, Stack } from "@mui/material"; 2 | import React from 'react'; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | export const LoadingWidget = () => { 6 | const [t] = useTranslation(); 7 | 8 | return ( 9 | {t('loading')} 10 | ); 11 | }; 12 | 13 | export const LoadingPlaceholder = () => 18 | 19 | ; 20 | 21 | 22 | export const LoadingProgressIcon = () => ; -------------------------------------------------------------------------------- /src/components/Core/Modals/WgerModal.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { WgerModal, WgerModalProps } from "components/Core/Modals/WgerModal"; 4 | 5 | describe("Test WgerModal component", () => { 6 | test('Renders title and subtitle when openFn is true', () => { 7 | 8 | // Arrange 9 | const props: WgerModalProps = { 10 | title: "Test title", 11 | subtitle: "Test subtitle", 12 | closeFn: jest.fn(), 13 | isOpen: true, 14 | children: null 15 | }; 16 | 17 | // Act 18 | render(

This is some content

); 19 | 20 | // Assert 21 | expect(screen.getByText('This is some content')).toBeInTheDocument(); 22 | expect(screen.getByText('Test title')).toBeInTheDocument(); 23 | expect(screen.getByText('Test subtitle')).toBeInTheDocument(); 24 | }); 25 | 26 | test('Doesnt render anything when isOpen is false', () => { 27 | 28 | // Arrange 29 | const props: WgerModalProps = { 30 | title: "Test title", 31 | subtitle: "Test subtitle", 32 | closeFn: jest.fn(), 33 | isOpen: false, 34 | children: null 35 | }; 36 | 37 | // Act 38 | render(

This is some content

); 39 | 40 | // Assert 41 | expect(screen.queryByText('This is some content')).toBeNull(); 42 | expect(screen.queryByText('Test title')).toBeNull(); 43 | expect(screen.queryByText('Test subtitle')).toBeNull(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/components/Core/Modals/WgerModal.tsx: -------------------------------------------------------------------------------- 1 | import CloseIcon from '@mui/icons-material/Close'; 2 | import { Card, CardActions, CardContent, CardHeader, Modal } from "@mui/material"; 3 | import React, { FunctionComponent } from 'react'; 4 | 5 | export interface WgerModalProps { 6 | title: string, 7 | subtitle?: string, 8 | isOpen: boolean, 9 | closeFn: any, 10 | children: any 11 | } 12 | 13 | export const WgerModal: FunctionComponent = ({ title, subtitle, isOpen, closeFn, children }) => { 14 | 15 | const style = { 16 | position: 'absolute' as const, 17 | top: '50%', 18 | left: '50%', 19 | transform: 'translate(-50%, -50%)', 20 | p: 2, 21 | minWidth: '400px' 22 | }; 23 | 24 | return ( 25 | 31 | 32 | } 36 | /> 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/components/Core/Notifications/notifications.module.css: -------------------------------------------------------------------------------- 1 | .notification { 2 | position: fixed; 3 | top: 5%; 4 | right: 5%; 5 | width: 20%; 6 | } 7 | 8 | /*# sourceMappingURL=notifications.module.css.map */ 9 | -------------------------------------------------------------------------------- /src/components/Core/Notifications/notifications.module.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sourceRoot":"","sources":["notifications.module.scss"],"names":[],"mappings":"AAAA;EACI;EACA;EACA;EACA","file":"notifications.module.css"} -------------------------------------------------------------------------------- /src/components/Core/Notifications/notifications.module.scss: -------------------------------------------------------------------------------- 1 | .notification { 2 | position: fixed; 3 | top: 5%; 4 | right: 5%; 5 | width: 20%; 6 | } -------------------------------------------------------------------------------- /src/components/Core/Tooltips/LightToolTip.tsx: -------------------------------------------------------------------------------- 1 | import { styled } from "@mui/material/styles"; 2 | import Tooltip, { tooltipClasses, TooltipProps } from "@mui/material/Tooltip"; 3 | import React from "react"; 4 | 5 | export const LightTooltip = styled(({ className, ...props }: TooltipProps) => ( 6 | 7 | ))(({ theme }) => ({ 8 | [`& .${tooltipClasses.tooltip}`]: { 9 | backgroundColor: 'rgb(245, 245, 245)', 10 | color: 'rgba(0, 0, 0, 0.87)', 11 | boxShadow: theme.shadows[1], 12 | fontSize: 11, 13 | }, 14 | })); -------------------------------------------------------------------------------- /src/components/Core/Widgets/FormError.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, AlertTitle } from "@mui/material"; 2 | import { collectValidationErrors } from "utils/forms"; 3 | 4 | export const FormQueryErrors = (props: { mutationQuery: any }) => { 5 | if (!props.mutationQuery?.isError) { 6 | return null; 7 | } 8 | 9 | return <> 10 | 11 | {props.mutationQuery.error?.message} 12 |
    13 | {/* TODO: how to properly type this */} 14 | {collectValidationErrors((props.mutationQuery.error as any).response?.data).map((error, index) => 15 |
  • {error}
  • 16 | )} 17 |
18 |
19 | ; 20 | }; -------------------------------------------------------------------------------- /src/components/Core/Widgets/OverviewEmpty.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Typography } from "@mui/material"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export const OverviewEmpty = (props: { height?: string }) => { 5 | const [t] = useTranslation(); 6 | 7 | const height = props.height ? props.height : "50vh"; 8 | 9 | return <> 10 | 18 | 19 | {t('nothingHereYet')} 20 | 21 | 22 | {t('nothingHereYetAction')} 23 | 24 | 25 | ; 26 | }; -------------------------------------------------------------------------------- /src/components/Core/Widgets/RenderLoadingQuery.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Box, Stack } from "@mui/material"; 2 | 3 | import { UseQueryResult } from "@tanstack/react-query"; 4 | import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget"; 5 | import React from "react"; 6 | 7 | 8 | export const RenderLoadingQuery = (props: { query: UseQueryResult, child: JSX.Element | boolean }) => { 9 | 10 | if (props.query.isLoading) { 11 | return ; 12 | } 13 | 14 | if (props.query.isError) { 15 | return 20 | {/*// @ts-ignore */} 21 | Error while fetching data: {props.query.error!.message} 22 | ; 23 | } 24 | 25 | if (props.query.isSuccess) { 26 | return props.child; 27 | } 28 | }; -------------------------------------------------------------------------------- /src/components/Dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@mui/material/Grid'; 2 | import { NutritionCard } from "components/Dashboard/NutritionCard"; 3 | import { RoutineCard } from "components/Dashboard/RoutineCard"; 4 | import { WeightCard } from "components/Dashboard/WeightCard"; 5 | import React from 'react'; 6 | 7 | export const Dashboard = () => { 8 | 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | }; -------------------------------------------------------------------------------- /src/components/Dashboard/EmptyCard.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card, CardActions, CardContent, CardHeader } from "@mui/material"; 2 | import { WgerModal } from "components/Core/Modals/WgerModal"; 3 | import { OverviewEmpty } from "components/Core/Widgets/OverviewEmpty"; 4 | import React, { ReactElement } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export const EmptyCard = (props: { 8 | title: string, 9 | link?: string, 10 | modalTitle?: string, 11 | modalContent?: ReactElement 12 | }) => { 13 | 14 | const [t] = useTranslation(); 15 | 16 | const [openModal, setOpenModal] = React.useState(false); 17 | const handleOpenModal = () => setOpenModal(true); 18 | const handleCloseModal = () => setOpenModal(false); 19 | 20 | 21 | const button = props.link !== undefined 22 | ? 27 | : ; 33 | 34 | return (<> 35 | 36 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | {button} 47 | 48 | 49 | 53 | {props.modalContent!} 54 | 55 | ); 56 | }; -------------------------------------------------------------------------------- /src/components/Exercises/Add/AddExerciseStepper.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 4 | import { AddExerciseStepper } from "components/Exercises/Add/AddExerciseStepper"; 5 | 6 | describe("Test the add-exercise stepper component", () => { 7 | 8 | 9 | test("Renders without crashing", () => { 10 | // Act 11 | const queryClient = new QueryClient(); 12 | render( 13 | 14 | 15 | 16 | ); 17 | 18 | // Assert 19 | expect(screen.getByText("exercises.step1HeaderBasics")).toBeInTheDocument(); 20 | expect(screen.getByText("exercises.variations")).toBeInTheDocument(); 21 | expect(screen.getByText("description")).toBeInTheDocument(); 22 | expect(screen.getByText("translation")).toBeInTheDocument(); 23 | expect(screen.getByText("images")).toBeInTheDocument(); 24 | expect(screen.getByText("overview")).toBeInTheDocument(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/Exercises/Add/NotEnoughRights.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Button, Container, Typography } from "@mui/material"; 3 | import { useTranslation } from "react-i18next"; 4 | import { useCanContributeExercises } from "components/User/queries/contribute"; 5 | import { MIN_ACCOUNT_AGE } from "utils/consts"; 6 | import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; 7 | 8 | export const NotEnoughRights = () => { 9 | const [t] = useTranslation(); 10 | const contributeQuery = useCanContributeExercises(); 11 | 12 | return ( 13 | 14 | {t('exercises.notEnoughRightsHeader')} 15 | 24 | {t('exercises.notEnoughRights', { days: MIN_ACCOUNT_AGE })} 25 | 26 | {!contributeQuery.anonymous && !contributeQuery.emailVerified 27 | && 28 | 33 | } 34 | 35 | 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/Exercises/Add/Step5Images.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen } from "@testing-library/react"; 3 | import { Step5Images } from "components/Exercises/Add/Step5Images"; 4 | import React from "react"; 5 | import { testQueryClient } from "tests/queryClient"; 6 | 7 | 8 | const mockOnContinue = jest.fn(); 9 | const mockOnBack = jest.fn(); 10 | 11 | describe("Test the add exercise step 5 component", () => { 12 | 13 | test("Smoketest", () => { 14 | // Act 15 | render( 16 | 17 | 21 | 22 | ); 23 | 24 | // Assert 25 | expect(screen.getByText('exercises.compatibleImagesCC')).toBeInTheDocument(); 26 | }); 27 | 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/Exercises/Detail/ExerciseImageAvatar.tsx: -------------------------------------------------------------------------------- 1 | import PhotoIcon from "@mui/icons-material/Photo"; 2 | import { Avatar } from "@mui/material"; 3 | import { ExerciseImage } from "components/Exercises/models/image"; 4 | import React from "react"; 5 | 6 | export const ExerciseImageAvatar = (props: { 7 | image: ExerciseImage | undefined, 8 | iconSize?: number | undefined, 9 | avatarSize?: number | undefined, 10 | }) => { 11 | const avatarSize = props.avatarSize || 40; 12 | const iconSize = props.iconSize || 40; 13 | 14 | return 18 | 19 | ; 20 | }; -------------------------------------------------------------------------------- /src/components/Exercises/Detail/ExerciseImagePlaceholder.tsx: -------------------------------------------------------------------------------- 1 | import PhotoIcon from "@mui/icons-material/Photo"; 2 | import { Box } from "@mui/material"; 3 | import React from "react"; 4 | 5 | export const ExerciseImagePlaceholder = (props: { 6 | backgroundColor?: string | undefined, 7 | iconColor?: string | undefined, 8 | height?: number | undefined, 9 | }) => { 10 | 11 | const backgroundColor = props.backgroundColor || "lightgray"; 12 | const iconColor = props.iconColor || "gray"; 13 | const height = props.height || 200; 14 | 15 | return 19 | 20 | ; 21 | }; -------------------------------------------------------------------------------- /src/components/Exercises/Detail/Head/head.module.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAAA,KAAM;EACJ,gBAAgB,EAAE,OAAkB;EACpC,OAAO,EAAE,SAAS;EAElB,sBAAiB;IACf,OAAO,EAAE,IAAI;IACb,eAAe,EAAE,aAAa;IAC9B,WAAW,EAAE,UAAU;IAMvB,8BAAQ;MACN,OAAO,EAAE,IAAI;MAEb,gCAAE;QACA,eAAe,EAAE,IAAI;QACrB,KAAK,EAAE,OAAO;QAEd,sCAAQ;UACN,KAAK,EAAE,OAAe;MAI1B,oCAAqC;QAZvC,8BAAQ;UAaJ,OAAO,EAAE,KAAK;IAIlB,oCAAc;MACZ,KAAK,EAAE,IAAI;MACX,MAAM,EAAE,IAAI;MACZ,MAAM,EAAE,OAAO;MACf,aAAa,EAAE,GAAG;MAGlB,0CAAQ;QACN,gBAAgB,EAAE,KAAK;MAGzB,oCAAqC;QAXvC,oCAAc;UAYV,OAAO,EAAE,IAAI;IAIjB,iCAAW;MACT,QAAQ,EAAE,QAAQ;MAElB,2CAAU;QACR,OAAO,EAAE,IAAI;QACb,WAAW,EAAE,QAAQ;QACrB,GAAG,EAAE,IAAI;QACT,aAAa,EAAE,MAAM;QAErB,oCAAqC;UANvC,2CAAU;YAON,gBAAgB,EAAE,OAAkB;YACpC,OAAO,EAAE,WAAW;YACpB,aAAa,EAAE,MAAM;MAIzB,gDAAe;QACb,QAAQ,EAAE,QAAQ;QAClB,GAAG,EAAE,IAAI;QACT,IAAI,EAAE,KAAK;QACX,gBAAgB,EAAE,IAAI;QACtB,OAAO,EAAE,MAAM;QACf,aAAa,EAAE,IAAI;QACnB,MAAM,EAAE,iBAA4B;QACpC,UAAU,EAAE,IAAI;QAEhB,qDAAK;UACH,cAAc,EAAE,SAAS;UACzB,KAAK,EAAE,OAAO;EAMtB,aAAQ;IACN,OAAO,EAAE,IAAI;IACb,eAAe,EAAE,aAAa;IAE9B,sBAAS;MACP,OAAO,EAAE,IAAI;MAEb,oCAAqC;QAHvC,sBAAS;UAIL,OAAO,EAAE,IAAI;UACb,UAAU,EAAE,QAAQ;UACpB,WAAW,EAAE,MAAM;UACnB,eAAe,EAAE,MAAM;MAIzB,0BAAI;QACF,KAAK,EAAE,IAAI;QACX,MAAM,EAAE,IAAI;QACZ,MAAM,EAAE,OAAO;;AAOvB,KAAM;EACJ,MAAM,EAAE,IAAI;EACZ,KAAK,EAAE,IAAI;EACX,MAAM,EAAE,OAAO", 4 | "sources": ["head.module.scss"], 5 | "names": [], 6 | "file": "head.module.css" 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Exercises/Detail/SideGallery/side_gallery.module.css: -------------------------------------------------------------------------------- 1 | .side_gallery { 2 | box-sizing: border-box; 3 | margin-top: 5rem; 4 | width: 500px; } 5 | @media screen and (max-width: 700px) { 6 | .side_gallery { 7 | display: none; } } 8 | .side_gallery .main_image { 9 | height: 40vh; 10 | overflow: hidden; 11 | border-radius: 0.2rem; } 12 | .side_gallery .secondary_images { 13 | display: grid; 14 | grid-template-columns: repeat(2, 1fr); 15 | grid-auto-rows: 150px; 16 | gap: 1rem; 17 | margin-top: 2rem; } 18 | .side_gallery .secondary_images .image_thumb { 19 | border-radius: 0.5rem; 20 | overflow: hidden; } 21 | .side_gallery img { 22 | width: 100%; 23 | height: 100%; 24 | object-fit: cover; } 25 | 26 | /*# sourceMappingURL=side_gallery.module.css.map */ 27 | -------------------------------------------------------------------------------- /src/components/Exercises/Detail/SideGallery/side_gallery.module.css.map: -------------------------------------------------------------------------------- 1 | { 2 | "version": 3, 3 | "mappings": "AAAA,aAAc;EACV,UAAU,EAAE,UAAU;EACtB,UAAU,EAAE,IAAI;EAChB,KAAK,EAAE,KAAK;EAEZ,oCAAqC;IALzC,aAAc;MAMN,OAAO,EAAE,IAAI;EAGjB,yBAAY;IACR,MAAM,EAAE,IAAI;IACZ,QAAQ,EAAE,MAAM;IAChB,aAAa,EAAE,MAAM;EAGzB,+BAAkB;IACd,OAAO,EAAE,IAAI;IACb,qBAAqB,EAAE,cAAc;IACrC,cAAc,EAAE,KAAK;IACrB,GAAG,EAAE,IAAI;IACT,UAAU,EAAE,IAAI;IAEhB,4CAAa;MACT,aAAa,EAAE,MAAM;MACrB,QAAQ,EAAE,MAAM;EAIxB,iBAAI;IACA,KAAK,EAAE,IAAI;IACX,MAAM,EAAE,IAAI;IACZ,UAAU,EAAE,KAAK", 4 | "sources": ["side_gallery.module.scss"], 5 | "names": [], 6 | "file": "side_gallery.module.css" 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Exercises/Detail/SideGallery/side_gallery.module.scss: -------------------------------------------------------------------------------- 1 | .side_gallery { 2 | box-sizing: border-box; 3 | margin-top: 5rem; 4 | width: 500px; 5 | 6 | @media screen and (max-width: 700px) { 7 | display: none; 8 | } 9 | 10 | .main_image { 11 | height: 40vh; 12 | overflow: hidden; 13 | border-radius: 0.2rem; 14 | } 15 | 16 | .secondary_images { 17 | display: grid; 18 | grid-template-columns: repeat(2, 1fr); 19 | grid-auto-rows: 150px; 20 | gap: 1rem; 21 | margin-top: 2rem; 22 | 23 | .image_thumb { 24 | border-radius: 0.5rem; 25 | overflow: hidden; 26 | } 27 | } 28 | 29 | img { 30 | width: 100%; 31 | height: 100%; 32 | object-fit: cover; 33 | } 34 | } -------------------------------------------------------------------------------- /src/components/Exercises/Filter/ExerciseFiltersContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import { Equipment } from '../models/equipment'; 3 | import { Muscle } from '../models/muscle'; 4 | import { Category } from '../models/category'; 5 | 6 | 7 | type ExerciseContext = { 8 | selectedEquipment: Equipment[]; 9 | setSelectedEquipment: (equipment: Equipment[]) => void; 10 | selectedMuscles: Muscle[]; 11 | setSelectedMuscles: (muscles: Muscle[]) => void; 12 | selectedCategories: Category[]; 13 | setSelectedCategories: (exercises: Category[]) => void; 14 | } 15 | 16 | export const ExerciseFiltersContext = createContext({} as unknown as ExerciseContext); 17 | -------------------------------------------------------------------------------- /src/components/Exercises/Filter/FilterDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Button, Divider, Drawer, Stack, Typography } from '@mui/material'; 3 | import FilterAltIcon from '@mui/icons-material/FilterAlt'; 4 | import CloseIcon from '@mui/icons-material/Close'; 5 | import { useTranslation } from 'react-i18next'; 6 | 7 | type FilterDrawerProps = { 8 | children: React.ReactNode; 9 | } 10 | 11 | export const FilterDrawer = ({ children }: FilterDrawerProps) => { 12 | const [t] = useTranslation(); 13 | const [open, setOpen] = useState(false); 14 | 15 | const toggleDrawer = (newOpen: boolean) => () => { 16 | setOpen(newOpen); 17 | }; 18 | 19 | return ( 20 | <> 21 | 24 | 25 | 26 | 27 | {t('filters')} 28 | 29 | 32 | 33 | 34 | {children} 35 | 36 | 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/Exercises/Overview/ExerciseGrid.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@mui/material/Grid'; 2 | import { OverviewCard } from "components/Exercises/Detail/OverviewCard"; 3 | import { Exercise } from "components/Exercises/models/exercise"; 4 | import { Language } from "components/Exercises/models/language"; 5 | import { useLanguageQuery } from "components/Exercises/queries"; 6 | import React from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | import { getLanguageByShortName } from "services"; 9 | 10 | type ExerciseGridProps = { 11 | exercises: Exercise[]; 12 | }; 13 | 14 | export const ExerciseGrid = ({ exercises }: ExerciseGridProps) => { 15 | 16 | const languageQuery = useLanguageQuery(); 17 | 18 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 19 | const [t, i18n] = useTranslation(); 20 | 21 | let currentUserLanguage: Language | undefined; 22 | if (languageQuery.isSuccess) { 23 | currentUserLanguage = getLanguageByShortName( 24 | i18n.language, 25 | languageQuery.data 26 | ); 27 | } 28 | 29 | return ( 30 | ( 31 | {exercises.map(b => ( 32 | 39 | 40 | 41 | ))} 42 | ) 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/components/Exercises/Overview/ExerciseGridLoadingSkeleton.test.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "@testing-library/react"; 3 | import { ExerciseGridSkeleton } from "components/Exercises/Overview/ExerciseGridLoadingSkeleton"; 4 | 5 | test('Renders without crashing', async () => { 6 | 7 | // Act 8 | render(); 9 | 10 | }); -------------------------------------------------------------------------------- /src/components/Exercises/Overview/ExerciseGridLoadingSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Card, CardContent, CardMedia, Skeleton, } from "@mui/material"; 2 | import Grid from '@mui/material/Grid'; 3 | import React from "react"; 4 | 5 | export const ExerciseGridSkeleton = () => { 6 | 7 | return ( 8 | ( 9 | {[...Array(21)].map((skeletonBase, idx) => ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ))} 24 | ) 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/components/Exercises/forms/Category.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, InputLabel, MenuItem, Select, SelectChangeEvent } from "@mui/material"; 2 | import { useCategoriesQuery } from "components/Exercises/queries"; 3 | import { useProfileQuery } from "components/User/queries/profile"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import { editExercise } from "services"; 7 | 8 | export function EditExerciseCategory(props: { exerciseId: number, initial: number }) { 9 | const { t } = useTranslation(); 10 | const [value, setValue] = React.useState(props.initial.toString()); 11 | const categoryQuery = useCategoriesQuery(); 12 | const profileQuery = useProfileQuery(); 13 | 14 | const handleOnChange = async (e: SelectChangeEvent) => { 15 | setValue(e.target.value); 16 | await editExercise(props.exerciseId, { 17 | category: parseInt(e.target.value), 18 | 19 | // eslint-disable-next-line camelcase 20 | license_author: profileQuery.data!.username 21 | }); 22 | }; 23 | 24 | return categoryQuery.isSuccess 25 | ? 26 | {t("category")} 39 | 40 | : null; 41 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/Equipment.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from "@mui/material"; 2 | import { useEquipmentQuery } from "components/Exercises/queries"; 3 | import { useProfileQuery } from "components/User/queries/profile"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | import { editExercise } from "services"; 7 | 8 | export function EditExerciseEquipment(props: { exerciseId: number, initial: number[] }) { 9 | const { t } = useTranslation(); 10 | const [value, setValue] = React.useState(props.initial); 11 | const equipmentQuery = useEquipmentQuery(); 12 | const profileQuery = useProfileQuery(); 13 | 14 | const handleOnChange = async (newValue: number[]) => { 15 | setValue(newValue); 16 | 17 | // eslint-disable-next-line camelcase 18 | await editExercise(props.exerciseId, { equipment: newValue, license_author: profileQuery.data!.username }); 19 | }; 20 | 21 | return equipmentQuery.isSuccess 22 | ? e.id)} 26 | getOptionLabel={option => equipmentQuery.data.find(e => e.id === option)!.translatedName} 27 | onChange={(event, newValue) => handleOnChange(newValue)} 28 | renderInput={params => ( 29 | 35 | )} 36 | /> 37 | : null; 38 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/ExerciseAliases.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, Chip, TextField } from "@mui/material"; 2 | import { useField } from "formik"; 3 | import React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | export function ExerciseAliases(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta, helpers] = useField(props.fieldName); 9 | 10 | return { 17 | helpers.setValue(newValue); 18 | }} 19 | renderTags={(value: readonly string[], getTagProps) => { 20 | return value.map((option: string, index: number) => ( 21 | 22 | )); 23 | }} 24 | onBlur={field.onBlur} 25 | renderInput={params => ( 26 | 35 | )} 36 | />; 37 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/ExerciseDescription.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useField } from "formik"; 3 | import { FormHelperText } from "@mui/material"; 4 | import { 5 | BtnBold, 6 | BtnBulletList, 7 | BtnItalic, 8 | BtnNumberedList, 9 | BtnRedo, 10 | BtnUnderline, 11 | BtnUndo, 12 | Editor, 13 | EditorProps, 14 | EditorProvider, 15 | Separator, 16 | Toolbar, 17 | } from "react-simple-wysiwyg"; 18 | 19 | export function ExerciseEditor(props: EditorProps) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | { /* */} 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | 41 | export function ExerciseDescription(props: { fieldName: string }) { 42 | 43 | const [field, meta, helpers] = useField(props.fieldName); 44 | 45 | return <> 46 |
47 | helpers.setValue(newValue.target.value)} 50 | /> 51 |
52 | {meta.touched 53 | && Boolean(meta.error) 54 | && {meta.error} 55 | } 56 | ; 57 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/ExerciseEquipmentSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from "@mui/material"; 2 | import { useField } from "formik"; 3 | import React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | export function ExerciseEquipmentSelect(props: { fieldName: string, options: any[] }) { 7 | const [t] = useTranslation(); 8 | const [field, meta, helpers] = useField(props.fieldName); 9 | 10 | return e.id)} 14 | getOptionLabel={option => props.options.find(e => e.id === option)!.translatedName} 15 | {...field} 16 | onChange={(event, newValue) => { 17 | helpers.setValue(newValue); 18 | }} 19 | renderInput={params => ( 20 | 26 | )} 27 | />; 28 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/ExerciseName.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { TextField } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function ExerciseName(props: { fieldName: string }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return ; 19 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/ExerciseSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useTranslation } from "react-i18next"; 2 | import { FormControl, FormHelperText, InputLabel, Select } from "@mui/material"; 3 | import React from "react"; 4 | import { useField } from "formik"; 5 | 6 | export function ExerciseSelect(props: { fieldName: string, options: any }) { 7 | const [t] = useTranslation(); 8 | const [field, meta] = useField(props.fieldName); 9 | 10 | return 11 | {t("category")} 12 | 21 | { 22 | meta.touched 23 | && Boolean(meta.error) 24 | && {meta.error} 25 | } 26 | ; 27 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/Muscle.tsx: -------------------------------------------------------------------------------- 1 | import { Autocomplete, TextField } from "@mui/material"; 2 | import { useMusclesQuery } from "components/Exercises/queries"; 3 | import React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | import { editExercise } from "services"; 6 | 7 | export function EditExerciseMuscle(props: { 8 | exerciseId: number, 9 | value: number[], 10 | setValue: React.Dispatch>, 11 | blocked: number[], 12 | isMain: boolean 13 | }) { 14 | const { t } = useTranslation(); 15 | const musclesQuery = useMusclesQuery(); 16 | 17 | const handleOnChange = async (newValue: number[]) => { 18 | props.setValue(newValue); 19 | // eslint-disable-next-line camelcase 20 | await editExercise(props.exerciseId, props.isMain ? { muscles: newValue } : { muscles_secondary: newValue }); 21 | }; 22 | 23 | return musclesQuery.isSuccess 24 | ? m.id)} 27 | getOptionDisabled={(option) => props.blocked.includes(option)} 28 | getOptionLabel={option => musclesQuery.data!.find(m => m.id === option)!.getName(t)} 29 | value={props.value} 30 | onChange={(event, newValue) => handleOnChange(newValue)} 31 | renderInput={params => ( 32 | 38 | )} 39 | /> 40 | : null; 41 | } -------------------------------------------------------------------------------- /src/components/Exercises/forms/yupValidators.ts: -------------------------------------------------------------------------------- 1 | import * as yup from "yup"; 2 | 3 | const MIN_CHAR_NAME = 5; 4 | const MAX_CHAR_NAME = 40; 5 | 6 | 7 | export const nameValidator = (t: Function) => yup 8 | .string() 9 | .min(MIN_CHAR_NAME, t("forms.valueTooShort")) 10 | .max(MAX_CHAR_NAME, t("forms.valueTooLong")) 11 | .required(t("forms.fieldRequired")); 12 | 13 | export const alternativeNameValidator = (t: Function) => yup 14 | .array() 15 | .ensure() 16 | .compact() 17 | .of( 18 | yup 19 | .string() 20 | .min(MIN_CHAR_NAME, t("forms.valueTooShort")) 21 | .max(MAX_CHAR_NAME, t("forms.valueTooLong")) 22 | ); 23 | 24 | export const descriptionValidator = (t: Function) => yup 25 | .string() 26 | .min(40, t("forms.valueTooShort")) 27 | .required(t("forms.fieldRequired")); 28 | 29 | export const noteValidator = (t: Function) => yup 30 | .array() 31 | .ensure() 32 | .compact() 33 | .of( 34 | yup 35 | .string() 36 | .min(15, t("forms.valueTooShort")) 37 | ); 38 | 39 | export const categoryValidator = (t: Function) => yup 40 | .number() 41 | .required(t("forms.fieldRequired")); -------------------------------------------------------------------------------- /src/components/Exercises/models/alias.ts: -------------------------------------------------------------------------------- 1 | import { ApiAliasType } from "types"; 2 | import { Adapter } from "utils/Adapter"; 3 | 4 | export class Alias { 5 | constructor( 6 | public id: number, 7 | public uuid: string, 8 | public alias: string, 9 | ) { 10 | } 11 | 12 | } 13 | 14 | export class AliasAdapter implements Adapter { 15 | fromJson(item: ApiAliasType): Alias { 16 | return new Alias( 17 | item.id, 18 | item.uuid, 19 | item.alias, 20 | ); 21 | } 22 | 23 | toJson(item: Alias) { 24 | return { 25 | id: item.id, 26 | name: item.alias, 27 | }; 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/category.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18n"; 2 | import { Adapter } from "utils/Adapter"; 3 | import { getTranslationKey } from "utils/strings"; 4 | 5 | 6 | export class Category { 7 | 8 | constructor( 9 | public id: number, 10 | public name: string 11 | ) { 12 | } 13 | 14 | public get translatedName(): string { 15 | return i18n.t(getTranslationKey(this.name), { defaultValue: this.name }); 16 | } 17 | } 18 | 19 | 20 | export class CategoryAdapter implements Adapter { 21 | fromJson(item: any): Category { 22 | return new Category( 23 | item.id, 24 | item.name 25 | ); 26 | } 27 | 28 | toJson(item: Category) { 29 | return { 30 | id: item.id, 31 | name: item.name, 32 | }; 33 | } 34 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/equipment.ts: -------------------------------------------------------------------------------- 1 | import i18n from "i18n"; 2 | import { Adapter } from "utils/Adapter"; 3 | import { getTranslationKey } from "utils/strings"; 4 | 5 | export class Equipment { 6 | 7 | constructor( 8 | public id: number, 9 | public name: string 10 | ) { 11 | } 12 | 13 | public get translatedName(): string { 14 | return i18n.t(getTranslationKey(this.name), { defaultValue: this.name }); 15 | } 16 | } 17 | 18 | export class EquipmentAdapter implements Adapter { 19 | fromJson(item: any): Equipment { 20 | return new Equipment( 21 | item.id, 22 | item.name, 23 | ); 24 | } 25 | 26 | toJson(item: Equipment) { 27 | return { 28 | id: item.id, 29 | name: item.name, 30 | }; 31 | } 32 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/image.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export enum ImageStyle { 4 | LINE_ART = 1, 5 | THREE_D, 6 | LOW_POLY, 7 | PHOTO, 8 | OTHER, 9 | } 10 | 11 | export class ExerciseImage { 12 | 13 | constructor( 14 | public id: number, 15 | public uuid: string, 16 | public url: string, 17 | public isMain: boolean) { 18 | } 19 | } 20 | 21 | export class ExerciseImageAdapter implements Adapter { 22 | fromJson(item: any): ExerciseImage { 23 | return new ExerciseImage( 24 | item.id, 25 | item.uuid, 26 | item.image, 27 | item.is_main 28 | ); 29 | } 30 | 31 | // TODO: when uploading an image we have to send the file 32 | toJson(item: ExerciseImage) { 33 | return { 34 | id: item.id, 35 | image: item.url, 36 | // eslint-disable-next-line camelcase 37 | is_front: item.isMain 38 | }; 39 | } 40 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/language.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class Language { 4 | 5 | constructor( 6 | public id: number, 7 | public nameShort: string, 8 | public nameLong: string 9 | ) { 10 | } 11 | } 12 | 13 | 14 | export class LanguageAdapter implements Adapter { 15 | fromJson(item: any): Language { 16 | return new Language( 17 | item.id, 18 | item.short_name, 19 | item.full_name 20 | ); 21 | } 22 | 23 | toJson(item: Language) { 24 | return {}; 25 | } 26 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/muscle.test.ts: -------------------------------------------------------------------------------- 1 | import { Muscle } from "components/Exercises/models/muscle"; 2 | 3 | 4 | describe("Muscle model tests", () => { 5 | 6 | test('name helper', () => { 7 | 8 | // Arrange 9 | const m1 = new Muscle(2, "Anterior deltoid", "Shoulders", true); 10 | const m2 = new Muscle(2, "Anterior deltoid", "", true); 11 | 12 | // Assert 13 | expect(m1.getName()).toBe("Anterior deltoid (Shoulders)"); 14 | expect(m2.getName()).toBe("Anterior deltoid"); 15 | }); 16 | }); 17 | 18 | -------------------------------------------------------------------------------- /src/components/Exercises/models/muscle.ts: -------------------------------------------------------------------------------- 1 | import i18n from 'i18next'; 2 | import { Adapter } from "utils/Adapter"; 3 | import { getTranslationKey } from "utils/strings"; 4 | 5 | export class Muscle { 6 | constructor( 7 | public id: number, 8 | public name: string, 9 | public nameEn: string, 10 | public isFront: boolean 11 | ) { 12 | } 13 | 14 | public get translatedName(): string { 15 | return i18n.t(getTranslationKey(this.nameEn), { defaultValue: this.nameEn }); 16 | } 17 | 18 | // Return the name and english name of the muscle, if available. 19 | public getName(): string { 20 | if (this.nameEn) { 21 | return `${this.name} (${this.translatedName})`; 22 | } else { 23 | return this.name; 24 | } 25 | } 26 | 27 | } 28 | 29 | export class MuscleAdapter implements Adapter { 30 | fromJson(item: any): Muscle { 31 | return new Muscle( 32 | item.id, 33 | item.name, 34 | item.name_en, 35 | item.is_front 36 | ); 37 | } 38 | 39 | toJson(item: Muscle) { 40 | return {}; 41 | } 42 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/note.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class Note { 4 | constructor( 5 | public id: number | null, 6 | public exercise: number, 7 | public note: string, 8 | ) { 9 | } 10 | } 11 | 12 | export class NoteAdapter implements Adapter { 13 | fromJson(item: any): Note { 14 | return new Note( 15 | item.id, 16 | item.exercise, 17 | item.comment, 18 | ); 19 | } 20 | 21 | toJson(item: Note) { 22 | return { 23 | id: item.id, 24 | comment: item.note, 25 | exercise: item.exercise 26 | }; 27 | } 28 | } -------------------------------------------------------------------------------- /src/components/Exercises/models/video.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class ExerciseVideo { 4 | 5 | constructor( 6 | public id: number, 7 | public uuid: string, 8 | public url: string, 9 | public isMain: boolean) { 10 | } 11 | } 12 | 13 | export class ExerciseVideoAdapter implements Adapter { 14 | fromJson(item: any): ExerciseVideo { 15 | return new ExerciseVideo( 16 | item.id, 17 | item.uuid, 18 | item.video, 19 | item.is_main 20 | ); 21 | } 22 | 23 | toJson(item: ExerciseVideo) { 24 | return { 25 | id: item.id, 26 | video: item.url, 27 | }; 28 | } 29 | } -------------------------------------------------------------------------------- /src/components/Header/SubMenus/BodyWeightSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Menu, MenuItem } from "@mui/material"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Link } from 'react-router-dom'; 5 | import { makeLink, WgerLink } from "utils/url"; 6 | 7 | export const BodyWeightSubMenu = () => { 8 | const { i18n } = useTranslation(); 9 | const [anchorElWeight, setAnchorElWeight] = React.useState(null); 10 | 11 | return ( 12 | <> 13 | 16 | setAnchorElWeight(null)} 20 | > 21 | 22 | Weight overview 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/Header/SubMenus/MeasurementsSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { useTranslation } from "react-i18next"; 3 | import { Button, Menu, MenuItem } from "@mui/material"; 4 | import { makeLink, WgerLink } from "utils/url"; 5 | import React from "react"; 6 | 7 | export const MeasurementsSubMenu = () => { 8 | 9 | const { i18n } = useTranslation(); 10 | const [anchorElMeasurements, setAnchorElMeasurements] = React.useState(null); 11 | 12 | return ( 13 | <> 14 | 17 | setAnchorElMeasurements(null)} 21 | > 22 | 23 | Overview 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Header/SubMenus/NutritionSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from 'react-router-dom'; 2 | import { useTranslation } from "react-i18next"; 3 | import { Button, Menu, MenuItem } from "@mui/material"; 4 | import { makeLink, WgerLink } from "utils/url"; 5 | import React from "react"; 6 | 7 | export const NutritionSubMenu = () => { 8 | 9 | const { i18n } = useTranslation(); 10 | const [anchorEl, setAnchorEl] = React.useState(null); 11 | 12 | return ( 13 | <> 14 | 17 | setAnchorEl(null)} 21 | > 22 | 23 | Overview 24 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /src/components/Header/SubMenus/WorkoutSubMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Menu, MenuItem } from "@mui/material"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { Link } from "react-router-dom"; 5 | import { makeLink, WgerLink } from "../../../utils/url"; 6 | 7 | export const WorkoutSubMenu = () => { 8 | const { i18n } = useTranslation(); 9 | const [anchorElWorkout, setAnchorElWorkout] = React.useState(null); 10 | 11 | 12 | return ( 13 | <> 14 | 17 | setAnchorElWorkout(null)} 21 | > 22 | 23 | Calendar 24 | 25 | 26 | 27 | ); 28 | }; -------------------------------------------------------------------------------- /src/components/Header/index.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Toolbar, Typography } from "@mui/material"; 2 | import { BodyWeightSubMenu } from "components/Header/SubMenus/BodyWeightSubMenu"; 3 | import { MeasurementsSubMenu } from "components/Header/SubMenus/MeasurementsSubMenu"; 4 | import { NutritionSubMenu } from "components/Header/SubMenus/NutritionSubMenu"; 5 | import { TrainingSubMenu } from "components/Header/SubMenus/TrainingSubMenu"; 6 | import React from 'react'; 7 | 8 | 9 | export const Header = () => { 10 | 11 | return ( 12 | 13 | 14 | 15 | wger 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; -------------------------------------------------------------------------------- /src/components/Measurements/Screens/MeasurementCategoryDetail.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, } from "@mui/material"; 2 | import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget"; 3 | import { WgerContainerRightSidebar } from "components/Core/Widgets/Container"; 4 | import { useMeasurementsQuery } from "components/Measurements/queries"; 5 | import { CategoryDetailDataGrid } from "components/Measurements/widgets/CategoryDetailDataGrid"; 6 | import { CategoryDetailDropdown } from "components/Measurements/widgets/CategoryDetailDropdown"; 7 | import { AddMeasurementEntryFab } from "components/Measurements/widgets/fab"; 8 | import { MeasurementChart } from "components/Measurements/widgets/MeasurementChart"; 9 | import React from "react"; 10 | import { useParams } from "react-router-dom"; 11 | 12 | export const MeasurementCategoryDetail = () => { 13 | const params = useParams<{ categoryId: string }>(); 14 | const categoryId = parseInt(params.categoryId ?? ''); 15 | if (Number.isNaN(categoryId)) { 16 | return

Please pass an integer as the category id.

; 17 | } 18 | 19 | // eslint-disable-next-line react-hooks/rules-of-hooks 20 | const categoryQuery = useMeasurementsQuery(categoryId); 21 | 22 | if (categoryQuery.isLoading) { 23 | return ; 24 | } 25 | 26 | return } 29 | mainContent={ 30 | 31 | 32 | 33 | 34 | } 35 | fab={} 36 | />; 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/Measurements/models/Category.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | import { MeasurementEntry } from "components/Measurements/models/Entry"; 3 | 4 | export class MeasurementCategory { 5 | 6 | entries: MeasurementEntry[] = []; 7 | 8 | constructor( 9 | public id: number, 10 | public name: string, 11 | public unit: string, 12 | entries?: MeasurementEntry[] 13 | ) { 14 | if (entries) { 15 | this.entries = entries; 16 | } 17 | } 18 | } 19 | 20 | 21 | export class MeasurementCategoryAdapter implements Adapter { 22 | fromJson(item: any) { 23 | return new MeasurementCategory( 24 | item.id, 25 | item.name, 26 | item.unit 27 | ); 28 | } 29 | 30 | toJson(item: MeasurementCategory) { 31 | return { 32 | id: item.id, 33 | name: item.name, 34 | unit: item.unit, 35 | }; 36 | } 37 | } -------------------------------------------------------------------------------- /src/components/Measurements/models/Entry.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class MeasurementEntry { 4 | 5 | constructor( 6 | public id: number | null, 7 | public category: number, 8 | public date: Date, 9 | public value: number, 10 | public notes: string 11 | ) { 12 | } 13 | } 14 | 15 | 16 | export class MeasurementEntryAdapter implements Adapter { 17 | fromJson(item: any) { 18 | return new MeasurementEntry( 19 | item.id, 20 | item.category, 21 | new Date(item.date), 22 | item.value, 23 | item.notes 24 | ); 25 | } 26 | 27 | toJson(item: MeasurementEntry) { 28 | return { 29 | id: item.id, 30 | category: item.category, 31 | date: item.date, 32 | value: item.value, 33 | notes: item.notes 34 | }; 35 | } 36 | } -------------------------------------------------------------------------------- /src/components/Muscles/MuscleOverview.tsx: -------------------------------------------------------------------------------- 1 | import { Muscle } from "components/Exercises/models/muscle"; 2 | import { PUBLIC_URL } from "config"; 3 | import React from "react"; 4 | 5 | type OverviewCardProps = { 6 | primaryMuscles: Muscle[]; 7 | secondaryMuscles: Muscle[]; 8 | isFront: boolean; 9 | }; 10 | 11 | export const MuscleOverview = ({ primaryMuscles, secondaryMuscles, isFront }: OverviewCardProps) => { 12 | const backgroundStyle = []; 13 | 14 | backgroundStyle.push( 15 | ...primaryMuscles 16 | .filter(m => m.isFront === isFront) 17 | .map(m => `/muscles/main/muscle-${m.id}.svg`) 18 | ); 19 | backgroundStyle.push( 20 | ...secondaryMuscles 21 | .filter(m => m.isFront === isFront) 22 | .map(m => `/muscles/secondary/muscle-${m.id}.svg`) 23 | ); 24 | backgroundStyle.push(isFront ? "/muscles/muscular_system_front.svg" : "/muscles/muscular_system_back.svg"); 25 | const backgroundUrl = backgroundStyle.map(url => `url(${PUBLIC_URL}${url})`).join(", "); 26 | 27 | return ( 28 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/Nutrition/components/IngredientSearch.tsx: -------------------------------------------------------------------------------- 1 | import { IngredientAutocompleter } from "components/Nutrition/widgets/IngredientAutcompleter"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { IngredientSearchResponse } from "services/responseType"; 5 | import { makeLink, WgerLink } from "utils/url"; 6 | 7 | 8 | /* 9 | * Ingredient autocompleter used to navigate the user to the ingredient 10 | * detail page from the overview 11 | */ 12 | export const IngredientSearch = () => { 13 | const [t, i18n] = useTranslation(); 14 | 15 | const navigateToIngredient = (value: IngredientSearchResponse | null) => { 16 | if (value !== null) { 17 | window.location.href = makeLink(WgerLink.INGREDIENT_DETAIL, i18n.language, { id: value.data.id }); 18 | } 19 | }; 20 | 21 | return ; 24 | }; -------------------------------------------------------------------------------- /src/components/Nutrition/components/PlanOverview.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen } from '@testing-library/react'; 3 | import { PlansOverview } from "components/Nutrition/components/PlansOverview"; 4 | import { useFetchNutritionalPlansQuery } from "components/Nutrition/queries"; 5 | import { TEST_NUTRITIONAL_PLAN_1, TEST_NUTRITIONAL_PLAN_2 } from "tests/nutritionTestdata"; 6 | 7 | jest.mock("components/Nutrition/queries"); 8 | 9 | const queryClient = new QueryClient(); 10 | 11 | describe("Test the PlansOverview component", () => { 12 | 13 | beforeEach(() => { 14 | (useFetchNutritionalPlansQuery as jest.Mock).mockImplementation(() => ({ 15 | isSuccess: true, 16 | isLoading: false, 17 | data: [TEST_NUTRITIONAL_PLAN_1, TEST_NUTRITIONAL_PLAN_2] 18 | })); 19 | 20 | }); 21 | 22 | test('renders all plans correctly', async () => { 23 | 24 | // Act 25 | render( 26 | 27 | 28 | 29 | ); 30 | 31 | // Assert 32 | expect(useFetchNutritionalPlansQuery).toHaveBeenCalled(); 33 | expect(screen.getByText('nutrition.plans')).toBeInTheDocument(); 34 | expect(screen.getByText('Summer body!!!')).toBeInTheDocument(); 35 | expect(screen.getByText('Bulking till we puke')).toBeInTheDocument(); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/Nutrition/models/Ingredient.ts: -------------------------------------------------------------------------------- 1 | import { IngredientImage, IngredientImageAdapter } from "components/Nutrition/models/IngredientImage"; 2 | import { ApiIngredientType } from "types"; 3 | import { Adapter } from "utils/Adapter"; 4 | 5 | export class Ingredient { 6 | 7 | constructor( 8 | public id: number, 9 | public uuid: string, 10 | public code: string, 11 | public name: string, 12 | public energy: number, 13 | public protein: number, 14 | public carbohydrates: number, 15 | public carbohydratesSugar: number | null, 16 | public fat: number, 17 | public fatSaturated: number | null, 18 | public fiber: number | null, 19 | public sodium: number | null, 20 | public image: IngredientImage | null = null, 21 | ) { 22 | } 23 | } 24 | 25 | 26 | export class IngredientAdapter implements Adapter { 27 | fromJson(item: ApiIngredientType) { 28 | return new Ingredient( 29 | item.id, 30 | item.uuid, 31 | item.code, 32 | item.name, 33 | item.energy, 34 | parseFloat(item.protein), 35 | parseFloat(item.carbohydrates), 36 | item.carbohydrates_sugar === null ? null : parseFloat(item.carbohydrates_sugar), 37 | parseFloat(item.fat), 38 | item.fat_saturated === null ? null : parseFloat(item.fat_saturated), 39 | item.fiber === null ? null : parseFloat(item.fiber), 40 | item.sodium === null ? null : parseFloat(item.sodium), 41 | item.image === null ? null : new IngredientImageAdapter().fromJson(item.image), 42 | ); 43 | } 44 | } -------------------------------------------------------------------------------- /src/components/Nutrition/models/IngredientImage.ts: -------------------------------------------------------------------------------- 1 | import { ApiIngredientImageType } from "types"; 2 | import { Adapter } from "utils/Adapter"; 3 | 4 | export class IngredientImage { 5 | 6 | constructor( 7 | public id: number, 8 | public uuid: string, 9 | public url: string, 10 | public created: Date, 11 | public lastUpdate: Date, 12 | public size: number, 13 | public width: number, 14 | public height: number, 15 | ) { 16 | } 17 | } 18 | 19 | 20 | export class IngredientImageAdapter implements Adapter { 21 | fromJson(item: ApiIngredientImageType) { 22 | return new IngredientImage( 23 | item.id, 24 | item.uuid, 25 | item.image, 26 | new Date(item.created), 27 | new Date(item.last_update), 28 | item.size, 29 | item.width, 30 | item.height, 31 | ); 32 | } 33 | } -------------------------------------------------------------------------------- /src/components/Nutrition/models/meal.test.ts: -------------------------------------------------------------------------------- 1 | import { MealAdapter } from "components/Nutrition/models/meal"; 2 | import { TEST_MEAL_1 } from "tests/nutritionTestdata"; 3 | 4 | 5 | describe('Test the meal model', () => { 6 | 7 | test('correctly creates a meal from the API response', () => { 8 | // Arrange 9 | const apiResponse = { 10 | id: 111, 11 | order: 22, 12 | time: '22:31', 13 | name: 'bla bla' 14 | }; 15 | const adapter = new MealAdapter(); 16 | 17 | // Act 18 | const meal = adapter.fromJson(apiResponse); 19 | 20 | // Assert 21 | expect(meal.timeHHMMLocale).toBe('22:31'); 22 | }); 23 | 24 | test('correctly creates a meal from the API response - no date', () => { 25 | // Arrange 26 | const apiResponse = { 27 | id: 111, 28 | order: 22, 29 | time: null, 30 | name: 'bla bla' 31 | }; 32 | const adapter = new MealAdapter(); 33 | 34 | // Act 35 | const meal = adapter.fromJson(apiResponse); 36 | 37 | // Assert 38 | expect(meal.timeHHMMLocale).toBe(null); 39 | }); 40 | 41 | test('correctly creates a JSON response from a meal', () => { 42 | // Arrange 43 | const adapter = new MealAdapter(); 44 | 45 | // Act 46 | const json = adapter.toJson(TEST_MEAL_1); 47 | 48 | // Assert 49 | expect(json.time).toBe('12:30'); 50 | }); 51 | 52 | }); 53 | -------------------------------------------------------------------------------- /src/components/Nutrition/models/mealItem.test.ts: -------------------------------------------------------------------------------- 1 | import { TEST_MEAL_ITEM_1, TEST_WEIGHT_UNIT_SLICE } from "tests/nutritionTestdata"; 2 | 3 | 4 | describe("Test the meal item model", () => { 5 | 6 | test('correctly uses the weight unit', async () => { 7 | // Arrange 8 | TEST_MEAL_ITEM_1.weightUnit = TEST_WEIGHT_UNIT_SLICE; 9 | 10 | // Act 11 | const values = TEST_MEAL_ITEM_1.nutritionalValues; 12 | 13 | // Assert 14 | expect(values.energy).toBeCloseTo(60, 2); 15 | expect(values.protein).toBeCloseTo(342, 2); 16 | expect(values.carbohydrates).toBeCloseTo(1116, 2); 17 | expect(values.carbohydratesSugar).toBeCloseTo(611.999, 2); 18 | expect(values.fat).toBeCloseTo(198, 2); 19 | expect(values.fatSaturated).toBeCloseTo(54, 2); 20 | expect(values.fiber).toBeCloseTo(30, 2); 21 | expect(values.sodium).toBeCloseTo(2.4, 2); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/Nutrition/models/weightUnit.ts: -------------------------------------------------------------------------------- 1 | import { ApiIngredientWeightUnitType } from "types"; 2 | import { Adapter } from "utils/Adapter"; 3 | 4 | export class NutritionWeightUnit { 5 | 6 | constructor( 7 | public id: number, 8 | public amount: number, 9 | public grams: number, 10 | public name: string = '' 11 | ) { 12 | } 13 | } 14 | 15 | 16 | export class NutritionWeightUnitAdapter implements Adapter { 17 | fromJson(item: ApiIngredientWeightUnitType) { 18 | return new NutritionWeightUnit( 19 | item.id, 20 | parseFloat(item.amount), 21 | item.gram, 22 | ); 23 | } 24 | } -------------------------------------------------------------------------------- /src/components/Nutrition/queries/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | useFetchNutritionalPlansQuery, 3 | useFetchNutritionalPlanDateQuery, 4 | useEditNutritionalPlanQuery, 5 | useAddNutritionalPlanQuery, 6 | useFetchNutritionalPlanQuery, 7 | useDeleteNutritionalPlanQuery, 8 | useFetchLastNutritionalPlanQuery, 9 | 10 | } from './plan'; 11 | 12 | export { 13 | useAddDiaryEntryQuery, useDeleteDiaryEntryQuery, useEditDiaryEntryQuery, useNutritionDiaryQuery 14 | } from './diary'; 15 | 16 | export { useEditMealQuery, useAddMealQuery, useDeleteMealQuery } from './meal'; 17 | 18 | export { useEditMealItemQuery, useAddMealItemQuery, useDeleteMealItemQuery } from './mealItem'; 19 | 20 | export { useFetchIngredientQuery } from './ingredient' ; -------------------------------------------------------------------------------- /src/components/Nutrition/queries/ingredient.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getIngredient } from "services"; 3 | import { QueryKey } from "utils/consts"; 4 | 5 | 6 | export function useFetchIngredientQuery(ingredientId: number) { 7 | return useQuery({ 8 | queryKey: [QueryKey.INGREDIENT, ingredientId], 9 | queryFn: () => getIngredient(ingredientId) 10 | }); 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/components/Nutrition/queries/meal.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { addMeal, AddMealParams, deleteMeal, editMeal, EditMealParams } from "services/meal"; 3 | import { QueryKey } from "utils/consts"; 4 | 5 | export const useAddMealQuery = (planId: number) => { 6 | const queryClient = useQueryClient(); 7 | 8 | return useMutation({ 9 | mutationFn: (data: AddMealParams) => addMeal(data), 10 | onSuccess: () => { 11 | queryClient.invalidateQueries({ 12 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 13 | }); 14 | } 15 | }); 16 | }; 17 | export const useDeleteMealQuery = (planId: number) => { 18 | const queryClient = useQueryClient(); 19 | 20 | return useMutation({ 21 | mutationFn: (id: number) => deleteMeal(id), 22 | onSuccess: () => { 23 | queryClient.invalidateQueries({ 24 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 25 | }); 26 | } 27 | }); 28 | }; 29 | export const useEditMealQuery = (planId: number) => { 30 | const queryClient = useQueryClient(); 31 | 32 | return useMutation({ 33 | mutationFn: (data: EditMealParams) => editMeal(data), 34 | onSuccess: () => { 35 | queryClient.invalidateQueries({ 36 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 37 | }); 38 | } 39 | }); 40 | }; -------------------------------------------------------------------------------- /src/components/Nutrition/queries/mealItem.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { addMealItem, AddMealItemParams, deleteMealItem, editMealItem, EditMealItemParams } from "services/mealItem"; 3 | import { QueryKey } from "utils/consts"; 4 | import { number } from "yup"; 5 | 6 | export const useAddMealItemQuery = (planId: number) => { 7 | const queryClient = useQueryClient(); 8 | 9 | return useMutation({ 10 | mutationFn: (data: AddMealItemParams) => addMealItem(data), 11 | onSuccess: () => { 12 | queryClient.invalidateQueries({ 13 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 14 | }); 15 | } 16 | }); 17 | }; 18 | 19 | export const useEditMealItemQuery = (planId: number) => { 20 | const queryClient = useQueryClient(); 21 | 22 | return useMutation({ 23 | mutationFn: (data: EditMealItemParams) => editMealItem(data), 24 | onSuccess: () => { 25 | queryClient.invalidateQueries({ 26 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 27 | }); 28 | } 29 | }); 30 | }; 31 | 32 | export const useDeleteMealItemQuery = (planId: number) => { 33 | const queryClient = useQueryClient(); 34 | 35 | return useMutation({ 36 | mutationFn: (id: number) => deleteMealItem(id), 37 | onSuccess: () => { 38 | queryClient.invalidateQueries({ 39 | queryKey: [QueryKey.NUTRITIONAL_PLAN, planId] 40 | }); 41 | } 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/Nutrition/widgets/PlanSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Stack, Typography } from "@mui/material"; 2 | import { NutritionalPlan } from "components/Nutrition/models/nutritionalPlan"; 3 | import { LinearPlannedLoggedChart } from "components/Nutrition/widgets/charts/LinearPlannedLoggedChart"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | 8 | export const PlanSidebar = (props: { plan: NutritionalPlan }) => { 9 | const [t] = useTranslation(); 10 | 11 | const planned = props.plan.plannedNutritionalValues; 12 | const loggedToday = props.plan.loggedNutritionalValuesToday; 13 | const percentages = props.plan.percentageValuesLoggedToday; 14 | 15 | 16 | return <> 17 | 18 | 19 | {t('nutrition.goalsTitle')} 20 | 21 | 22 | 28 | 29 | 35 | 36 | 42 | 43 | ; 44 | }; -------------------------------------------------------------------------------- /src/components/Nutrition/widgets/charts/LinearPlannedLoggedChart.tsx: -------------------------------------------------------------------------------- 1 | import { LinearProgress, Typography } from "@mui/material"; 2 | import React from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | import { numberGramLocale } from "utils/numbers"; 5 | 6 | export const LinearPlannedLoggedChart = (props: { 7 | percentage: number, 8 | logged: number, 9 | title: string, 10 | planned: number 11 | }) => { 12 | const { i18n } = useTranslation(); 13 | 14 | const hasPlanned = props.planned > 0; 15 | 16 | return <> 17 | 21 | 22 | {props.title} — {numberGramLocale(props.logged, i18n.language)} 23 | {hasPlanned && <> / {numberGramLocale(props.planned, i18n.language)}} 24 | 25 | ; 26 | 27 | }; -------------------------------------------------------------------------------- /src/components/User/queries/contribute.ts: -------------------------------------------------------------------------------- 1 | import { useProfileQuery } from "components/User/queries/profile"; 2 | import { usePermissionQuery } from "components/User/queries/permission"; 3 | import { WgerPermissions } from "permissions"; 4 | 5 | export function useCanContributeExercises() { 6 | const profileQuery = useProfileQuery(); 7 | const permissionQuery = usePermissionQuery(WgerPermissions.EDIT_EXERCISE); 8 | 9 | const result = { canContribute: false, anonymous: true, emailVerified: false, admin: false }; 10 | 11 | if (profileQuery.isSuccess && permissionQuery.isSuccess) { 12 | 13 | // Profile is null, user is not logged in 14 | if (profileQuery.data === null) { 15 | return result; 16 | } 17 | 18 | result.anonymous = false; 19 | 20 | if (profileQuery.data?.emailVerified) { 21 | result.emailVerified = true; 22 | } 23 | 24 | if (permissionQuery.data) { 25 | result.admin = true; 26 | } 27 | 28 | if (result.admin || profileQuery.data?.isTrustworthy) { 29 | result.canContribute = true; 30 | } 31 | } 32 | return result; 33 | } -------------------------------------------------------------------------------- /src/components/User/queries/permission.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { WgerPermissions } from "permissions"; 3 | import { checkPermission } from "services/permission"; 4 | import { QUERY_PERMISSION } from "utils/consts"; 5 | 6 | export function usePermissionQuery(permission: WgerPermissions) { 7 | return useQuery({ 8 | queryKey: [QUERY_PERMISSION, permission], 9 | queryFn: () => checkPermission(permission.valueOf()) 10 | }); 11 | } -------------------------------------------------------------------------------- /src/components/User/queries/profile.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { EditProfileParams } from "components/User/models/profile"; 3 | import { editProfile, getProfile } from "services"; 4 | import { QueryKey } from "utils/consts"; 5 | 6 | export function useProfileQuery() { 7 | return useQuery({ 8 | queryKey: [QueryKey.QUERY_PROFILE], 9 | queryFn: getProfile 10 | }); 11 | } 12 | 13 | export const useEditProfileQuery = () => { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation({ 17 | mutationFn: (data: Partial) => editProfile(data), 18 | onSuccess: async () => { 19 | await queryClient.invalidateQueries({ queryKey: [QueryKey.QUERY_PROFILE] }); 20 | } 21 | }); 22 | }; -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Detail/RoutineAdd.tsx: -------------------------------------------------------------------------------- 1 | import Grid from "@mui/material/Grid"; 2 | import { RoutineForm } from "components/WorkoutRoutines/widgets/forms/RoutineForm"; 3 | import React from "react"; 4 | 5 | export const RoutineAdd = () => { 6 | 7 | return 8 | 9 | 10 | 11 | ; 12 | }; -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Detail/SlotProgressionEdit.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import { SlotProgressionEdit } from "components/WorkoutRoutines/Detail/SlotProgressionEdit"; 4 | import React from "react"; 5 | import { MemoryRouter, Route, Routes } from "react-router"; 6 | import { getLanguages, getRoutine } from "services"; 7 | import { testLanguages } from "tests/exerciseTestdata"; 8 | import { testQueryClient } from "tests/queryClient"; 9 | import { testRoutine1 } from "tests/workoutRoutinesTestData"; 10 | 11 | jest.mock("services"); 12 | 13 | describe("Smoke tests the SlotProgressionEdit component", () => { 14 | 15 | beforeEach(() => { 16 | (getRoutine as jest.Mock).mockResolvedValue(testRoutine1); 17 | (getLanguages as jest.Mock).mockResolvedValue(testLanguages); 18 | }); 19 | 20 | test('renders the progression page', async () => { 21 | 22 | // Act 23 | render( 24 | 25 | 26 | 27 | } /> 28 | 29 | 30 | 31 | ); 32 | 33 | // Assert 34 | await waitFor(() => { 35 | expect(getRoutine).toHaveBeenCalledTimes(1); 36 | }); 37 | expect(screen.getByText('routines.editProgression')).toBeInTheDocument(); 38 | expect(screen.getByText('Benchpress')).toBeInTheDocument(); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Detail/TemplateDetail.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen, waitFor } from '@testing-library/react'; 3 | import { TemplateDetail } from "components/WorkoutRoutines/Detail/TemplateDetail"; 4 | import React from "react"; 5 | import { MemoryRouter, Route, Routes } from "react-router"; 6 | import { getLanguages, getRoutine } from "services"; 7 | import { testLanguages } from "tests/exerciseTestdata"; 8 | import { testQueryClient } from "tests/queryClient"; 9 | import { testRoutine1 } from "tests/workoutRoutinesTestData"; 10 | 11 | jest.mock("services"); 12 | 13 | describe("Smoke tests the TemplateDetail component", () => { 14 | 15 | beforeEach(() => { 16 | (getRoutine as jest.Mock).mockResolvedValue(testRoutine1); 17 | (getLanguages as jest.Mock).mockResolvedValue(testLanguages); 18 | }); 19 | 20 | test('renders all public templates', async () => { 21 | 22 | // Act 23 | render( 24 | 25 | 26 | 27 | } /> 28 | 29 | 30 | 31 | ); 32 | 33 | // Assert 34 | await waitFor(() => { 35 | expect(getRoutine).toHaveBeenCalledTimes(1); 36 | }); 37 | expect(screen.getByText('Test routine 1')).toBeInTheDocument(); 38 | expect(screen.getByText('Full body routine')).toBeInTheDocument(); 39 | expect(screen.getByText('routines.template')).toBeInTheDocument(); 40 | expect(screen.getByText('routines.copyAndUseTemplate')).toBeInTheDocument(); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/Fab.tsx: -------------------------------------------------------------------------------- 1 | import AddIcon from "@mui/icons-material/Add"; 2 | import { Fab } from "@mui/material"; 3 | import { WgerModal } from "components/Core/Modals/WgerModal"; 4 | import { RoutineForm } from "components/WorkoutRoutines/widgets/forms/RoutineForm"; 5 | import React from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | export const AddRoutineFab = () => { 9 | 10 | const [t] = useTranslation(); 11 | const [openModal, setOpenModal] = React.useState(false); 12 | const handleOpenModal = () => setOpenModal(true); 13 | const handleCloseModal = () => setOpenModal(false); 14 | 15 | 16 | return ( 17 |
18 | theme.spacing(2), 26 | zIndex: 9, 27 | }}> 28 | 29 | 30 | 31 | 32 | 33 |
34 | ); 35 | }; -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/PrivateTemplateOverview.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { act, render, screen } from '@testing-library/react'; 3 | import { PrivateTemplateOverview } from "components/WorkoutRoutines/Overview/PrivateTemplateOverview"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import { getPrivateTemplatesShallow } from "services"; 6 | import { testQueryClient } from "tests/queryClient"; 7 | import { testPrivateTemplate1 } from "tests/workoutRoutinesTestData"; 8 | 9 | jest.mock("services"); 10 | 11 | describe("Smoke tests the PrivateTemplateOverview component", () => { 12 | 13 | beforeEach(() => { 14 | (getPrivateTemplatesShallow as jest.Mock).mockResolvedValue([testPrivateTemplate1]); 15 | }); 16 | 17 | test('renders all private templates', async () => { 18 | 19 | // Act 20 | render( 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | await act(async () => { 28 | await new Promise((r) => setTimeout(r, 20)); 29 | }); 30 | 31 | // Assert 32 | expect(getPrivateTemplatesShallow).toHaveBeenCalledTimes(1); 33 | expect(screen.getByText('private template 1')).toBeInTheDocument(); 34 | expect(screen.getByText('routines.templates')).toBeInTheDocument(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/PrivateTemplateOverview.tsx: -------------------------------------------------------------------------------- 1 | import { List, Paper, } from "@mui/material"; 2 | import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget"; 3 | import { WgerContainerRightSidebar } from "components/Core/Widgets/Container"; 4 | import { OverviewEmpty } from "components/Core/Widgets/OverviewEmpty"; 5 | import { RoutineList } from "components/WorkoutRoutines/Overview/RoutineOverview"; 6 | import { usePrivateRoutinesShallowQuery } from "components/WorkoutRoutines/queries/routines"; 7 | import React from "react"; 8 | import { useTranslation } from "react-i18next"; 9 | import { WgerLink } from "utils/url"; 10 | 11 | 12 | export const PrivateTemplateOverview = () => { 13 | const routineQuery = usePrivateRoutinesShallowQuery(); 14 | const [t] = useTranslation(); 15 | 16 | if (routineQuery.isLoading) { 17 | return ; 18 | } 19 | 20 | 21 | return 24 | {routineQuery.data!.length === 0 25 | ? 26 | : 27 | 28 | {routineQuery.data!.map(r => 33 | )} 34 | 35 | } 36 | } 37 | />; 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/PublicTemplateOverview.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { act, render, screen } from '@testing-library/react'; 3 | import { PublicTemplateOverview } from "components/WorkoutRoutines/Overview/PublicTemplateOverview"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import { getPublicTemplatesShallow } from "services"; 6 | import { testQueryClient } from "tests/queryClient"; 7 | import { testPublicTemplate1 } from "tests/workoutRoutinesTestData"; 8 | 9 | jest.mock("services"); 10 | 11 | describe("Smoke tests the PublicTemplateOverview component", () => { 12 | 13 | beforeEach(() => { 14 | (getPublicTemplatesShallow as jest.Mock).mockResolvedValue([testPublicTemplate1]); 15 | }); 16 | 17 | test('renders all public templates', async () => { 18 | 19 | // Act 20 | render( 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | await act(async () => { 28 | await new Promise((r) => setTimeout(r, 20)); 29 | }); 30 | 31 | // Assert 32 | expect(getPublicTemplatesShallow).toHaveBeenCalledTimes(1); 33 | expect(screen.getByText('public template 1')).toBeInTheDocument(); 34 | expect(screen.getByText('routines.publicTemplates')).toBeInTheDocument(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/PublicTemplateOverview.tsx: -------------------------------------------------------------------------------- 1 | import { List, Paper, } from "@mui/material"; 2 | import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget"; 3 | import { WgerContainerRightSidebar } from "components/Core/Widgets/Container"; 4 | import { OverviewEmpty } from "components/Core/Widgets/OverviewEmpty"; 5 | import { RoutineList } from "components/WorkoutRoutines/Overview/RoutineOverview"; 6 | import { usePublicRoutinesShallowQuery } from "components/WorkoutRoutines/queries"; 7 | import React from "react"; 8 | import { useTranslation } from "react-i18next"; 9 | import { WgerLink } from "utils/url"; 10 | 11 | 12 | export const PublicTemplateOverview = () => { 13 | const routineQuery = usePublicRoutinesShallowQuery(); 14 | const [t] = useTranslation(); 15 | 16 | if (routineQuery.isLoading) { 17 | return ; 18 | } 19 | 20 | 21 | return 24 | {routineQuery.data!.length === 0 25 | ? 26 | : 27 | 28 | {routineQuery.data!.map(r => 33 | )} 34 | 35 | } 36 | } 37 | />; 38 | }; 39 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/Overview/RoutineOverview.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { act, render, screen } from '@testing-library/react'; 3 | import { RoutineOverview } from "components/WorkoutRoutines/Overview/RoutineOverview"; 4 | import React from 'react'; 5 | import { BrowserRouter } from "react-router-dom"; 6 | import { getRoutinesShallow } from "services"; 7 | import { testQueryClient } from "tests/queryClient"; 8 | import { TEST_ROUTINES } from "tests/workoutRoutinesTestData"; 9 | 10 | jest.mock("services"); 11 | 12 | describe("Smoke tests the RoutineOverview component", () => { 13 | 14 | beforeEach(() => { 15 | (getRoutinesShallow as jest.Mock).mockResolvedValue(TEST_ROUTINES); 16 | }); 17 | 18 | test('renders all routines', async () => { 19 | 20 | // Act 21 | render( 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | await act(async () => { 29 | await new Promise((r) => setTimeout(r, 20)); 30 | }); 31 | 32 | // Assert 33 | expect(getRoutinesShallow).toHaveBeenCalledTimes(1); 34 | expect(screen.getByText('Test routine 1')).toBeInTheDocument(); 35 | expect(screen.getByText('routines.routine')).toBeInTheDocument(); 36 | expect(screen.getByText('routines.routines')).toBeInTheDocument(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/LogStats.test.ts: -------------------------------------------------------------------------------- 1 | import { LogData, RoutineStatsDataAdapter } from "components/WorkoutRoutines/models/LogStats"; 2 | import { testRoutineStatistics } from "tests/workoutStatisticsTestData"; 3 | 4 | describe('RoutineStatsDataAdapter parser tests', () => { 5 | 6 | 7 | test('calls addQuery.mutate with correct data when creating a new entry', async () => { 8 | const adapter = new RoutineStatsDataAdapter(); 9 | 10 | const result = adapter.fromJson(testRoutineStatistics); 11 | 12 | expect(result.volume.mesocycle.total).toBe(150); 13 | expect(result.volume.mesocycle).toStrictEqual(new LogData({ 14 | "exercises": { 15 | "1": 20, 16 | "2": 30, 17 | "42": 50, 18 | }, 19 | "muscle": { 20 | "7": 70, 21 | "8": 10, 22 | "9": 20, 23 | }, 24 | "upper_body": 7, 25 | "lower_body": 8, 26 | "total": 150 27 | })); 28 | expect(result.volume.daily['2024-12-01']).toStrictEqual(new LogData({ 29 | "exercises": { 30 | "1": 20, 31 | "2": 30, 32 | "42": 50, 33 | }, 34 | "muscle": { 35 | "7": 70, 36 | "8": 10, 37 | "9": 20, 38 | }, 39 | "upper_body": 7, 40 | "lower_body": 8, 41 | "total": 150 42 | })); 43 | }); 44 | }); -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/RepetitionUnit.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class RepetitionUnit { 4 | id: number; 5 | name: string; 6 | 7 | constructor(id: number, description: string) { 8 | this.id = id; 9 | this.name = description; 10 | } 11 | } 12 | 13 | 14 | export class RepetitionUnitAdapter implements Adapter { 15 | fromJson(item: any): RepetitionUnit { 16 | return new RepetitionUnit( 17 | item.id, 18 | item.name, 19 | ); 20 | } 21 | 22 | toJson(item: RepetitionUnit) { 23 | return {}; 24 | } 25 | } -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/RoutineDayData.ts: -------------------------------------------------------------------------------- 1 | import { Day, DayAdapter } from "components/WorkoutRoutines/models/Day"; 2 | import { SlotData, SlotDataAdapter } from "components/WorkoutRoutines/models/SlotData"; 3 | import { Adapter } from "utils/Adapter"; 4 | 5 | export class RoutineDayData { 6 | 7 | slots: SlotData[] = []; 8 | 9 | constructor( 10 | public iteration: number, 11 | public date: Date, 12 | public label: string, 13 | public day: Day | null, 14 | slots?: SlotData[], 15 | ) { 16 | this.slots = slots ?? []; 17 | } 18 | } 19 | 20 | 21 | export class RoutineDayDataAdapter implements Adapter { 22 | fromJson = (item: any) => new RoutineDayData( 23 | item.iteration, 24 | new Date(item.date), 25 | item.label, 26 | item.day != null ? new DayAdapter().fromJson(item.day) : null, 27 | item.slots.map((slot: any) => new SlotDataAdapter().fromJson(slot)) 28 | ); 29 | } -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/RoutineLogData.ts: -------------------------------------------------------------------------------- 1 | import { WorkoutLog, WorkoutLogAdapter } from "components/WorkoutRoutines/models/WorkoutLog"; 2 | import { WorkoutSession, WorkoutSessionAdapter } from "components/WorkoutRoutines/models/WorkoutSession"; 3 | import { Adapter } from "utils/Adapter"; 4 | 5 | export class RoutineLogData { 6 | 7 | constructor( 8 | public session: WorkoutSession, 9 | public logs: WorkoutLog[], 10 | ) { 11 | } 12 | } 13 | 14 | 15 | export class RoutineLogDataAdapter implements Adapter { 16 | fromJson = (item: any) => new RoutineLogData( 17 | new WorkoutSessionAdapter().fromJson(item.session), 18 | item.logs.map((log: any) => new WorkoutLogAdapter().fromJson(log)), 19 | ); 20 | } -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/SlotData.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from "components/Exercises/models/exercise"; 2 | import { SetConfigData, SetConfigDataAdapter } from "components/WorkoutRoutines/models/SetConfigData"; 3 | import { Adapter } from "utils/Adapter"; 4 | 5 | export class SlotData { 6 | 7 | exercises: Exercise[] = []; 8 | 9 | constructor( 10 | public comment: string, 11 | public isSuperset: boolean, 12 | public exerciseIds: number[], 13 | public setConfigs: SetConfigData[], 14 | exercises?: Exercise[], 15 | ) { 16 | this.exercises = exercises ?? []; 17 | } 18 | } 19 | 20 | 21 | export class SlotDataAdapter implements Adapter { 22 | fromJson = (item: any) => new SlotData( 23 | item.comment, 24 | item.is_superset, 25 | item.exercises, 26 | item.sets.map((item: any) => new SetConfigDataAdapter().fromJson(item)) 27 | ); 28 | } -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/SlotEntry.test.ts: -------------------------------------------------------------------------------- 1 | import { slotEntryAdapter } from "components/WorkoutRoutines/models/SlotEntry"; 2 | import { testSlotEntryApiResponse } from "tests/slotEntryApiResponse"; 3 | 4 | describe('SlotEntry model tests', () => { 5 | 6 | 7 | test('correctly parses the JSON response', () => { 8 | // Act 9 | const result = slotEntryAdapter.fromJson(testSlotEntryApiResponse); 10 | 11 | // Assert 12 | expect(result.id).toEqual(143); 13 | expect(result.nrOfSetsConfigs[0].id).toEqual(145); 14 | expect(result.nrOfSetsConfigs[0].value).toEqual(2); 15 | 16 | expect(result.maxNrOfSetsConfigs[0].id).toEqual(222); 17 | expect(result.maxNrOfSetsConfigs[0].value).toEqual(4); 18 | 19 | expect(result.weightConfigs[0].id).toEqual(142); 20 | expect(result.weightConfigs[0].value).toEqual(102.5); 21 | 22 | expect(result.maxWeightConfigs[0].id).toEqual(143); 23 | expect(result.maxWeightConfigs[0].value).toEqual(120); 24 | 25 | expect(result.restTimeConfigs[0].id).toEqual(54); 26 | expect(result.restTimeConfigs[0].value).toEqual(120); 27 | 28 | expect(result.maxRestTimeConfigs[0].id).toEqual(45); 29 | expect(result.maxRestTimeConfigs[0].value).toEqual(150); 30 | }); 31 | }); -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/models/WeightUnit.ts: -------------------------------------------------------------------------------- 1 | import { Adapter } from "utils/Adapter"; 2 | 3 | export class WeightUnit { 4 | id: number; 5 | name: string; 6 | 7 | constructor(id: number, description: string) { 8 | this.id = id; 9 | this.name = description; 10 | } 11 | } 12 | 13 | 14 | export class WeightUnitAdapter implements Adapter { 15 | fromJson(item: any): WeightUnit { 16 | return new WeightUnit( 17 | item.id, 18 | item.name, 19 | ); 20 | } 21 | 22 | toJson(item: WeightUnit) { 23 | return {}; 24 | } 25 | } -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/queries/days.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { addDay, deleteDay, editDay, editDayOrder } from "services"; 3 | import { AddDayParams, EditDayOrderParam, EditDayParams } from "services/day"; 4 | import { QueryKey, } from "utils/consts"; 5 | 6 | 7 | export const useEditDayQuery = (routineId: number) => { 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation({ 11 | mutationFn: (data: EditDayParams) => editDay(data), 12 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 13 | }); 14 | }; 15 | 16 | export const useEditDayOrderQuery = (routineId: number) => { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation({ 20 | mutationFn: (data: EditDayOrderParam[]) => editDayOrder(data), 21 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 22 | }); 23 | }; 24 | 25 | export const useAddDayQuery = (routineId: number) => { 26 | const queryClient = useQueryClient(); 27 | 28 | return useMutation({ 29 | mutationFn: (data: AddDayParams) => addDay(data), 30 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 31 | }); 32 | }; 33 | 34 | 35 | export const useDeleteDayQuery = (routineId: number) => { 36 | const queryClient = useQueryClient(); 37 | 38 | return useMutation({ 39 | mutationFn: (id: number) => deleteDay(id), 40 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/queries/sessions.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { AddSessionParams, EditSessionParams } from "components/WorkoutRoutines/models/WorkoutSession"; 3 | import { addSession, editSession, getSessions, searchSession } from "services"; 4 | import { SessionQueryOptions } from "services/session"; 5 | import { QueryKey, } from "utils/consts"; 6 | 7 | 8 | export const useFindSessionQuery = (routineId: number, queryParams: Record) => useQuery({ 9 | queryFn: () => searchSession(queryParams), 10 | queryKey: [QueryKey.SESSION_SEARCH, routineId, JSON.stringify(queryParams)], 11 | }); 12 | 13 | export const useAddSessionQuery = () => { 14 | const queryClient = useQueryClient(); 15 | 16 | return useMutation({ 17 | mutationFn: (data: AddSessionParams) => addSession(data), 18 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_OVERVIEW] }), 19 | }); 20 | }; 21 | 22 | export const useSessionsQuery = (options?: SessionQueryOptions) => useQuery({ 23 | queryFn: () => getSessions(options), 24 | queryKey: [QueryKey.SESSIONS_FULL, JSON.stringify(options || {})], 25 | }); 26 | 27 | 28 | export const useEditSessionQuery = (id: number) => { 29 | const queryClient = useQueryClient(); 30 | 31 | return useMutation({ 32 | mutationFn: (data: EditSessionParams) => editSession(data), 33 | onSuccess: () => { 34 | queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_OVERVIEW] }); 35 | queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, id] }); 36 | } 37 | }); 38 | }; 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/queries/slot_entries.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { deleteSlotEntry, editSlotEntry } from "services"; 3 | import { addSlotEntry, AddSlotEntryParams, EditSlotEntryParams } from "services/slot_entry"; 4 | import { QueryKey, } from "utils/consts"; 5 | 6 | 7 | export const useEditSlotEntryQuery = (routineId: number) => { 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation({ 11 | mutationFn: (data: EditSlotEntryParams) => editSlotEntry(data), 12 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 13 | }); 14 | }; 15 | 16 | export const useAddSlotEntryQuery = (routineId: number) => { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation({ 20 | mutationFn: (data: AddSlotEntryParams) => addSlotEntry(data), 21 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 22 | }); 23 | }; 24 | 25 | export const useDeleteSlotEntryQuery = (routineId: number) => { 26 | const queryClient = useQueryClient(); 27 | 28 | return useMutation({ 29 | mutationFn: (slotId: number) => deleteSlotEntry(slotId), 30 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/queries/slots.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { addSlot, deleteSlot, editSlot, editSlotOrder } from "services"; 3 | import { AddSlotParams, EditSlotOrderParam, EditSlotParams } from "services/slot"; 4 | import { QueryKey, } from "utils/consts"; 5 | 6 | 7 | export const useAddSlotQuery = (routineId: number) => { 8 | const queryClient = useQueryClient(); 9 | 10 | return useMutation({ 11 | mutationFn: (data: AddSlotParams) => addSlot(data), 12 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 13 | }); 14 | }; 15 | 16 | export const useEditSlotQuery = (routineId: number) => { 17 | const queryClient = useQueryClient(); 18 | 19 | return useMutation({ 20 | mutationFn: (data: EditSlotParams) => editSlot(data), 21 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 22 | }); 23 | }; 24 | export const useEditSlotOrderQuery = (routineId: number) => { 25 | const queryClient = useQueryClient(); 26 | 27 | return useMutation({ 28 | mutationFn: (data: EditSlotOrderParam[]) => editSlotOrder(data), 29 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 30 | }); 31 | }; 32 | 33 | export const useDeleteSlotQuery = (routineId: number) => { 34 | const queryClient = useQueryClient(); 35 | 36 | return useMutation({ 37 | mutationFn: (slotId: number) => deleteSlot(slotId), 38 | onSuccess: () => queryClient.invalidateQueries({ queryKey: [QueryKey.ROUTINE_DETAIL, routineId] }) 39 | }); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/queries/units.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getRoutineRepUnits, getRoutineWeightUnits } from "services"; 3 | import { QueryKey, } from "utils/consts"; 4 | 5 | 6 | export const useFetchRoutineWeighUnitsQuery = () => useQuery({ 7 | queryKey: [QueryKey.ROUTINE_WEIGHT_UNITS], 8 | queryFn: getRoutineWeightUnits 9 | }); 10 | 11 | export const useFetchRoutineRepUnitsQuery = () => useQuery({ 12 | queryKey: [QueryKey.ROUTINE_REP_UNITS], 13 | queryFn: getRoutineRepUnits 14 | }); 15 | 16 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/widgets/RoutineDetailsCard.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen } from '@testing-library/react'; 3 | import { useRoutineDetailQuery } from "components/WorkoutRoutines/queries"; 4 | import { RoutineDetailsCard } from "components/WorkoutRoutines/widgets/RoutineDetailsCard"; 5 | import React from 'react'; 6 | import { MemoryRouter, Route, Routes } from "react-router"; 7 | import { testRoutine1 } from "tests/workoutRoutinesTestData"; 8 | 9 | jest.mock("components/WorkoutRoutines/queries"); 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | describe("Test the RoutineDetail component", () => { 14 | 15 | beforeEach(() => { 16 | // @ts-ignore 17 | useRoutineDetailQuery.mockImplementation(() => ({ 18 | isSuccess: true, 19 | isLoading: false, 20 | data: testRoutine1 21 | })); 22 | }); 23 | 24 | test('renders a specific routine', async () => { 25 | 26 | // Act 27 | render( 28 | 29 | 30 | 31 | } /> 32 | 33 | 34 | 35 | ); 36 | 37 | // Assert 38 | expect(useRoutineDetailQuery).toHaveBeenCalledWith(101); 39 | expect(screen.getByText('Full body routine')).toBeInTheDocument(); 40 | expect(screen.getByText('Every day is leg day 🦵🏻')).toBeInTheDocument(); 41 | expect(screen.getByText('Squats')).toBeInTheDocument(); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/widgets/forms/SlotForm.test.tsx: -------------------------------------------------------------------------------- 1 | import { QueryClientProvider } from "@tanstack/react-query"; 2 | import { render, screen } from '@testing-library/react'; 3 | import userEvent from "@testing-library/user-event"; 4 | import { SlotForm } from "components/WorkoutRoutines/widgets/forms/SlotForm"; 5 | import { editSlot } from "services"; 6 | import { testQueryClient } from "tests/queryClient"; 7 | import { testDayLegs } from "tests/workoutRoutinesTestData"; 8 | 9 | 10 | jest.mock("services"); 11 | 12 | let user: ReturnType; 13 | const mockEditSlot = editSlot as jest.Mock; 14 | 15 | describe('SlotForm', () => { 16 | 17 | beforeEach(() => { 18 | user = userEvent.setup(); 19 | jest.resetAllMocks(); 20 | }); 21 | 22 | test('correctly updates the slot entry on change', async () => { 23 | render( 24 | 25 | 29 | 30 | ); 31 | 32 | 33 | const inputElement = screen.getByRole('textbox', { name: /comment/i }); 34 | await user.click(inputElement); 35 | await user.type(inputElement, 'This is a test comment'); 36 | await user.tab(); 37 | 38 | expect(mockEditSlot).toHaveBeenCalledTimes(1); 39 | expect(mockEditSlot).toHaveBeenCalledWith({ id: 1, comment: 'This is a test comment' }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/WorkoutRoutines/widgets/forms/SlotForm.tsx: -------------------------------------------------------------------------------- 1 | import { TextField } from "@mui/material"; 2 | import { LoadingProgressIcon } from "components/Core/LoadingWidget/LoadingWidget"; 3 | import { Slot } from "components/WorkoutRoutines/models/Slot"; 4 | import { useEditSlotQuery } from "components/WorkoutRoutines/queries"; 5 | import React, { useState } from "react"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | export const SlotForm = (props: { slot: Slot, routineId: number }) => { 9 | const { t } = useTranslation(); 10 | const editSlotQuery = useEditSlotQuery(props.routineId); 11 | const [slotComment, setSlotComment] = useState(props.slot.comment); 12 | const [isEditing, setIsEditing] = useState(false); 13 | 14 | const handleChange = (value: string) => { 15 | setIsEditing(true); 16 | setSlotComment(value); 17 | }; 18 | 19 | const handleBlur = () => { 20 | if (isEditing) { 21 | editSlotQuery.mutate({ id: props.slot.id, comment: slotComment }); 22 | setIsEditing(false); 23 | } 24 | }; 25 | 26 | return ( 27 | <> 28 | handleChange(e.target.value)} 36 | onBlur={handleBlur} 37 | InputProps={{ 38 | endAdornment: editSlotQuery.isPending && , 39 | }} 40 | /> 41 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { BodyWeight } from './BodyWeight/index'; 2 | export { Header } from './Header'; 3 | export { Carousel, CarouselItem } from './Carousel'; 4 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Collect all constants that access import.meta.env so it's easier to mock them 3 | */ 4 | 5 | export const IS_PROD = import.meta.env.PROD; 6 | export const PUBLIC_URL = IS_PROD ? "/static/react" : import.meta.env.VITE_PUBLIC_URL; 7 | export const SERVER_URL = IS_PROD ? "" : import.meta.env.VITE_API_SERVER; 8 | export const TIME_ZONE = import.meta.env.TIME_ZONE; 9 | export const MIN_ACCOUNT_AGE_TO_TRUST = import.meta.env.MIN_ACCOUNT_AGE_TO_TRUST; 10 | 11 | export const VITE_API_SERVER = import.meta.env.VITE_API_SERVER; 12 | export const VITE_API_KEY = import.meta.env.VITE_API_KEY; -------------------------------------------------------------------------------- /src/i18n.tsx: -------------------------------------------------------------------------------- 1 | // This code is autogenerated in the backend repo in extract-i18n.py do not edit! 2 | 3 | // Translate dynamic strings that are returned from the server 4 | // These strings such as categories or equipment are returned by the server 5 | // in English and need to be translated here in the application (there are 6 | // probably better ways to do this, but that's the way it is right now). 7 | 8 | import { useTranslation } from "react-i18next"; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 11 | const DummyComponent = () => { 12 | const [t] = useTranslation(); 13 | t("server.abs"); 14 | t("server.arms"); 15 | t("server.back"); 16 | t("server.barbell"); 17 | t("server.bench"); 18 | t("server.biceps"); 19 | t("server.body_weight"); 20 | t("server.calves"); 21 | t("server.cardio"); 22 | t("server.chest"); 23 | t("server.dumbbell"); 24 | t("server.glutes"); 25 | t("server.gym_mat"); 26 | t("server.hamstrings"); 27 | t("server.incline_bench"); 28 | t("server.kettlebell"); 29 | t("server.kilometers"); 30 | t("server.kilometers_per_hour"); 31 | t("server.lats"); 32 | t("server.legs"); 33 | t("server.max_reps"); 34 | t("server.miles"); 35 | t("server.miles_per_hour"); 36 | t("server.minutes"); 37 | t("server.plates"); 38 | t("server.pull_up_bar"); 39 | t("server.quads"); 40 | t("server.repetitions"); 41 | t("server.sz_bar"); 42 | t("server.seconds"); 43 | t("server.shoulders"); 44 | t("server.swiss_ball"); 45 | t("server.triceps"); 46 | t("server.until_failure"); 47 | t("server.kg"); 48 | t("server.lb"); 49 | t("server.none__bodyweight_exercise_"); 50 | 51 | return (

); 52 | }; -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: 'Open Sans Light', sans-serif; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | } 7 | 8 | code { 9 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 10 | monospace; 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/locales/README.md: -------------------------------------------------------------------------------- 1 | Note 2 | ==== 3 | 4 | 5 | this symlink is only needed so that `src/i18n.ts` can correctly load its contents 6 | and make typescript happy. We can't import from public since it is outside of src, 7 | and we need to keep the translations there, so they can be served from there. -------------------------------------------------------------------------------- /src/locales/en/translation.json: -------------------------------------------------------------------------------- 1 | ../../../public/locales/en/translation.json -------------------------------------------------------------------------------- /src/pages/About/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const About = () => { 4 | return ( 5 |
6 | About Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/AddExercise/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AddExerciseStepper } from "components/Exercises/Add/AddExerciseStepper"; 3 | import { useCanContributeExercises } from "components/User/queries/contribute"; 4 | import { NotEnoughRights } from "components/Exercises/Add/NotEnoughRights"; 5 | 6 | export const AddExercise = () => { 7 | const contributeQuery = useCanContributeExercises(); 8 | 9 | return <> 10 | {contributeQuery.canContribute 11 | ? 12 | : } 13 | 14 | ; 15 | }; -------------------------------------------------------------------------------- /src/pages/AddWeight/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const AddWeight = () => { 4 | return ( 5 |
6 | Add Weight Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/ApiPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const ApiPage = () => { 4 | return ( 5 |
6 | RestApi Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/Calendar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CalendarComponent from "../../components/Calendar/Components/CalendarComponent"; 3 | 4 | export const Calendar = () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | }; -------------------------------------------------------------------------------- /src/pages/CaloriesCalculator/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const CaloriesCalculator = () => { 4 | return ( 5 |
6 | Calories Calculator Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/Equipments/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Equipments = () => { 4 | return ( 5 |
6 | Equipments Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/ExerciseDetails/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ExerciseDetails } from "components/Exercises/Detail/ExerciseDetails"; 3 | 4 | export const ExerciseDetailPage = () => { 5 | return ; 6 | }; -------------------------------------------------------------------------------- /src/pages/Gallery/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Gallery = () => { 4 | return ( 5 |
6 | Gallery Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/Ingrdedients/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Ingredients = () => { 4 | return ( 5 |
6 | Ingredients Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/Login/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Login = () => { 4 | return ( 5 |
6 | Login Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/Preferences/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Preferences = () => { 4 | return ( 5 |
6 | Preferences Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/PublicTemplate/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const PublicTemplate = () => { 4 | return ( 5 |
6 | Public Template 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/TemplatePage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const TemplatePage = () => { 4 | return ( 5 |
6 | Your Template 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/WeightOverview/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BodyWeight } from 'components'; 3 | 4 | export const WeightOverview = () => { 5 | return ; 6 | }; -------------------------------------------------------------------------------- /src/pages/Workout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const Workout = () => { 4 | return ( 5 |
6 | Workout Page 7 |
8 | ); 9 | }; -------------------------------------------------------------------------------- /src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { About } from './About'; 2 | export { AddExercise } from './AddExercise'; 3 | export { AddWeight } from './AddWeight'; 4 | export { Calendar } from './Calendar'; 5 | export { CaloriesCalculator } from './CaloriesCalculator'; 6 | export { Equipments } from './Equipments'; 7 | export { Gallery } from './Gallery'; 8 | export { Ingredients } from './Ingrdedients'; 9 | export { Login } from './Login'; 10 | export { ExerciseDetails } from '../components/Exercises/Detail/ExerciseDetails'; 11 | export { Preferences } from './Preferences'; 12 | export { PublicTemplate } from './PublicTemplate'; 13 | export { ApiPage } from './ApiPage'; 14 | export { TemplatePage } from './TemplatePage'; 15 | export { WeightOverview } from './WeightOverview'; 16 | export { Workout } from './Workout'; 17 | -------------------------------------------------------------------------------- /src/permissions/index.ts: -------------------------------------------------------------------------------- 1 | export enum WgerPermissions { 2 | // Exercises 3 | EDIT_EXERCISE = 'exercises.change_exercise', 4 | DELETE_EXERCISE = 'exercises.delete_exercise', 5 | 6 | ADD_IMAGE = 'exercises.add_exerciseimage', 7 | EDIT_IMAGE = 'exercises.change_exerciseimage', 8 | DELETE_IMAGE = 'exercises.delete_exerciseimage', 9 | 10 | ADD_VIDEO = 'exercises.add_exercisevideo', 11 | EDIT_VIDEO = 'exercises.change_exercisevideo', 12 | DELETE_VIDEO = 'exercises.delete_exercisevideo', 13 | } 14 | -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/services/alias.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Alias } from "components/Exercises/models/alias"; 3 | import { postAlias } from "services"; 4 | import { deleteAlias } from "services/alias"; 5 | 6 | jest.mock("axios"); 7 | 8 | 9 | describe("Exercise translation service API tests", () => { 10 | 11 | 12 | test('POST a new alias', async () => { 13 | 14 | // Arrange 15 | const response = { 16 | "id": 200, 17 | "uuid": "eb18288d-4ca3-4c54-8279-343b110d86e0", 18 | "exercise": 100, 19 | "alias": "Elbow dislocator", 20 | 21 | }; 22 | (axios.post as jest.Mock).mockImplementation(() => Promise.resolve({ data: response })); 23 | 24 | // Act 25 | const result = await postAlias( 26 | 100, 27 | "Elbow dislocator", 28 | ); 29 | 30 | // Assert 31 | expect(axios.post).toHaveBeenCalledTimes(1); 32 | expect(result).toEqual(new Alias(200, "eb18288d-4ca3-4c54-8279-343b110d86e0", "Elbow dislocator")); 33 | }); 34 | 35 | test('DELETE an existing alias', async () => { 36 | 37 | // Arrange 38 | (axios.delete as jest.Mock).mockImplementation(() => Promise.resolve({ status: 204 })); 39 | 40 | // Act 41 | const result = await deleteAlias(100); 42 | 43 | // Assert 44 | expect(axios.delete).toHaveBeenCalledTimes(1); 45 | expect(result).toEqual(204); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/services/alias.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Alias, AliasAdapter } from "components/Exercises/models/alias"; 3 | import { makeHeader, makeUrl } from "utils/url"; 4 | 5 | export const ALIAS_PATH = 'exercisealias'; 6 | 7 | 8 | /* 9 | * Create a new alias 10 | */ 11 | export const postAlias = async (exerciseId: number, alias: string): Promise => { 12 | const url = makeUrl(ALIAS_PATH); 13 | const response = await axios.post( 14 | url, 15 | { exercise: exerciseId, alias: alias }, 16 | { headers: makeHeader() } 17 | ); 18 | const adapter = new AliasAdapter(); 19 | return adapter.fromJson(response.data); 20 | }; 21 | 22 | /* 23 | * Delete a given alias 24 | */ 25 | export const deleteAlias = async (aliasId: number): Promise => { 26 | const response = await axios.delete( 27 | makeUrl(ALIAS_PATH, { id: aliasId }), 28 | { headers: makeHeader() } 29 | ); 30 | 31 | return response.status; 32 | }; 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/services/base_config.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { BaseConfigAdapter } from "components/WorkoutRoutines/models/BaseConfig"; 3 | import { editBaseConfig, EditBaseConfigParams } from "services/base_config"; 4 | 5 | jest.mock('axios'); 6 | const mockedAxios = axios as jest.Mocked; 7 | 8 | describe('editBaseConfig', () => { 9 | const mockBaseConfigData = { 10 | id: 1, 11 | value: 100, 12 | // eslint-disable-next-line camelcase 13 | slot_entry: 1, 14 | }; 15 | 16 | const mockEditBaseConfigParams: EditBaseConfigParams = { 17 | id: 1, 18 | value: 120, 19 | }; 20 | 21 | const mockUrl = '/api/baseconfig/'; 22 | 23 | beforeEach(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | test('should update a base config and return the updated config', async () => { 28 | mockedAxios.patch.mockResolvedValue({ data: mockBaseConfigData }); 29 | 30 | const updatedConfig = await editBaseConfig(mockEditBaseConfigParams, mockUrl); 31 | 32 | expect(axios.patch).toHaveBeenCalledTimes(1); 33 | expect(axios.patch).toHaveBeenCalledWith( 34 | expect.any(String), 35 | mockEditBaseConfigParams, 36 | expect.any(Object) 37 | ); 38 | expect(updatedConfig).toEqual(new BaseConfigAdapter().fromJson(mockBaseConfigData)); 39 | }); 40 | 41 | test('should handle errors gracefully', async () => { 42 | const errorMessage = 'Network Error'; 43 | mockedAxios.patch.mockRejectedValue(new Error(errorMessage)); 44 | 45 | await expect(editBaseConfig(mockEditBaseConfigParams, mockUrl)).rejects.toThrowError(errorMessage); 46 | expect(axios.patch).toHaveBeenCalledTimes(1); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/services/category.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Category } from "components/Exercises/models/category"; 3 | import { getCategories } from "services"; 4 | 5 | jest.mock("axios"); 6 | 7 | describe("category service tests", () => { 8 | 9 | test('GET category entries', async () => { 10 | 11 | // Arrange 12 | const response = { 13 | count: 3, 14 | next: null, 15 | previous: null, 16 | results: [ 17 | { 18 | "id": 10, 19 | "name": "Abs" 20 | }, 21 | { 22 | "id": 8, 23 | "name": "Arms" 24 | }, 25 | { 26 | "id": 12, 27 | "name": "Back" 28 | }, 29 | ] 30 | }; 31 | 32 | // Act 33 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: response })); 34 | const result = await getCategories(); 35 | 36 | // Assert 37 | expect(axios.get).toHaveBeenCalledTimes(1); 38 | expect(result).toStrictEqual([ 39 | new Category(10, "Abs"), 40 | new Category(8, "Arms"), 41 | new Category(12, "Back"), 42 | ]); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/services/category.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Category, CategoryAdapter } from "components/Exercises/models/category"; 3 | import { ApiCategoryType } from 'types'; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | import { ResponseType } from "./responseType"; 6 | 7 | export const CATEGORY_PATH = 'exercisecategory'; 8 | 9 | 10 | /* 11 | * Fetch all categories 12 | */ 13 | export const getCategories = async (): Promise => { 14 | const url = makeUrl(CATEGORY_PATH); 15 | const { data: receivedCategories } = await axios.get>(url, { 16 | headers: makeHeader(), 17 | }); 18 | const adapter = new CategoryAdapter(); 19 | return receivedCategories.results.map(c => adapter.fromJson(c)); 20 | }; 21 | 22 | -------------------------------------------------------------------------------- /src/services/equipment.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Equipment } from "components/Exercises/models/equipment"; 3 | import { getEquipment } from "services"; 4 | 5 | jest.mock("axios"); 6 | 7 | 8 | describe("equipment service tests", () => { 9 | 10 | test('GET equipment entries', async () => { 11 | 12 | // Arrange 13 | const response = { 14 | count: 3, 15 | next: null, 16 | previous: null, 17 | results: [ 18 | { 19 | "id": 1, 20 | "name": "Barbell" 21 | }, 22 | { 23 | "id": 8, 24 | "name": "Bench" 25 | }, 26 | { 27 | "id": 3, 28 | "name": "Dumbbell" 29 | }, 30 | ] 31 | }; 32 | 33 | 34 | // Act 35 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: response })); 36 | const result = await getEquipment(); 37 | 38 | // Assert 39 | expect(axios.get).toHaveBeenCalledTimes(1); 40 | expect(result).toStrictEqual([ 41 | new Equipment(1, "Barbell"), 42 | new Equipment(8, "Bench"), 43 | new Equipment(3, "Dumbbell"), 44 | ]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/services/equipment.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Equipment, EquipmentAdapter } from "components/Exercises/models/equipment"; 3 | import { ApiEquipmentType } from 'types'; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | import { ResponseType } from "./responseType"; 6 | 7 | export const EQUIPMENT_PATH = 'equipment'; 8 | 9 | 10 | /* 11 | * Fetch all equipment 12 | */ 13 | export const getEquipment = async (): Promise => { 14 | const url = makeUrl(EQUIPMENT_PATH); 15 | const { data: receivedEquipment } = await axios.get>(url, { 16 | headers: makeHeader(), 17 | }); 18 | const adapter = new EquipmentAdapter(); 19 | return receivedEquipment.results.map(e => adapter.fromJson(e)); 20 | }; 21 | -------------------------------------------------------------------------------- /src/services/ingredientweightunit.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { NutritionWeightUnit, NutritionWeightUnitAdapter } from "components/Nutrition/models/weightUnit"; 3 | import { ApiIngredientWeightUnitType } from 'types'; 4 | import { ApiPath } from "utils/consts"; 5 | import { makeHeader, makeUrl } from "utils/url"; 6 | import { ResponseType } from "./responseType"; 7 | 8 | 9 | export const getWeightUnit = async (unitId: number | null): Promise => { 10 | if (unitId === null) { 11 | return null; 12 | } 13 | 14 | const { data: receivedUnit } = await axios.get( 15 | makeUrl(ApiPath.INGREDIENT_WEIGHT_UNIT, { id: unitId }), 16 | { headers: makeHeader() } 17 | ); 18 | return new NutritionWeightUnitAdapter().fromJson(receivedUnit); 19 | }; 20 | 21 | export const getWeightUnits = async (ingredientId: number): Promise => { 22 | const { data: receivedUnits } = await axios.get>( 23 | makeUrl(ApiPath.INGREDIENT_WEIGHT_UNIT, { query: { ingredient: ingredientId } }), 24 | { headers: makeHeader() } 25 | ); 26 | const adapter = new NutritionWeightUnitAdapter(); 27 | return receivedUnits.results.map(weight => adapter.fromJson(weight)); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /src/services/language.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Language, LanguageAdapter } from "components/Exercises/models/language"; 3 | import { ApiLanguageType } from 'types'; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | import { ResponseType } from "./responseType"; 6 | 7 | export const API_LANGUAGE_PATH = 'language'; 8 | 9 | 10 | /* 11 | * Fetch all languages 12 | */ 13 | export const getLanguages = async (): Promise => { 14 | const url = makeUrl(API_LANGUAGE_PATH); 15 | const { data: receivedLanguages } = await axios.get>(url, { 16 | headers: makeHeader(), 17 | }); 18 | const adapter = new LanguageAdapter(); 19 | return receivedLanguages.results.map(l => adapter.fromJson(l)); 20 | }; 21 | 22 | /* 23 | * Searches for the language with the given short name 24 | */ 25 | export const getLanguageByShortName = (name: string, availableLanguages: Language[]): Language | undefined => { 26 | 27 | // If the name is in the form of "en-US", remove the country code 28 | const shortName = name.split('-')[0]; 29 | 30 | const language = availableLanguages.find(l => l.nameShort === shortName); 31 | if (language) { 32 | return language; 33 | } 34 | return undefined; 35 | }; -------------------------------------------------------------------------------- /src/services/mealItem.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { MealItem, MealItemAdapter } from "components/Nutrition/models/mealItem"; 3 | import { ApiPath } from "utils/consts"; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | 6 | 7 | export interface AddMealItemParams { 8 | meal: number, 9 | ingredient: number, 10 | weight_unit: number | null, 11 | amount: number 12 | } 13 | 14 | export interface EditMealItemParams extends AddMealItemParams { 15 | id: number, 16 | } 17 | 18 | 19 | export const addMealItem = async (data: AddMealItemParams): Promise => { 20 | const response = await axios.post( 21 | makeUrl(ApiPath.MEAL_ITEM), 22 | data, 23 | { headers: makeHeader() } 24 | ); 25 | 26 | return new MealItemAdapter().fromJson(response.data); 27 | }; 28 | 29 | export const editMealItem = async (data: EditMealItemParams): Promise => { 30 | const response = await axios.patch( 31 | makeUrl(ApiPath.MEAL_ITEM, { id: data.id }), 32 | data, 33 | { headers: makeHeader() } 34 | ); 35 | 36 | return new MealItemAdapter().fromJson(response.data); 37 | }; 38 | 39 | export const deleteMealItem = async (id: number): Promise => { 40 | await axios.delete( 41 | makeUrl(ApiPath.MEAL_ITEM, { id: id }), 42 | { headers: makeHeader() }, 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /src/services/muscles.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Muscle } from "components/Exercises/models/muscle"; 3 | import { getMuscles } from "services"; 4 | 5 | jest.mock("axios"); 6 | 7 | describe("muscle service tests", () => { 8 | 9 | test('GET muscle entries', async () => { 10 | 11 | const muscleResponse = { 12 | count: 2, 13 | next: null, 14 | previous: null, 15 | results: [ 16 | { 17 | "id": 2, 18 | "name": "Anterior deltoid", 19 | "name_en": "Shoulders", 20 | "is_front": true, 21 | "image_url_main": "/static/images/muscles/main/muscle-2.svg", 22 | "image_url_secondary": "/static/images/muscles/secondary/muscle-2.svg" 23 | }, 24 | { 25 | "id": 1, 26 | "name": "Biceps brachii", 27 | "name_en": "Biceps", 28 | "is_front": false, 29 | "image_url_main": "/static/images/muscles/main/muscle-1.svg", 30 | "image_url_secondary": "/static/images/muscles/secondary/muscle-1.svg" 31 | }, 32 | ] 33 | }; 34 | 35 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: muscleResponse })); 36 | 37 | const result = await getMuscles(); 38 | expect(axios.get).toHaveBeenCalledTimes(1); 39 | 40 | expect(result).toStrictEqual([ 41 | new Muscle(2, "Anterior deltoid", "Shoulders", true), 42 | new Muscle(1, "Biceps brachii", "Biceps", false), 43 | ]); 44 | }); 45 | }); 46 | 47 | -------------------------------------------------------------------------------- /src/services/muscles.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Muscle, MuscleAdapter } from "components/Exercises/models/muscle"; 3 | import { ApiMuscleType } from 'types'; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | import { ResponseType } from "./responseType"; 6 | 7 | export const MUSCLES_PATH = 'muscle'; 8 | 9 | /* 10 | * Fetch all muscles 11 | */ 12 | export const getMuscles = async (): Promise => { 13 | const url = makeUrl(MUSCLES_PATH); 14 | const { data: receivedMuscles } = await axios.get>(url, { 15 | headers: makeHeader(), 16 | }); 17 | const adapter = new MuscleAdapter(); 18 | return receivedMuscles.results.map(m => adapter.fromJson(m)); 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /src/services/note.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Note, NoteAdapter } from "components/Exercises/models/note"; 3 | import { makeHeader, makeUrl } from "utils/url"; 4 | 5 | export const API_NOTE_PATH = 'exercisecomment'; 6 | 7 | 8 | /* 9 | * Create a new note 10 | */ 11 | export const addNote = async (note: Note): Promise => { 12 | const adapter = new NoteAdapter(); 13 | const url = makeUrl(API_NOTE_PATH); 14 | const response = await axios.post( 15 | url, 16 | adapter.toJson(note), 17 | { headers: makeHeader() } 18 | ); 19 | 20 | return adapter.fromJson(response.data); 21 | }; 22 | 23 | /* 24 | * Edit an existing note 25 | */ 26 | export const editNote = async (note: Note): Promise => { 27 | const adapter = new NoteAdapter(); 28 | 29 | const url = makeUrl(API_NOTE_PATH, { id: note.id! }); 30 | const response = await axios.patch( 31 | url, 32 | adapter.toJson(note), 33 | { headers: makeHeader() } 34 | ); 35 | 36 | return adapter.fromJson(response.data); 37 | }; 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/services/nutritionalDiary.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { getNutritionalDiaryEntries } from "services"; 3 | 4 | jest.mock("axios"); 5 | 6 | describe("Nutritional plan diary service tests", () => { 7 | 8 | beforeEach(() => { 9 | jest.resetAllMocks(); 10 | }); 11 | 12 | test('Correctly filters diary entries', async () => { 13 | (axios.get as jest.Mock).mockImplementation(() => { 14 | return Promise.resolve({ 15 | data: { 16 | count: 2, 17 | next: null, 18 | previous: null, 19 | results: [] 20 | } 21 | }); 22 | }); 23 | 24 | await getNutritionalDiaryEntries({ 25 | filtersetQuery: { foo: "bar" }, 26 | }); 27 | 28 | // No results, so no loading of ingredients or weight units 29 | expect(axios.get).toHaveBeenCalledTimes(1); 30 | expect(axios.get).toHaveBeenNthCalledWith(1, 31 | expect.stringContaining('foo=bar'), 32 | expect.anything() 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/services/nutritionalPlan.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { NutritionalPlan } from "components/Nutrition/models/nutritionalPlan"; 3 | import { getNutritionalPlansSparse } from "services/nutritionalPlan"; 4 | 5 | jest.mock("axios"); 6 | 7 | describe("Nutritional plan service tests", () => { 8 | 9 | test('GET plans - sparse', async () => { 10 | 11 | const planResponse = { 12 | count: 2, 13 | next: null, 14 | previous: null, 15 | results: [ 16 | { 17 | "id": 72559, 18 | "creation_date": "2023-05-26", 19 | "description": "first plan", 20 | "only_logging": true, 21 | }, 22 | { 23 | "id": 60131, 24 | "creation_date": "2022-06-01", 25 | "description": "", 26 | "only_logging": false, 27 | }, 28 | { 29 | "id": 24752, 30 | "creation_date": "2023-08-01", 31 | "description": "", 32 | "only_logging": false, 33 | }, 34 | ] 35 | }; 36 | 37 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: planResponse })); 38 | 39 | const result = await getNutritionalPlansSparse(); 40 | expect(axios.get).toHaveBeenCalledTimes(1); 41 | 42 | expect(result).toStrictEqual([ 43 | new NutritionalPlan(72559, new Date('2023-05-26'), 'first plan', true), 44 | new NutritionalPlan(60131, new Date('2022-06-01'), '', false), 45 | new NutritionalPlan(24752, new Date('2023-08-01'), '', false), 46 | ]); 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/services/permission.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { makeHeader, makeUrl } from "utils/url"; 3 | 4 | export const PERMISSION_PATH = 'check-permission'; 5 | 6 | 7 | /* 8 | * Checks if the user has a given permission 9 | */ 10 | export const checkPermission = async (permission: string): Promise => { 11 | const url = makeUrl(PERMISSION_PATH, { query: { 'permission': permission } }); 12 | const response = await axios.get( 13 | url, 14 | { headers: makeHeader() } 15 | ); 16 | 17 | // User is logged out, etc. 18 | if (response.status === 400) { 19 | return false; 20 | } 21 | 22 | return response.data.result; 23 | }; 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/services/permissions.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { checkPermission } from "services/permission"; 3 | 4 | jest.mock("axios"); 5 | 6 | 7 | describe("Permission API tests", () => { 8 | 9 | 10 | test('Check an exising permission', async () => { 11 | 12 | // Arrange 13 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: { "result": true } })); 14 | 15 | // Act 16 | const result = await checkPermission('exercises.delete_exercise'); 17 | 18 | // Assert 19 | expect(axios.get).toHaveBeenCalled(); 20 | expect(result).toEqual(true); 21 | }); 22 | 23 | test('Check permission logged out user', async () => { 24 | // Arrange 25 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ status: 400 })); 26 | 27 | // Act 28 | const result = await checkPermission('exercises.sus_scrofa'); 29 | 30 | // Assert 31 | expect(axios.get).toHaveBeenCalled(); 32 | expect(result).toEqual(false); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/services/profile.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { getProfile } from "services"; 3 | import { testProfileApiResponse, testProfileDataVerified } from "tests/userTestdata"; 4 | 5 | jest.mock("axios"); 6 | 7 | 8 | describe("Profile API tests", () => { 9 | 10 | beforeEach(() => { 11 | jest.resetAllMocks(); 12 | }); 13 | 14 | 15 | test('get the user profile (logged in)', async () => { 16 | 17 | // Arrange 18 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ data: testProfileApiResponse })); 19 | 20 | // Act 21 | const result = await getProfile(); 22 | 23 | // Assert 24 | expect(axios.get).toHaveBeenCalledTimes(1); 25 | expect(result).toEqual(testProfileDataVerified); 26 | }); 27 | 28 | test('get the user profile (logged out)', async () => { 29 | 30 | // Arrange 31 | (axios.get as jest.Mock).mockImplementation(() => Promise.resolve({ status: 403 })); 32 | 33 | // Act 34 | const result = await getProfile(); 35 | 36 | // Assert 37 | expect(axios.get).toHaveBeenCalledTimes(1); 38 | expect(result).toEqual(null); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/services/profile.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { EditProfileParams, Profile, ProfileAdapter } from "components/User/models/profile"; 3 | import { ApiPath } from "utils/consts"; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | 6 | export const API_PROFILE_PATH = 'userprofile'; 7 | 8 | 9 | /* 10 | * Loads the user's profile 11 | */ 12 | export const getProfile = async (): Promise => { 13 | const url = makeUrl(API_PROFILE_PATH); 14 | const adapter = new ProfileAdapter(); 15 | 16 | // We need to manually catch the error, otherwise react-query will retry the 17 | // query and report an error in the end 18 | try { 19 | const response = await axios.get( 20 | url, 21 | { headers: makeHeader() } 22 | ); 23 | return adapter.fromJson(response.data); 24 | } catch (error) { 25 | return null; 26 | } 27 | }; 28 | 29 | /* 30 | * Edits the user's profile 31 | */ 32 | export const editProfile = async (data: Partial): Promise => { 33 | const response = await axios.post( 34 | makeUrl(ApiPath.API_PROFILE_PATH), 35 | data, 36 | { headers: makeHeader() } 37 | ); 38 | 39 | return new ProfileAdapter().fromJson(response.data); 40 | }; 41 | -------------------------------------------------------------------------------- /src/services/responseType.ts: -------------------------------------------------------------------------------- 1 | import { Exercise } from "components/Exercises/models/exercise"; 2 | 3 | export interface ResponseType { 4 | count: number, 5 | next: number | null, 6 | previous: number | null, 7 | results: T[] 8 | } 9 | 10 | export interface ExerciseSearchResponse { 11 | value: string, 12 | data: { 13 | id: number, 14 | base_id: number, 15 | name: string, 16 | category: string, 17 | image: string | null, 18 | image_thumbnail: string | null, 19 | }, 20 | exercise?: Exercise 21 | } 22 | 23 | export interface ExerciseSearchType { 24 | suggestions: ExerciseSearchResponse[]; 25 | } 26 | 27 | export interface IngredientSearchResponse { 28 | value: string, 29 | data: { 30 | id: number, 31 | name: string, 32 | image: string | null, 33 | image_thumbnail: string | null, 34 | } 35 | } 36 | 37 | export interface IngredientSearchType { 38 | suggestions: IngredientSearchResponse[]; 39 | } -------------------------------------------------------------------------------- /src/services/slot.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Slot, SlotAdapter } from "components/WorkoutRoutines/models/Slot"; 3 | import { ApiPath } from "utils/consts"; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | 6 | 7 | export interface AddSlotParams { 8 | day: number; 9 | order: number; 10 | comment?: string; 11 | } 12 | 13 | export interface EditSlotParams extends Partial { 14 | id: number, 15 | } 16 | 17 | export interface EditSlotOrderParam { 18 | id: number, 19 | order: number 20 | } 21 | 22 | /* 23 | * Creates a new Slot 24 | */ 25 | export const addSlot = async (data: AddSlotParams): Promise => { 26 | const response = await axios.post( 27 | makeUrl(ApiPath.SLOT), 28 | data, 29 | { headers: makeHeader() } 30 | ); 31 | 32 | return new SlotAdapter().fromJson(response.data); 33 | }; 34 | /* 35 | * Update a Slot 36 | */ 37 | export const editSlot = async (data: EditSlotParams): Promise => { 38 | const response = await axios.patch( 39 | makeUrl(ApiPath.SLOT, { id: data.id }), 40 | data, 41 | { headers: makeHeader() } 42 | ); 43 | 44 | return new SlotAdapter().fromJson(response.data); 45 | }; 46 | 47 | export const editSlotOrder = async (data: EditSlotOrderParam[]): Promise => { 48 | 49 | for (const value of data) { 50 | await axios.patch( 51 | makeUrl(ApiPath.SLOT, { id: value.id }), 52 | { order: value.order }, 53 | { headers: makeHeader() } 54 | ); 55 | } 56 | }; 57 | 58 | /* 59 | * Delete an existing lot 60 | */ 61 | export const deleteSlot = async (id: number): Promise => { 62 | await axios.delete( 63 | makeUrl(ApiPath.SLOT, { id: id }), 64 | { headers: makeHeader() } 65 | ); 66 | }; 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/services/slot_entry.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { SlotEntry, slotEntryAdapter, SlotEntryType } from "components/WorkoutRoutines/models/SlotEntry"; 3 | import { ApiPath } from "utils/consts"; 4 | import { makeHeader, makeUrl } from "utils/url"; 5 | 6 | 7 | export interface AddSlotEntryParams { 8 | slot: number, 9 | exercise: number, 10 | type: SlotEntryType, 11 | order: number, 12 | comment?: string, 13 | repetition_unit?: number, 14 | repetition_rounding?: number | null, 15 | weight_unit?: number, 16 | weight_rounding?: number | null, 17 | } 18 | 19 | export interface EditSlotEntryParams extends Partial { 20 | id: number, 21 | } 22 | 23 | /* 24 | * Update a Slot entry 25 | */ 26 | export const editSlotEntry = async (data: EditSlotEntryParams): Promise => { 27 | const response = await axios.patch( 28 | makeUrl(ApiPath.SLOT_ENTRY, { id: data.id }), 29 | data, 30 | { headers: makeHeader() } 31 | ); 32 | 33 | return slotEntryAdapter.fromJson(response.data); 34 | }; 35 | 36 | /* 37 | * Delete an existing slot entry 38 | */ 39 | export const deleteSlotEntry = async (id: number): Promise => { 40 | await axios.delete( 41 | makeUrl(ApiPath.SLOT_ENTRY, { id: id }), 42 | { headers: makeHeader() } 43 | ); 44 | }; 45 | 46 | /* 47 | * Creates a new slot entry 48 | */ 49 | export const addSlotEntry = async (data: AddSlotEntryParams): Promise => { 50 | const response = await axios.post( 51 | makeUrl(ApiPath.SLOT_ENTRY), 52 | data, 53 | { headers: makeHeader() } 54 | ); 55 | 56 | return slotEntryAdapter.fromJson(response.data); 57 | }; 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/services/variation.test.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { addVariation } from "services/variation"; 3 | 4 | jest.mock("axios"); 5 | 6 | 7 | describe("Variation service API tests", () => { 8 | 9 | test('POST a new variation', async () => { 10 | 11 | // Arrange 12 | const response = { 13 | "id": 123 14 | }; 15 | (axios.post as jest.Mock).mockImplementation(() => Promise.resolve({ data: response })); 16 | 17 | // Act 18 | const result = await addVariation(); 19 | 20 | // Assert 21 | expect(axios.post).toHaveBeenCalledTimes(1); 22 | expect(result).toEqual(123); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/services/variation.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { makeHeader, makeUrl } from "utils/url"; 3 | 4 | export const VARIATION_PATH = 'variation'; 5 | 6 | 7 | /* 8 | * Create a new exercise base 9 | */ 10 | export const addVariation = async (): Promise => { 11 | 12 | const url = makeUrl(VARIATION_PATH); 13 | const response = await axios.post(url, {}, { 14 | headers: makeHeader(), 15 | }); 16 | 17 | return response.data.id; 18 | }; -------------------------------------------------------------------------------- /src/services/video.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { ExerciseVideo, ExerciseVideoAdapter } from "components/Exercises/models/video"; 3 | import { makeHeader, makeUrl } from "utils/url"; 4 | 5 | export const VIDEO_PATH = 'video'; 6 | 7 | 8 | /* 9 | * Post a new exercise video 10 | */ 11 | export const postExerciseVideo = async (exerciseId: number, author: string, video: File): Promise => { 12 | const url = makeUrl(VIDEO_PATH); 13 | const headers = makeHeader(); 14 | headers['Content-Type'] = 'multipart/form-data'; 15 | 16 | const response = await axios.post( 17 | url, 18 | // eslint-disable-next-line camelcase 19 | { exercise: exerciseId, license_author: author, video: video }, 20 | { headers: headers } 21 | ); 22 | return new ExerciseVideoAdapter().fromJson(response.data); 23 | }; 24 | 25 | /* 26 | * Delete an exercise video 27 | */ 28 | export const deleteExerciseVideo = async (videoId: number): Promise => { 29 | const url = makeUrl(VIDEO_PATH, { id: videoId }); 30 | const headers = makeHeader(); 31 | const response = await axios.delete(url, { headers: headers }); 32 | 33 | return response.status; 34 | }; 35 | -------------------------------------------------------------------------------- /src/services/workoutUnits.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { RepetitionUnit, RepetitionUnitAdapter } from "components/WorkoutRoutines/models/RepetitionUnit"; 3 | import { WeightUnit, WeightUnitAdapter } from "components/WorkoutRoutines/models/WeightUnit"; 4 | import { ApiSettingRepUnitType, ApiSettingWeightUnitType } from 'types'; 5 | import { makeHeader, makeUrl } from "utils/url"; 6 | import { ResponseType } from "./responseType"; 7 | 8 | export const API_SETTING_REP_UNIT_PATH = 'setting-repetitionunit'; 9 | export const API_SETTING_WEIGHT_UNIT_PATH = 'setting-weightunit'; 10 | 11 | 12 | export const getRoutineRepUnits = async (): Promise => { 13 | const url = makeUrl(API_SETTING_REP_UNIT_PATH); 14 | const { data: receivedUnits } = await axios.get>(url, { 15 | headers: makeHeader(), 16 | }); 17 | const adapter = new RepetitionUnitAdapter(); 18 | return receivedUnits.results.map(l => adapter.fromJson(l)); 19 | }; 20 | 21 | export const getRoutineWeightUnits = async (): Promise => { 22 | const url = makeUrl(API_SETTING_WEIGHT_UNIT_PATH); 23 | const { data: receivedUnits } = await axios.get>(url, { 24 | headers: makeHeader(), 25 | }); 26 | const adapter = new WeightUnitAdapter(); 27 | return receivedUnits.results.map(l => adapter.fromJson(l)); 28 | }; 29 | 30 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | import i18n from 'i18next'; 7 | import { initReactI18next } from 'react-i18next'; 8 | import { TextDecoder, TextEncoder } from 'util'; 9 | 10 | // Mock the translations 11 | i18n.use(initReactI18next).init({ 12 | lng: 'en', 13 | fallbackLng: 'en', 14 | 15 | // have a common namespace used around the full app 16 | ns: ['translations'], 17 | defaultNS: 'translations', 18 | 19 | // debug: true, 20 | 21 | interpolation: { 22 | escapeValue: false, // not needed for react!! 23 | }, 24 | 25 | resources: { en: { translations: {} } }, 26 | }); 27 | 28 | jest.mock('./config', () => { 29 | return { 30 | IS_PROD: false, 31 | SERVER_URL: 'https://example.com', 32 | VITE_API_SERVER: 'https://example.com', 33 | VITE_API_KEY: '122333444455555666666' 34 | }; 35 | }); 36 | 37 | global.TextEncoder = TextEncoder as any; 38 | global.TextDecoder = TextDecoder as any; -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | notificationReducer, 3 | setNotification 4 | } from 'state/notificationReducer'; 5 | export { NotificationStateProvider, useWeightStateValue } from 'state/notificationState'; 6 | export type { NotificationState } from 'state/notificationState'; 7 | export { SetNotificationState, SetExerciseSubmissionState } from 'state/stateTypes'; 8 | 9 | 10 | export type { ExerciseSubmissionState } from 'state/exerciseSubmissionState'; 11 | export { 12 | ExerciseSubmissionStateProvider, useExerciseSubmissionStateValue, exerciseSubmissionInitialState 13 | } from 'state/exerciseSubmissionState'; 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/state/notificationReducer.ts: -------------------------------------------------------------------------------- 1 | import { NotificationState } from 'state'; 2 | import { Notification } from "types"; 3 | import { SetNotificationState } from "./stateTypes"; 4 | 5 | export type WeightAction = { 6 | type: SetNotificationState, 7 | payload: Notification 8 | } 9 | 10 | 11 | export const setNotification = (notification: Notification): WeightAction => { 12 | return { type: SetNotificationState.SET_NOTIFICATION, payload: notification }; 13 | }; 14 | 15 | 16 | export const notificationReducer = (state: NotificationState, action: WeightAction): NotificationState => { 17 | 18 | switch (action.type) { 19 | 20 | case SetNotificationState.SET_NOTIFICATION: 21 | return { 22 | ...state, 23 | notification: action.payload as Notification 24 | }; 25 | 26 | default: 27 | return state; 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/state/notificationState.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useContext, useReducer } from "react"; 2 | import { notificationReducer, WeightAction } from "state/notificationReducer"; 3 | import { Notification } from "types"; 4 | 5 | export type NotificationState = { 6 | notification: Notification 7 | }; 8 | 9 | const initialState: NotificationState = { 10 | notification: { notify: false, message: "", severity: undefined, title: "", type: undefined } 11 | }; 12 | 13 | export const WeightStateContext = createContext<[NotificationState, React.Dispatch]>([ 14 | initialState, 15 | () => initialState 16 | ]); 17 | 18 | type StateProp = { 19 | children: React.ReactElement 20 | }; 21 | 22 | export const NotificationStateProvider: React.FC = ({ children }: StateProp) => { 23 | const [state, dispatch] = useReducer(notificationReducer, initialState); 24 | 25 | return ( 26 | 27 | {children} 28 | 29 | ); 30 | }; 31 | 32 | export const useWeightStateValue = () => useContext(WeightStateContext); 33 | -------------------------------------------------------------------------------- /src/state/stateTypes.ts: -------------------------------------------------------------------------------- 1 | export enum SetNotificationState { 2 | SET_NOTIFICATION 3 | } 4 | 5 | export enum SetExerciseSubmissionState { 6 | RESET, 7 | 8 | SET_NAME_EN, 9 | SET_ALIASES_EN, 10 | SET_DESCRIPTION_EN, 11 | SET_NOTES_EN, 12 | SET_CATEGORY, 13 | SET_EQUIPMENT, 14 | SET_PRIMARY_MUSCLES, 15 | SET_MUSCLES_SECONDARY, 16 | SET_VARIATION_ID, 17 | SET_NEW_VARIATION_BASE_ID, 18 | SET_LANGUAGE, 19 | SET_NAME_I18N, 20 | SET_ALIASES_I18N, 21 | SET_DESCRIPTION_I18N, 22 | SET_NOTES_I18N, 23 | SET_IMAGES 24 | } 25 | -------------------------------------------------------------------------------- /src/tests/api/ingredientSearch.ts: -------------------------------------------------------------------------------- 1 | export const INGREDIENT_SEARCH = [ 2 | { 3 | "value": "Baguette with cheese", 4 | "data": { 5 | "id": 1234, 6 | "name": "Baguette with cheese", 7 | "category": "Desserts", 8 | "image": null, 9 | "image_thumbnail": null 10 | } 11 | }, 12 | { 13 | "value": "Blue cheese", 14 | "data": { 15 | "id": 4321, 16 | "name": "Blue cheese", 17 | "category": "Beverages", 18 | "image": null, 19 | "image_thumbnail": null 20 | } 21 | } 22 | ]; -------------------------------------------------------------------------------- /src/tests/exercises/searchResponse.ts: -------------------------------------------------------------------------------- 1 | export const searchResponse = [ 2 | { 3 | "value": "Crunches an Negativbank", 4 | "data": { 5 | "id": 1149, 6 | "base_id": 998, 7 | "name": "Crunches an Negativbank", 8 | "category": "Bauch", 9 | "image": null, 10 | "image_thumbnail": null 11 | } 12 | }, { 13 | "value": "Crunches am Seil", 14 | "data": { 15 | "id": 1213, 16 | "base_id": 979, 17 | "name": "Crunches am Seil", 18 | "category": "Brust", 19 | "image": null, 20 | "image_thumbnail": null 21 | } 22 | } 23 | ]; -------------------------------------------------------------------------------- /src/tests/ingredientTestdata.ts: -------------------------------------------------------------------------------- 1 | import { Ingredient } from "components/Nutrition/models/Ingredient"; 2 | 3 | export const TEST_INGREDIENT_1 = new Ingredient( 4 | 101, 5 | "3af59658-7d83-4b0a-82f9-9f0edc0f00d5", 6 | "00975957", 7 | "0% fat Greek style yogurt", 8 | 1, 9 | 5.700, 10 | 18.600, 11 | 10.200, 12 | 3.300, 13 | 0.900, 14 | 0.500, 15 | 0.040, 16 | ); 17 | 18 | export const TEST_INGREDIENT_2 = new Ingredient( 19 | 102, 20 | "18985fac-a519-4ebe-9017-b2fc3be91357", 21 | "4005967511077", 22 | "1001 Nacht Haferbrei", 23 | 351, 24 | 10.400, 25 | 61.100, 26 | 17.100, 27 | 5.100, 28 | 1.000, 29 | 9.300, 30 | 0.008, 31 | ); 32 | 33 | export const TEST_INGREDIENT_3 = new Ingredient( 34 | 103, 35 | "ef7b50e0-5a2f-4060-8f9d-dd6b181d393c", 36 | "0082592720153", 37 | "100% boosted juice smoothie", 38 | 60, 39 | 0.890, 40 | 14.000, 41 | 11.780, 42 | 0.000, 43 | 0.000, 44 | 0.000, 45 | 0.006, 46 | ); 47 | 48 | export const TEST_INGREDIENT_4 = new Ingredient( 49 | 104, 50 | "20a2ed05-f216-414a-a4a5-515d5bb9cb85", 51 | "3596710427192", 52 | "100% Cacao Boissons et Pâtisseries", 53 | 385, 54 | 22.000, 55 | 12.000, 56 | 1.900, 57 | 21.000, 58 | 13.000, 59 | 30.000, 60 | 0.020, 61 | ); 62 | 63 | export const TEST_INGREDIENT_5 = new Ingredient( 64 | 105, 65 | "12512223-5df8-457b-9f1f-9ff409e828fb", 66 | "3036850776410", 67 | "100% cacao non sucré", 68 | 367, 69 | 19.000, 70 | 12.000, 71 | 1.900, 72 | 21.000, 73 | 13.000, 74 | 27.000, 75 | 0.020, 76 | ); -------------------------------------------------------------------------------- /src/tests/measurementsTestData.ts: -------------------------------------------------------------------------------- 1 | import { MeasurementCategory } from "components/Measurements/models/Category"; 2 | import { MeasurementEntry } from "components/Measurements/models/Entry"; 3 | 4 | export const TEST_MEASUREMENT_ENTRIES_1 = [ 5 | new MeasurementEntry(1, 1, new Date(2023, 1, 1), 10, "test note"), 6 | new MeasurementEntry(2, 1, new Date(2023, 1, 2), 20, ""), 7 | new MeasurementEntry(3, 1, new Date(2023, 1, 3), 30, "important note"), 8 | new MeasurementEntry(4, 1, new Date(2023, 1, 4), 40, "this day was good"), 9 | new MeasurementEntry(5, 1, new Date(2023, 1, 5), 50, ""), 10 | new MeasurementEntry(6, 1, new Date(2023, 1, 6), 60, ""), 11 | new MeasurementEntry(7, 1, new Date(2023, 1, 7), 70, ""), 12 | new MeasurementEntry(8, 1, new Date(2023, 1, 8), 80, ""), 13 | ]; 14 | 15 | export const TEST_MEASUREMENT_ENTRIES_2 = [ 16 | new MeasurementEntry(1, 2, new Date(2023, 3, 1), 11, ""), 17 | new MeasurementEntry(2, 2, new Date(2023, 3, 2), 22, ""), 18 | new MeasurementEntry(3, 2, new Date(2023, 3, 3), 33, ""), 19 | new MeasurementEntry(4, 2, new Date(2023, 3, 4), 44, ""), 20 | new MeasurementEntry(5, 2, new Date(2023, 3, 5), 55, ""), 21 | new MeasurementEntry(6, 2, new Date(2023, 3, 6), 66, ""), 22 | new MeasurementEntry(7, 2, new Date(2023, 3, 7), 77, ""), 23 | new MeasurementEntry(8, 2, new Date(2023, 3, 8), 88, ""), 24 | ]; 25 | 26 | 27 | export const TEST_MEASUREMENT_CATEGORY_1 = new MeasurementCategory( 28 | 1, 29 | "Biceps", 30 | "cm", 31 | TEST_MEASUREMENT_ENTRIES_1, 32 | ); 33 | 34 | 35 | export const TEST_MEASUREMENT_CATEGORY_2 = new MeasurementCategory( 36 | 2, 37 | "Body fat", 38 | "%", 39 | TEST_MEASUREMENT_ENTRIES_2 40 | ); -------------------------------------------------------------------------------- /src/tests/queryClient/index.ts: -------------------------------------------------------------------------------- 1 | import { QueryClient } from "@tanstack/react-query"; 2 | 3 | export const testQueryClient = new QueryClient(); -------------------------------------------------------------------------------- /src/tests/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | import { cleanup } from '@testing-library/react'; 3 | import { afterEach, vi } from 'vitest'; 4 | 5 | global.jest = vi; 6 | 7 | // runs a cleanup after each test case (e.g. clearing jsdom) 8 | afterEach(() => { 9 | cleanup(); 10 | }); -------------------------------------------------------------------------------- /src/tests/userTestdata.ts: -------------------------------------------------------------------------------- 1 | import { Profile } from "components/User/models/profile"; 2 | 3 | export const testProfileDataVerified = new Profile({ 4 | username: 'admin', 5 | email: 'root@example.com', 6 | emailVerified: true, 7 | dateJoined: new Date("2022-04-27 17:52:38.867000+00:00"), 8 | isTrustworthy: true, 9 | useMetric: true, 10 | height: 180, 11 | weightRounding: null, 12 | repetitionsRounding: null, 13 | }); 14 | 15 | export const testProfileDataNotVerified = new Profile({ 16 | username: 'user', 17 | email: 'hi@example.com', 18 | emailVerified: false, 19 | dateJoined: new Date(2022, 3, 27, 19, 52, 38, 867), 20 | isTrustworthy: false, 21 | useMetric: true, 22 | height: 180, 23 | weightRounding: null, 24 | repetitionsRounding: null, 25 | }); 26 | 27 | export const testProfileApiResponse = { 28 | username: 'admin', 29 | email: 'root@example.com', 30 | // eslint-disable-next-line camelcase 31 | email_verified: true, 32 | // eslint-disable-next-line camelcase 33 | date_joined: "2022-04-27 17:52:38.867000+00:00", 34 | // eslint-disable-next-line camelcase 35 | is_trustworthy: true, 36 | // eslint-disable-next-line camelcase 37 | weight_unit: 'kg', 38 | height: 180, 39 | // eslint-disable-next-line camelcase 40 | weight_rounding: null, 41 | // eslint-disable-next-line camelcase 42 | repetitions_rounding: null, 43 | }; 44 | -------------------------------------------------------------------------------- /src/tests/weight/testData.ts: -------------------------------------------------------------------------------- 1 | import { WeightEntry } from "components/BodyWeight/model"; 2 | 3 | export const testWeightEntry1 = new WeightEntry(new Date('2023-11-01'), 100, 1); 4 | export const testWeightEntry2 = new WeightEntry(new Date('2023-10-01'), 90, 2); 5 | export const testWeightEntry3 = new WeightEntry(new Date('2023-09-01'), 110, 3); 6 | 7 | export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3]; -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { ThemeOptions } from '@mui/material/styles/createTheme'; 3 | 4 | const fontFamilyBold = [ 5 | '"Open Sans Bold"', 6 | 'sans-serif' 7 | ].join(','); 8 | 9 | const fontFamilyLight = [ 10 | '"Open Sans Light"', 11 | 'sans-serif' 12 | ].join(','); 13 | 14 | const themeOptions: ThemeOptions = { 15 | 16 | spacing: 8, 17 | typography: { 18 | h3: { 19 | fontFamily: fontFamilyBold, 20 | }, 21 | h4: { 22 | fontFamily: fontFamilyBold, 23 | }, 24 | h5: { 25 | fontFamily: fontFamilyBold, 26 | }, 27 | h6: { 28 | fontFamily: fontFamilyBold, 29 | }, 30 | fontFamily: fontFamilyLight, 31 | }, 32 | palette: { 33 | primary: { 34 | main: '#2A4C7D', 35 | }, 36 | secondary: { 37 | main: '#e63946', 38 | }, 39 | warning: { 40 | main: '#cba328', 41 | }, 42 | info: { 43 | main: '#457b9d', 44 | }, 45 | success: { 46 | main: '#307916', 47 | }, 48 | } 49 | }; 50 | 51 | export const theme = createTheme(themeOptions); 52 | export const makeTheme = (element: HTMLDivElement) => createTheme( 53 | { 54 | ...themeOptions, 55 | components: { 56 | MuiPopover: { 57 | defaultProps: { 58 | container: element, 59 | }, 60 | }, 61 | MuiPopper: { 62 | defaultProps: { 63 | container: element, 64 | }, 65 | }, 66 | MuiModal: { 67 | defaultProps: { 68 | container: element, 69 | }, 70 | }, 71 | }, 72 | } 73 | ); 74 | -------------------------------------------------------------------------------- /src/utils/Adapter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Small adapter interface to make TypeScript and us happy 3 | */ 4 | export interface Adapter { 5 | fromJson(item: any): T; 6 | 7 | toJson?(item: T): any; 8 | } -------------------------------------------------------------------------------- /src/utils/colors.test.ts: -------------------------------------------------------------------------------- 1 | import { generateChartColors } from "utils/colors"; 2 | 3 | describe("test the color utility", () => { 4 | 5 | test('3 items or less', () => { 6 | const result = generateChartColors(2); 7 | expect(result.next().value).toStrictEqual("#2a4c7d"); 8 | expect(result.next().value).toStrictEqual("#d45089"); 9 | }); 10 | 11 | test('5 items or less', () => { 12 | const result = generateChartColors(5); 13 | 14 | expect(result.next().value).toStrictEqual("#2a4c7d"); 15 | expect(result.next().value).toStrictEqual("#825298"); 16 | expect(result.next().value).toStrictEqual("#d45089"); 17 | expect(result.next().value).toStrictEqual("#ff6a59"); 18 | expect(result.next().value).toStrictEqual("#ffa600"); 19 | }); 20 | 21 | test('8 items or more - last ones undefined', () => { 22 | const result = generateChartColors(8); 23 | 24 | expect(result.next().value).toStrictEqual("#2a4c7d"); 25 | expect(result.next().value).toStrictEqual("#5b5291"); 26 | expect(result.next().value).toStrictEqual("#8e5298"); 27 | expect(result.next().value).toStrictEqual("#bf5092"); 28 | expect(result.next().value).toStrictEqual("#e7537e"); 29 | expect(result.next().value).toStrictEqual("#ff6461"); 30 | expect(result.next().value).toStrictEqual("#ff813d"); 31 | expect(result.next().value).toStrictEqual("#ffa600"); 32 | 33 | // If we continue, we get undefined, but that's acceptable since the chart 34 | // renders this as black 35 | expect(result.next().value).toStrictEqual(undefined); 36 | }); 37 | 38 | }); 39 | -------------------------------------------------------------------------------- /src/utils/colors.ts: -------------------------------------------------------------------------------- 1 | import { LIST_OF_COLORS3, LIST_OF_COLORS5, LIST_OF_COLORS8 } from "utils/consts"; 2 | 3 | export function* generateChartColors(nrOfItems: number) { 4 | 5 | let colors; 6 | if (nrOfItems <= 3) { 7 | colors = LIST_OF_COLORS3; 8 | } else if (nrOfItems <= 5) { 9 | colors = LIST_OF_COLORS5; 10 | } else { 11 | colors = LIST_OF_COLORS8; 12 | } 13 | 14 | for (const i of colors) { 15 | yield i; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/forms.test.ts: -------------------------------------------------------------------------------- 1 | import { collectValidationErrors } from "utils/forms"; 2 | 3 | describe('test the collectValidationErrors function', () => { 4 | test('correctly collects all errors', () => { 5 | const result = collectValidationErrors({ 6 | 'field1': ['Error 1', 'Error 2'], 7 | 'field2': ['Error 3'], 8 | }); 9 | expect(result).toStrictEqual(['Error 1', 'Error 2', 'Error 3']); 10 | }); 11 | 12 | test('correctly handles an emtpy object', () => { 13 | const result = collectValidationErrors({}); 14 | expect(result).toStrictEqual([]); 15 | }); 16 | 17 | test('correctly handles an undefined object', () => { 18 | const result = collectValidationErrors(undefined); 19 | expect(result).toStrictEqual([]); 20 | }); 21 | 22 | test('correctly handles a null object', () => { 23 | const result = collectValidationErrors(null); 24 | expect(result).toStrictEqual([]); 25 | }); 26 | 27 | }); -------------------------------------------------------------------------------- /src/utils/forms.ts: -------------------------------------------------------------------------------- 1 | interface ValidationErrorResponse { 2 | [key: string]: string[]; 3 | } 4 | 5 | export function collectValidationErrors(errors: ValidationErrorResponse | undefined | null): string[] { 6 | const allErrors: string[] = []; 7 | if (!errors) { 8 | return allErrors; 9 | } 10 | 11 | for (const field in errors) { 12 | if (Object.hasOwn(errors, field)) { 13 | allErrors.push(...errors[field]); 14 | } 15 | } 16 | 17 | return allErrors; 18 | } 19 | 20 | export function errorsToString(errors: ValidationErrorResponse): string { 21 | const allErrors = collectValidationErrors(errors); 22 | return allErrors.length > 0 ? allErrors.join(" | ") : ""; 23 | } -------------------------------------------------------------------------------- /src/utils/numbers.ts: -------------------------------------------------------------------------------- 1 | export enum LocaleUnit { 2 | GRAM = 'gram', 3 | KG = 'kilogram', 4 | LB = 'pound', 5 | PERCENT = 'percent', 6 | } 7 | 8 | /* 9 | * Formats a number, localised, no fraction digits 10 | */ 11 | export function numberLocale(num: number, locale: string) { 12 | return num.toLocaleString(locale, { maximumFractionDigits: 0 }); 13 | } 14 | 15 | /* 16 | * Formats a number with a unit, localised, no fraction digits 17 | * 18 | * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat 19 | * https://tc39.es/ecma402/#table-sanctioned-single-unit-identifiers 20 | */ 21 | export function numberUnitLocale(num: number, locale: string, unit: LocaleUnit) { 22 | return num.toLocaleString( 23 | locale, 24 | { maximumFractionDigits: 0, unit: unit.valueOf(), style: 'unit' } 25 | ); 26 | } 27 | 28 | export function numberGramLocale(num: number, locale: string) { 29 | return numberUnitLocale(num, locale, LocaleUnit.GRAM); 30 | } 31 | 32 | export function numberPercentLocale(num: number, locale: string) { 33 | return numberUnitLocale(num, locale, LocaleUnit.PERCENT); 34 | } 35 | 36 | export function numberKgLocale(num: number, locale: string) { 37 | return numberUnitLocale(num, locale, LocaleUnit.KG); 38 | } 39 | 40 | export function numberLbLocale(num: number, locale: string) { 41 | return numberUnitLocale(num, locale, LocaleUnit.LB); 42 | } -------------------------------------------------------------------------------- /src/utils/requests.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from 'axios'; 2 | import { makeHeader } from "utils/url"; 3 | 4 | export async function* fetchPaginated(url: string, headers?: AxiosRequestConfig['headers']): AsyncGenerator { 5 | 6 | if (headers == null) { 7 | headers = makeHeader(); 8 | } 9 | 10 | while (true) { 11 | const response = await axios.get(url, { headers: headers }); 12 | const data = response.data; 13 | yield data.results; 14 | 15 | url = data.next; 16 | if (!url) { 17 | break; 18 | } 19 | } 20 | } 21 | 22 | -------------------------------------------------------------------------------- /src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | const MAX_LENGTH = 22; 2 | 3 | export function truncateLongNames(name: string, maxLength = MAX_LENGTH): string { 4 | return name.length > maxLength ? name.slice(0, maxLength) + '…' : name; 5 | } 6 | 7 | // Replace whitespace and parenthesis with underscores and make lowercase 8 | // 9 | // Note this function is used to generate the translation keys for the exercise categories 10 | // etc. so they can be used as keys in the translation files. Don't change the implementation 11 | // of this function without updating its counterpart in extract-i18n.py 12 | export function makeServerKey(name: string): string { 13 | return name.toLowerCase() 14 | .replace(/\s/g, '_') 15 | .replace('(', '_') 16 | .replace(')', '_') 17 | .replace('-', '_'); 18 | } 19 | 20 | // Returns the key used for the translation of the given exercise data 21 | export function getTranslationKey(name: string | undefined): string { 22 | if (name === undefined) { 23 | console.warn("called getTranslationKey with undefined name"); 24 | return ''; 25 | } 26 | 27 | return `server.${makeServerKey(name)}`; 28 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "target": "es2022", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "types": [ 11 | "vite/client" 12 | ], 13 | "allowJs": true, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strict": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "module": "es2022", 21 | "moduleResolution": "node", 22 | "resolveJsonModule": true, 23 | "isolatedModules": true, 24 | "noEmit": true, 25 | "jsx": "react-jsx" 26 | }, 27 | "include": [ 28 | "src" 29 | ], 30 | "references": [ 31 | { 32 | "path": "./tsconfig.node.json" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "types": [ 8 | "vite/client" 9 | ] 10 | }, 11 | "include": [ 12 | "vite.config.ts" 13 | ] 14 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import { defineConfig } from 'vite'; 3 | import eslintPlugin from "vite-plugin-eslint"; 4 | import viteTsconfigPaths from 'vite-tsconfig-paths'; 5 | 6 | export default defineConfig(() => { 7 | return { 8 | build: { 9 | outDir: 'build', 10 | }, 11 | server: { 12 | open: true, 13 | port: 3000, 14 | }, 15 | plugins: [ 16 | react({ 17 | jsxImportSource: '@emotion/react', 18 | babel: { 19 | plugins: ['@emotion/babel-plugin'], 20 | }, 21 | }), 22 | viteTsconfigPaths(), 23 | eslintPlugin({ 24 | cache: false, 25 | include: ['./src/**/*.js', './src/**/*.jsx', './src/**/*.ts', './src/**/*.tsx'], 26 | exclude: [], 27 | }), 28 | ], 29 | test: { 30 | environment: 'jsdom', 31 | globals: true, 32 | setupFiles: './src/tests/setup.ts', 33 | }, 34 | }; 35 | }); --------------------------------------------------------------------------------