diff --git a/eslint.config.js b/eslint.config.js index aaac74d21..7bf78eefe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,12 @@ const DOMAINS = [ 'User', ]; +// The key must not import the values: nutrition reads measurements, not the +// other way round, or the two form a module initialisation cycle. +const FORBIDDEN_DEPENDENCIES = { + Measurements: ['Nutrition'], +}; + const restrictAllDomains = { "patterns": [{ "group": DOMAINS.map(d => `@/components/${d}/*`), @@ -32,12 +38,18 @@ const domainOverrides = DOMAINS.map(domain => ({ files: [`src/components/${domain}/**/*.{ts,tsx}`], rules: { "no-restricted-imports": ["error", { - "patterns": [{ - "group": DOMAINS - .filter(d => d !== domain) - .map(d => `@/components/${d}/*`), - "message": `Import other domains via their public surface (e.g. '@/components/${DOMAINS[0]}'), not internal sub-paths.`, - }] + "patterns": [ + { + "group": DOMAINS + .filter(d => d !== domain) + .map(d => `@/components/${d}/*`), + "message": `Import other domains via their public surface (e.g. '@/components/${DOMAINS[0]}'), not internal sub-paths.`, + }, + ...(FORBIDDEN_DEPENDENCIES[domain] ?? []).map(target => ({ + "group": [`@/components/${target}`, `@/components/${target}/*`], + "message": `${domain} must not import ${target}: the dependency runs the other way. Take what you need as a prop, from the page that composes both.`, + })), + ] }], } })); diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index eccc9f3b9..bd1e04b6e 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -119,6 +119,7 @@ }, "submit": "Abschicken", "weight": "Gewicht", + "syncedEntryInfo": "Dieser Eintrag wurde aus einer Health-App synchronisiert und kann nur dort geändert werden", "workout": "Training", "images": "Bilder", "description": "Beschreibung", @@ -249,9 +250,74 @@ "preferences": "Voreinstellungen", "success": "Geschafft!", "measurements": { + "reorderCategories": "Kategorien neu anordnen", "deleteInfo": "Dies wird die Kategorie sowie alle seine Einträge löschen", + "deleteInfoGroup": "Dies wird die Gruppe sowie alle ihre Komponenten und deren Einträge löschen", "unitFormHelpText": "Die Einheit, in der die Kategorie gemessen wird, wie cm oder %", - "measurements": "Messungen" + "measurements": "Messungen", + "metricType": "Metrik-Typ", + "chartType": "Diagrammtyp", + "chartTypes": { + "auto": "Automatisch", + "line": "Linie", + "bar": "Balken", + "heatmap": "Heatmap", + "delta": "Veränderung", + "distribution": "Verteilung" + }, + "chartTrend": "Trendlinie", + "trends": { + "reactive": "Reaktiv", + "balanced": "Ausgewogen", + "sluggish": "Geglättet" + }, + "chartAverageWindow": "Durchschnitt über", + "chartAverageWindowDays_one": "1 Tag", + "chartAverageWindowDays_other": "{{count}} Tage", + "distributionMedian": "Median", + "distributionLatest": "Aktuell", + "distributionEntryCount_one": "1 Eintrag", + "distributionEntryCount_other": "{{count}} Einträge", + "distributionDayCount_one": "1 Tag", + "distributionDayCount_other": "{{count}} Tage", + "partOfGroup": "Teil der Gruppe", + "noGroup": "Keine Gruppe", + "metricTypes": { + "custom": "Benutzerdefiniert", + "body_weight": "Körpergewicht", + "body_fat": "Körperfett", + "lean_body_mass": "Magermasse", + "height": "Körpergröße", + "blood_pressure": "Blutdruck", + "blood_pressure_systolic": "Systolisch", + "blood_pressure_diastolic": "Diastolisch", + "heart_rate": "Herzfrequenz", + "resting_heart_rate": "Ruhepuls", + "blood_oxygen": "Sauerstoffsättigung", + "steps": "Schritte", + "distance": "Distanz", + "energy": "Energie", + "sleep": "Schlaf", + "sleep_total": "Gesamtschlaf", + "sleep_light": "Leichtschlaf", + "sleep_deep": "Tiefschlaf", + "sleep_rem": "REM-Schlaf", + "sleep_awake": "Wach" + }, + "indicatorRaw": "raw", + "indicatorAvg": "Durchschn.", + "indicatorTrend": "Trend", + "overallChangeWeight": "Allgemeine Veränderung", + "chartRangeAll": "Gesamt", + "chartRangeMonths_one": "1 Monat", + "chartRangeMonths_other": "{{count}} Monate", + "chartRangeWeeks_one": "1 Woche", + "chartRangeWeeks_other": "{{count}} Wochen", + "chartRangeYears_one": "1 Jahr", + "chartRangeYears_other": "{{count}} Jahre", + "customMeasurement": "Eigene Messung", + "metricAlreadyTracked": "Wird bereits aufgezeichnet", + "categoryFormHelpText": "Messkategorie, z. B. „Bizeps“ oder „Körperfett“" }, "timeOfDay": "Uhrzeit", "notes": "Notizen", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 83bd027c2..904ff17ef 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -1,382 +1,449 @@ { - "dashboard": { - "customizeDashboard": "Customize dashboard", - "dragWidgetsHelp": "Drag widgets to reposition them or resize using the bottom-right corner.", - "resetLayout": "Reset to the default layout" - }, - "core": { - "exitEditMode": "Exit edit mode", - "customize": "Customize" - }, - "weight": "Weight", - "height": "Height", - "cm": "cm", - "date": "Date", - "timeOfDay": "Time of day", - "submit": "Submit", - "edit": "Edit", - "preview": "Preview", - "editName": "Edit {{name}}", - "delete": "Delete", - "deleteConfirmation": "Are you sure you want to delete \"{{name}}\"?", - "add": "Add", - "close": "Close", - "difference": "Difference", - "useMarkdownHint": "You can use basic Markdown to format the text: *italic*, **bold**, - list", - "days": "Days", - "all": "All", - "lastYear": "Last Year", - "lastHalfYear": "Last 6 Months", - "lastMonth": "Last Month", - "lastWeek": "Last Week", - "start": "Start", - "end": "End", - "comment": "Comment", - "trophies": { - "trophies": "Trophies" - }, - "licenses": { - "authors": "Author(s)", - "authorProfile": "Link to author website or profile, if available", - "derivativeSourceUrl": "Link to the original source, if this is a derivative work", - "derivativeSourceUrlHelper": "Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.", - "originalObjectUrl": "Link to the source website, if available", - "originalTitle": "Title" - }, - "loading": "Loading...", - "nutritionalPlan": "Nutritional plan", - "addEntry": "Add entry", - "currentWeight": "Current weight", - "currentTrend": "Current trend", - "mean": "Mean", - "trend": "Trend", - "variance": "Variance", - "totalChange": "Total change", - "workout": "Workout", - "seeDetails": "See details", - "actions": "Actions", - "nothingHereYet": "Nothing here yet...", - "nothingHereYetAction": "Press the action button to begin", - "notes": "Notes", - "value": "Value", - "unit": "Unit", - "alsoSearchEnglish": "Also search for names in English", - "copyToClipboard": "Copy to clipboard", - "filters": "Filters", - "private": "Private", - "public": "Public", - "exercises": { - "replacements": "Replacements", - "replacementsInfoText": "Optionally, you can also select an exercise that should replace this one (e.g. because it was submitted twice, or similar). This will replace the exercise in routines as well as training logs, instead of just deleting it. These changes will also propagate to any instance that syncs the exercises from this one.", - "replacementsSearch": "Search for an exercise or copy and paste a known ID into the field and click on the \"load\" button.", - "noReplacementSelected": " No exercise selected for replacement", - "replacementCannotBeSame": "The replacement cannot be the same exercise that is being deleted.", - "transferMediaLabel": "Transfer media to the replacement exercise", - "transferTranslationsLabel": "Transfer translations to the replacement (skips languages already present)", - "contributeExercise": "Contribute an exercise", - "step1HeaderBasics": "Basics in English", - "variations": "Variations", - "notEnoughRightsHeader": "You can't contribute exercises", - "notEnoughRights": "You can only contribute exercises if your account is older than {{days}} days and have verified your email", - "muscles": "Muscles", - "secondaryMuscles": "Secondary muscles", - "whatVariationsExist": "Which variations of this exercise exist, if any?", - "filterVariations": "Enter exercise name to filter variations", - "identicalExercise": "Avoid duplicate exercises", - "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", - "translateExerciseNow": "Translate this exercise now", - "compatibleImagesCC": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", - "alternativeNames": "Alternative names", + "dashboard": { + "customizeDashboard": "Customize dashboard", + "dragWidgetsHelp": "Drag widgets to reposition them or resize using the bottom-right corner.", + "resetLayout": "Reset to the default layout" + }, + "core": { + "exitEditMode": "Exit edit mode", + "customize": "Customize" + }, + "weight": "Weight", + "syncedEntryInfo": "This entry was synced from a health app and can only be changed there", + "height": "Height", + "cm": "cm", + "date": "Date", + "timeOfDay": "Time of day", + "submit": "Submit", + "edit": "Edit", + "preview": "Preview", + "editName": "Edit {{name}}", + "delete": "Delete", + "deleteConfirmation": "Are you sure you want to delete \"{{name}}\"?", + "add": "Add", + "close": "Close", + "difference": "Difference", + "useMarkdownHint": "You can use basic Markdown to format the text: *italic*, **bold**, - list", + "days": "Days", + "all": "All", + "lastYear": "Last Year", + "lastHalfYear": "Last 6 Months", + "lastMonth": "Last Month", + "lastWeek": "Last Week", + "start": "Start", + "end": "End", + "comment": "Comment", + "trophies": { + "trophies": "Trophies" + }, + "licenses": { + "authors": "Author(s)", + "authorProfile": "Link to author website or profile, if available", + "derivativeSourceUrl": "Link to the original source, if this is a derivative work", + "derivativeSourceUrlHelper": "Note that a derivative work is one which is not only based on a previous work, but which also contains sufficient new, creative content to entitle it to its own copyright.", + "originalObjectUrl": "Link to the source website, if available", + "originalTitle": "Title" + }, + "loading": "Loading...", + "nutritionalPlan": "Nutritional plan", + "addEntry": "Add entry", + "currentWeight": "Current weight", + "currentTrend": "Current trend", + "mean": "Mean", + "trend": "Trend", + "variance": "Variance", + "totalChange": "Total change", + "workout": "Workout", + "seeDetails": "See details", + "actions": "Actions", + "nothingHereYet": "Nothing here yet...", + "nothingHereYetAction": "Press the action button to begin", "notes": "Notes", - "equipment": "Equipment", - "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", - "cacheWarning": "Due to caching it might take some time till the changes are visible throughout the application.", - "submitExercise": "Submit exercise", - "successfullyUpdated": "The exercise was successfully updated. Due to caching it might take some time till the changes are visible throughout the application.", + "value": "Value", + "unit": "Unit", + "alsoSearchEnglish": "Also search for names in English", + "copyToClipboard": "Copy to clipboard", + "filters": "Filters", + "private": "Private", + "public": "Public", + "exercises": { + "replacements": "Replacements", + "replacementsInfoText": "Optionally, you can also select an exercise that should replace this one (e.g. because it was submitted twice, or similar). This will replace the exercise in routines as well as training logs, instead of just deleting it. These changes will also propagate to any instance that syncs the exercises from this one.", + "replacementsSearch": "Search for an exercise or copy and paste a known ID into the field and click on the \"load\" button.", + "noReplacementSelected": " No exercise selected for replacement", + "replacementCannotBeSame": "The replacement cannot be the same exercise that is being deleted.", + "transferMediaLabel": "Transfer media to the replacement exercise", + "transferTranslationsLabel": "Transfer translations to the replacement (skips languages already present)", + "contributeExercise": "Contribute an exercise", + "step1HeaderBasics": "Basics in English", + "variations": "Variations", + "notEnoughRightsHeader": "You can't contribute exercises", + "notEnoughRights": "You can only contribute exercises if your account is older than {{days}} days and have verified your email", + "muscles": "Muscles", + "secondaryMuscles": "Secondary muscles", + "whatVariationsExist": "Which variations of this exercise exist, if any?", + "filterVariations": "Enter exercise name to filter variations", + "identicalExercise": "Avoid duplicate exercises", + "identicalExercisePleaseDiscard": "If you notice an exercise that is identical to the one you're adding, please discard your draft and edit that exercise instead.", + "translateExerciseNow": "Translate this exercise now", + "compatibleImagesCC": "Images must be compatible with the CC BY SA license. If in doubt, upload only photos you've taken yourself.", + "alternativeNames": "Alternative names", + "notes": "Notes", + "equipment": "Equipment", + "checkInformationBeforeSubmitting": "Please check that the information you entered is correct before submitting the exercise", + "cacheWarning": "Due to caching it might take some time till the changes are visible throughout the application.", + "submitExercise": "Submit exercise", + "successfullyUpdated": "The exercise was successfully updated. Due to caching it might take some time till the changes are visible throughout the application.", + "description": "Description", + "basics": "Basics", + "exerciseNotTranslated": "No translation available", + "exerciseNotTranslatedBody": "This exercise is currently not available in the currently selected language. Do you want to contribute a translation?", + "alsoKnownAs": "Also known as:", + "primaryMuscles": "Primary muscles", + "deleteExerciseBody": "Do you want to delete the exercise \"{{name}}\"? You can either delete the current {{language}} translation or the complete exercise with all translations, images, etc.", + "deleteTranslation": "Delete translation", + "deleteExerciseFull": "Delete full exercise", + "deleteExerciseReplace": "Delete and replace", + "exercises": "Exercises", + "changeExerciseLanguage": "Change this exercise's language", + "noEquipment": "No equipment", + "missingExercise": "Missing a certain exercise?", + "missingExerciseDescription": "Help out the community by contributing it!", + "searchExerciseName": "Search by exercise name", + "exactMatch": "Exact match", + "newNote": "New note", + "notesHelpText": "Notes are short comments on how to perform the exercise such as \"keep your body straight\"", + "imageStylePhoto": "Photo", + "imageStyle3D": "3D", + "imageStyleLine": "Line", + "imageStyleLowPoly": "Low-Poly", + "imageStyleOther": "Other", + "imageDetails": "Image details", + "imageIsAiGenerated": "Image was generated with AI", + "dropOrClickImage": "Drop an image here or click to select", + "addImage": "Add image", + "swapExercise": "Swap exercise" + }, + "nutrition": { + "plans": "Nutritional plans", + "copyPlan": "Make a copy of this plan", + "plan": "Nutritional plan", + "onlyLoggingHelpText": "Only track calories. Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", + "goalsTitle": "Goals", + "useGoalsHelpText": "Add goals to this plan", + "useGoalsHelpTextLong": "This allows you to set general goals for energy, protein, carbohydrates or fat for the plan. Note that if you setup a detailed meal plan, these values will take precedence.", + "goalEnergy": "Energy goal", + "goalProtein": "Protein goal", + "goalCarbohydrates": "Carbohydrates goal", + "goalFiber": "Fiber goal", + "goalFat": "Fat goal", + "addNutritionalDiary": "Add nutrition diary entry", + "meal": "Meal", + "addMeal": "Add meal", + "addMealItem": "Add ingredient to meal", + "nutritionalDiary": "Nutrition diary", + "gramShort": "g", + "kcal": "kcal", + "valueEnergyKcal": "{{value}} kcal", + "valueEnergyKcalKj": "{{kcal}} kcal / {{kj}} kJ", + "searchIngredientName": "Search by ingredient name", + "languageFilterCurrentOnly": "Only in current language ({{lang}})", + "languageFilterCurrentAndEnglish": "Current language ({{lang}}) & English", + "languageFilterAll": "All languages", + "filterVegan": "Vegan", + "filterVegetarian": "Vegetarian", + "filterNutriscore": "Nutri-Score filter", + "filterNutriscoreOff": "Off", + "filterNutriscoreNoFilter": "No filter", + "filterNutriscoreOrBetter": "{{grade}} or better", + "macronutrient": "Macronutrient", + "percentEnergy": "Percent of energy", + "gPerBodyKg": "g per body-kg", + "planned": "Planned", + "logged": "Logged", + "loggedToday": "Logged today", + "difference": "Difference", + "today": "Today", + "7dayAvg": "7-day average", + "energy": "Energy", + "protein": "Protein", + "carbohydrates": "Carbohydrates", + "sugar": "Sugar", + "ofWhichSugars": "of which sugars", + "fat": "Fat", + "ofWhichSaturated": "of which saturated", + "saturatedFat": "Saturated fat", + "pseudoMealTitle": "Other logs", + "others": "Others", + "fibres": "Fibres", + "sodium": "Sodium", + "planDeleteInfo": "This will delete all nutrition diary entries as well", + "mealDeleteInfo": "Nutrition diary entries to this meal will not be deleted and will appear under \"other logs\"", + "diaryEntrySaved": "Diary entry successfully saved", + "logThisMeal": "Log this meal as-is to the nutrition diary", + "logThisMealItem": "Log this ingredient as-is to the nutrition diary", + "valueRemaining": "remaining", + "valueTooMany": "too many" + }, + "bmi": { + "calculator": "BMI calculator", + "overweight": "Overweight", + "obese": "Obese", + "normal": "Normal weight", + "underweight": "Underweight", + "result": "Your BMI is {{value}}" + }, + "downloadAsPdf": "Download as PDF", + "total": "Total", "description": "Description", - "basics": "Basics", - "exerciseNotTranslated": "No translation available", - "exerciseNotTranslatedBody": "This exercise is currently not available in the currently selected language. Do you want to contribute a translation?", - "alsoKnownAs": "Also known as:", - "primaryMuscles": "Primary muscles", - "deleteExerciseBody": "Do you want to delete the exercise \"{{name}}\"? You can either delete the current {{language}} translation or the complete exercise with all translations, images, etc.", - "deleteTranslation": "Delete translation", - "deleteExerciseFull": "Delete full exercise", - "deleteExerciseReplace": "Delete and replace", - "exercises": "Exercises", - "changeExerciseLanguage": "Change this exercise's language", - "noEquipment": "No equipment", - "missingExercise": "Missing a certain exercise?", - "missingExerciseDescription": "Help out the community by contributing it!", - "searchExerciseName": "Search by exercise name", - "exactMatch": "Exact match", - "newNote": "New note", - "notesHelpText": "Notes are short comments on how to perform the exercise such as \"keep your body straight\"", - "imageStylePhoto": "Photo", - "imageStyle3D": "3D", - "imageStyleLine": "Line", - "imageStyleLowPoly": "Low-Poly", - "imageStyleOther": "Other", - "imageDetails": "Image details", - "imageIsAiGenerated": "Image was generated with AI", - "dropOrClickImage": "Drop an image here or click to select", - "addImage": "Add image", - "swapExercise": "Swap exercise" - }, - "nutrition": { - "plans": "Nutritional plans", - "copyPlan": "Make a copy of this plan", - "plan": "Nutritional plan", - "onlyLoggingHelpText": "Only track calories. Check the box if you only want to log your calories and don't want to setup a detailed nutritional plan with specific meals", - "goalsTitle": "Goals", - "useGoalsHelpText": "Add goals to this plan", - "useGoalsHelpTextLong": "This allows you to set general goals for energy, protein, carbohydrates or fat for the plan. Note that if you setup a detailed meal plan, these values will take precedence.", - "goalEnergy": "Energy goal", - "goalProtein": "Protein goal", - "goalCarbohydrates": "Carbohydrates goal", - "goalFiber": "Fiber goal", - "goalFat": "Fat goal", - "addNutritionalDiary": "Add nutrition diary entry", - "meal": "Meal", - "addMeal": "Add meal", - "addMealItem": "Add ingredient to meal", - "nutritionalDiary": "Nutrition diary", - "gramShort": "g", - "kcal": "kcal", - "valueEnergyKcal": "{{value}} kcal", - "valueEnergyKcalKj": "{{kcal}} kcal / {{kj}} kJ", - "searchIngredientName": "Search by ingredient name", - "languageFilterCurrentOnly": "Only in current language ({{lang}})", - "languageFilterCurrentAndEnglish": "Current language ({{lang}}) & English", - "languageFilterAll": "All languages", - "filterVegan": "Vegan", - "filterVegetarian": "Vegetarian", - "filterNutriscore": "Nutri-Score filter", - "filterNutriscoreOff": "Off", - "filterNutriscoreNoFilter": "No filter", - "filterNutriscoreOrBetter": "{{grade}} or better", - "macronutrient": "Macronutrient", - "percentEnergy": "Percent of energy", - "gPerBodyKg": "g per body-kg", - "planned": "Planned", - "logged": "Logged", - "loggedToday": "Logged today", - "difference": "Difference", - "today": "Today", - "7dayAvg": "7-day average", - "energy": "Energy", - "protein": "Protein", - "carbohydrates": "Carbohydrates", - "sugar": "Sugar", - "ofWhichSugars": "of which sugars", - "fat": "Fat", - "ofWhichSaturated": "of which saturated", - "saturatedFat": "Saturated fat", - "pseudoMealTitle": "Other logs", - "others": "Others", - "fibres": "Fibres", - "sodium": "Sodium", - "planDeleteInfo": "This will delete all nutrition diary entries as well", - "mealDeleteInfo": "Nutrition diary entries to this meal will not be deleted and will appear under \"other logs\"", - "diaryEntrySaved": "Diary entry successfully saved", - "logThisMeal": "Log this meal as-is to the nutrition diary", - "logThisMealItem": "Log this ingredient as-is to the nutrition diary", - "valueRemaining": "remaining", - "valueTooMany": "too many" - }, - "bmi": { - "calculator": "BMI calculator", - "overweight": "Overweight", - "obese": "Obese", - "normal": "Normal weight", - "underweight": "Underweight", - "result": "Your BMI is {{value}}" - }, - "downloadAsPdf": "Download as PDF", - "total": "Total", - "description": "Description", - "translation": "Translation", - "images": "Images", - "overview": "Overview", - "preferences": "Preferences", - "continue": "Continue", - "goBack": "Go back", - "language": "Language", - "forms": { - "supportedImageFormats": "Only JPEG, PNG, WEBP and AVIF files below 20Mb are supported", - "enterNumber": "Please enter a valid number", - "enterInteger": "Please enter a whole number", - "fieldRequired": "This field is required", - "maxLength": "Please enter less than {{chars}} characters", - "minLength": "Please enter more than {{chars}} characters", - "minValue": "The value for this field has to be higher than {{value}}", - "maxValue": "The value for this field has to be less than {{value}}", - "maxLessThanMin": "The max value has to be bigger than the minimum", - "endBeforeStart": "The end value cannot be before the start" - }, - "name": "Name", - "category": "Category", - "success": "Success!", - "English": "English", - "save": "Save", - "min": "Min", - "max": "Max", - "durationWeeks": "{{number}} weeks", - "durationWeeksDays": "{{nrWeeks}} weeks, {{nrDays}} days", - "videos": "Videos", - "undo": "Undo", - "successfullyDeleted": "Successfully deleted", - "cannotBeUndone": "This action can't be undone.", - "cancel": "Cancel", - "noResults": "No results", - "noResultsDescription": "No results found for this query, consider reducing the number of filters.", - "routines": { - "sets": "Sets", - "reps": "Reps", - "volume": "Volume", - "intensity": "Intensity", - "currentRoutine": "Current routine", - "iteration": "Iteration", - "weekly": "Weekly", - "daily": "Daily", - "restTime": "Rest time", - "workoutNr": "Workout Nr. {{number}}", - "weekNr": "Week {{number}}", - "iterationNr": "Iteration {{number}}", - "backToRoutine": "Back to routine", - "minLengthRoutine": "The routine needs to be at least {{number}} weeks long", - "maxLengthRoutine": "The routine can be at most {{number}} weeks long", - "resultingRoutine": "Resulting routine", - "addDay": "Add training day", - "deleteDayConfirmation": "This will remove all sets, exercises and progression rules", - "routineHasNoDays": "The routine has no days", - "setHasNoExercises": "This set has no exercises", - "fitDaysInWeek": "Fixed weekly schedule", - "fitDaysInWeekHelpText": "This setting controls how your routine's days are scheduled across multiple weeks. If enabled, the days will repeat in a weekly cycle. For example, a routine with workouts on Monday, Wednesday, and Friday will continue this pattern on the following Monday, Wednesday, and Friday. If disabled, the days will follow sequentially without regard to the start of a new week. This is useful for routines that don't follow a strict weekly schedule.", - "needsLogsToAdvance": "Needs logs to advance", - "needsLogsToAdvanceHelpText": "If you select this option, the routine will only progress to the next scheduled day if you've logged a workout for the current day. If this option is not selected, the routine will automatically advance to the next day regardless of whether you logged a workout or not.", - "addSuperset": "Add superset", - "addExercise": "Add exercise", - "addSet": "Add set", - "exerciseNr": "Exercise {{number}}", - "supersetNr": "Superset {{number}}", - "setNr": "Set {{number}}", - "editProgression": "Edit progression", - "progressionNeedsReplace": "One of the previous entries must have a replace operation", - "exerciseHasProgression": "This exercise has progression rules and can't be edited here. To do so, click the button.", - "exerciseNotAvailable": "Error while loading exercise", - "defaultRounding": "Default rounding", - "rounding": "Rounding (this exercise)", - "roundingHelp": "Set the default rounding for weight and repetitions (this is specially useful when using the percentage increase step in the progression). This will apply to all new sets but can be changed individually in the progression form. Leave empty to disable rounding.", - "newDay": "New day", - "addWeightLog": "Add training log", - "weightLogNotPlanned": "Saving logs to a date for which no workouts were planned.", - "logsOverview": "Logs overview", - "alsoShowLogs": "Also show logs", - "statsOverview": "Statistics", - "simpleMode": "Simple mode", - "logsHeader": "Training log for workout", - "logsFilterNote": "Note that only entries with a weight unit of kg or lb and repetitions are charted, other combinations such as time or until failure are ignored here", - "addLogToDay": "Add log to this day", - "routine": "Routine", - "routines": "Routines", - "workoutSession": "Workout session", - "rir": "RiR", - "restDay": "Rest day", - "confirmRestDay": "Confirm rest day change", - "confirmRestDayHelpText": "Please note that all sets and exercises will be removed when you mark a day as a rest day.", - "duplicate": "Duplicate routine", - "downloadPdfTable": "Download PDF (table)", - "downloadPdfLogs": "Download PDF (logs)", - "downloadIcal": "Download iCal file", - "impression": "General impression", - "impressionGood": "Good", - "impressionNeutral": "Neutral", - "impressionBad": "Bad", - "impressionHelpText": "This form records your workout results (reps, weight, etc.) for each exercise. Changes you make here, like removing or swapping exercises, only affect the specific logs you save and and won't change your overall routine. Only rows with values for either weight or repetitions are saved.", - "addAdditionalLog": "Add additional log", - "operation": "Operation", - "step": "Step", - "requirements": "Requirements", - "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect", - "repeat": "Repeat rule", - "repeatHelpText": "Check the check box if you want this rule to continue to apply to subsequent workouts until you define a new one", - "markAsTemplate": "Manage template", - "template": "Template", - "templates": "Templates", - "publicTemplate": "Public template", - "publicTemplates": "Public templates", - "templatesHelpText": "Templates are a way to save your routine for later use and as a starting point for further routines. You can't edit templates, but you can duplicate them and make changes to the copy (as well as converting them back to a regular routine, of course).", - "publicTemplateHelpText": "Public templates are available to all users.", - "copyAndUseTemplate": "Copy and use template", - "set": { - "type": "Type", - "normalSet": "Normal set", - "dropSet": "Drop set", - "myo": "MYO", - "partial": "Partial", - "forced": "Forced", - "tut": "Time under tension", - "iso": "Isometric hold", - "jump": "Jump", - "warmup": "Warmup" + "translation": "Translation", + "images": "Images", + "overview": "Overview", + "preferences": "Preferences", + "continue": "Continue", + "goBack": "Go back", + "language": "Language", + "forms": { + "supportedImageFormats": "Only JPEG, PNG, WEBP and AVIF files below 20Mb are supported", + "enterNumber": "Please enter a valid number", + "enterInteger": "Please enter a whole number", + "fieldRequired": "This field is required", + "maxLength": "Please enter less than {{chars}} characters", + "minLength": "Please enter more than {{chars}} characters", + "minValue": "The value for this field has to be higher than {{value}}", + "maxValue": "The value for this field has to be less than {{value}}", + "maxLessThanMin": "The max value has to be bigger than the minimum", + "endBeforeStart": "The end value cannot be before the start" + }, + "name": "Name", + "category": "Category", + "success": "Success!", + "English": "English", + "save": "Save", + "min": "Min", + "max": "Max", + "durationWeeks": "{{number}} weeks", + "durationWeeksDays": "{{nrWeeks}} weeks, {{nrDays}} days", + "videos": "Videos", + "undo": "Undo", + "successfullyDeleted": "Successfully deleted", + "cannotBeUndone": "This action can't be undone.", + "cancel": "Cancel", + "noResults": "No results", + "noResultsDescription": "No results found for this query, consider reducing the number of filters.", + "routines": { + "sets": "Sets", + "reps": "Reps", + "volume": "Volume", + "intensity": "Intensity", + "currentRoutine": "Current routine", + "iteration": "Iteration", + "weekly": "Weekly", + "daily": "Daily", + "restTime": "Rest time", + "workoutNr": "Workout Nr. {{number}}", + "weekNr": "Week {{number}}", + "iterationNr": "Iteration {{number}}", + "backToRoutine": "Back to routine", + "minLengthRoutine": "The routine needs to be at least {{number}} weeks long", + "maxLengthRoutine": "The routine can be at most {{number}} weeks long", + "resultingRoutine": "Resulting routine", + "addDay": "Add training day", + "deleteDayConfirmation": "This will remove all sets, exercises and progression rules", + "routineHasNoDays": "The routine has no days", + "setHasNoExercises": "This set has no exercises", + "fitDaysInWeek": "Fixed weekly schedule", + "fitDaysInWeekHelpText": "This setting controls how your routine's days are scheduled across multiple weeks. If enabled, the days will repeat in a weekly cycle. For example, a routine with workouts on Monday, Wednesday, and Friday will continue this pattern on the following Monday, Wednesday, and Friday. If disabled, the days will follow sequentially without regard to the start of a new week. This is useful for routines that don't follow a strict weekly schedule.", + "needsLogsToAdvance": "Needs logs to advance", + "needsLogsToAdvanceHelpText": "If you select this option, the routine will only progress to the next scheduled day if you've logged a workout for the current day. If this option is not selected, the routine will automatically advance to the next day regardless of whether you logged a workout or not.", + "addSuperset": "Add superset", + "addExercise": "Add exercise", + "addSet": "Add set", + "exerciseNr": "Exercise {{number}}", + "supersetNr": "Superset {{number}}", + "setNr": "Set {{number}}", + "editProgression": "Edit progression", + "progressionNeedsReplace": "One of the previous entries must have a replace operation", + "exerciseHasProgression": "This exercise has progression rules and can't be edited here. To do so, click the button.", + "exerciseNotAvailable": "Error while loading exercise", + "defaultRounding": "Default rounding", + "rounding": "Rounding (this exercise)", + "roundingHelp": "Set the default rounding for weight and repetitions (this is specially useful when using the percentage increase step in the progression). This will apply to all new sets but can be changed individually in the progression form. Leave empty to disable rounding.", + "newDay": "New day", + "addWeightLog": "Add training log", + "weightLogNotPlanned": "Saving logs to a date for which no workouts were planned.", + "logsOverview": "Logs overview", + "alsoShowLogs": "Also show logs", + "statsOverview": "Statistics", + "simpleMode": "Simple mode", + "logsHeader": "Training log for workout", + "logsFilterNote": "Note that only entries with a weight unit of kg or lb and repetitions are charted, other combinations such as time or until failure are ignored here", + "addLogToDay": "Add log to this day", + "routine": "Routine", + "routines": "Routines", + "workoutSession": "Workout session", + "rir": "RiR", + "restDay": "Rest day", + "confirmRestDay": "Confirm rest day change", + "confirmRestDayHelpText": "Please note that all sets and exercises will be removed when you mark a day as a rest day.", + "duplicate": "Duplicate routine", + "downloadPdfTable": "Download PDF (table)", + "downloadPdfLogs": "Download PDF (logs)", + "downloadIcal": "Download iCal file", + "impression": "General impression", + "impressionGood": "Good", + "impressionNeutral": "Neutral", + "impressionBad": "Bad", + "impressionHelpText": "This form records your workout results (reps, weight, etc.) for each exercise. Changes you make here, like removing or swapping exercises, only affect the specific logs you save and and won't change your overall routine. Only rows with values for either weight or repetitions are saved.", + "addAdditionalLog": "Add additional log", + "operation": "Operation", + "step": "Step", + "requirements": "Requirements", + "requirementsHelpText": "Select the workout results (from previous logs) that must be met for this rule to take effect", + "repeat": "Repeat rule", + "repeatHelpText": "Check the check box if you want this rule to continue to apply to subsequent workouts until you define a new one", + "markAsTemplate": "Manage template", + "template": "Template", + "templates": "Templates", + "publicTemplate": "Public template", + "publicTemplates": "Public templates", + "templatesHelpText": "Templates are a way to save your routine for later use and as a starting point for further routines. You can't edit templates, but you can duplicate them and make changes to the copy (as well as converting them back to a regular routine, of course).", + "publicTemplateHelpText": "Public templates are available to all users.", + "copyAndUseTemplate": "Copy and use template", + "set": { + "type": "Type", + "normalSet": "Normal set", + "dropSet": "Drop set", + "myo": "MYO", + "partial": "Partial", + "forced": "Forced", + "tut": "Time under tension", + "iso": "Isometric hold", + "jump": "Jump", + "warmup": "Warmup" + }, + "day": { + "custom": "Custom", + "enom": "Every minute on the minute", + "amrap": "As many rounds as possible", + "hiit": "High intensity interval training", + "tabata": "Tabata", + "edt": "Escalating density training", + "rft": "Rounds for time", + "afap": "As fast as possible" + } + }, + "measurements": { + "measurements": "Measurements", + "reorderCategories": "Reorder categories", + "unitFormHelpText": "The unit in which the category will be measured, such as cm or %", + "deleteInfo": "This will delete the category as well as all its entries", + "deleteInfoGroup": "This will delete the group as well as all its components and their entries", + "metricType": "Metric Type", + "chartType": "Chart type", + "chartTypes": { + "auto": "Automatic", + "line": "Line", + "bar": "Bars", + "heatmap": "Heatmap", + "delta": "Change", + "distribution": "Distribution" + }, + "chartTrend": "Trend line", + "trends": { + "reactive": "Reactive", + "balanced": "Balanced", + "sluggish": "Smooth" + }, + "chartAverageWindow": "Average over", + "chartAverageWindowDays_one": "1 day", + "chartAverageWindowDays_other": "{{count}} days", + "distributionMedian": "Median", + "distributionLatest": "Latest", + "distributionEntryCount_one": "1 entry", + "distributionEntryCount_other": "{{count}} entries", + "distributionDayCount_one": "1 day", + "distributionDayCount_other": "{{count}} days", + "partOfGroup": "Part of group", + "noGroup": "No group", + "metricTypes": { + "custom": "Custom", + "body_weight": "Body weight", + "body_fat": "Body fat", + "lean_body_mass": "Lean body mass", + "height": "Height", + "blood_pressure": "Blood pressure", + "blood_pressure_systolic": "Systolic", + "blood_pressure_diastolic": "Diastolic", + "heart_rate": "Heart rate", + "resting_heart_rate": "Resting heart rate", + "blood_oxygen": "Blood oxygen", + "steps": "Steps", + "distance": "Distance", + "energy": "Energy", + "sleep": "Sleep", + "sleep_total": "Total sleep", + "sleep_light": "Light sleep", + "sleep_deep": "Deep sleep", + "sleep_rem": "REM sleep", + "sleep_awake": "Awake" + }, + "indicatorRaw": "raw", + "indicatorAvg": "avg", + "indicatorTrend": "trend", + "noDataAvailable": "No data available", + "overallChangeWeight": "Overall change", + "chartRangeAll": "All", + "chartRangeMonths_one": "1 month", + "chartRangeMonths_other": "{{count}} months", + "chartRangeWeeks_one": "1 week", + "chartRangeWeeks_other": "{{count}} weeks", + "chartRangeYears_one": "1 year", + "chartRangeYears_other": "{{count}} years", + "customMeasurement": "Custom measurement", + "metricAlreadyTracked": "Already tracked", + "categoryFormHelpText": "Measurement category, such as 'biceps' or 'body fat'" + }, + "server": { + "abs": "Abs", + "arms": "Arms", + "back": "Back", + "barbell": "Barbell", + "bench": "Bench", + "biceps": "Biceps", + "body_weight": "Body weight", + "calves": "Calves", + "cardio": "Cardio", + "chest": "Chest", + "dumbbell": "Dumbbell", + "glutes": "Glutes", + "gym_mat": "Gym mat", + "hamstrings": "Hamstrings", + "incline_bench": "Incline bench", + "kettlebell": "Kettlebell", + "kilometers": "Kilometers", + "kilometers_per_hour": "Kilometers per hour", + "lats": "Lats", + "legs": "Legs", + "max_reps": "Max reps", + "miles": "Miles", + "miles_per_hour": "Miles per hour", + "minutes": "Minutes", + "plates": "Plates", + "pull_up_bar": "Pull up bar", + "quads": "Quads", + "repetitions": "Repetitions", + "sz_bar": "SZ bar", + "seconds": "Seconds", + "shoulders": "Shoulders", + "swiss_ball": "Swiss ball", + "triceps": "Triceps", + "until_failure": "Until failure", + "kg": "kg", + "lb": "lb", + "none__bodyweight_exercise_": "none (bodyweight exercise)" }, - "day": { - "custom": "Custom", - "enom": "Every minute on the minute", - "amrap": "As many rounds as possible", - "hiit": "High intensity interval training", - "tabata": "Tabata", - "edt": "Escalating density training", - "rft": "Rounds for time", - "afap": "As fast as possible" - } - }, - "measurements": { - "measurements": "Measurements", - "unitFormHelpText": "The unit in which the category will be measured, such as cm or %", - "deleteInfo": "This will delete the category as well as all its entries" - }, - "server": { - "abs": "Abs", - "arms": "Arms", - "back": "Back", - "barbell": "Barbell", - "bench": "Bench", - "biceps": "Biceps", - "body_weight": "Body weight", - "calves": "Calves", - "cardio": "Cardio", - "chest": "Chest", - "dumbbell": "Dumbbell", - "glutes": "Glutes", - "gym_mat": "Gym mat", - "hamstrings": "Hamstrings", - "incline_bench": "Incline bench", - "kettlebell": "Kettlebell", - "kilometers": "Kilometers", - "kilometers_per_hour": "Kilometers per hour", - "lats": "Lats", - "legs": "Legs", - "max_reps": "Max reps", - "miles": "Miles", - "miles_per_hour": "Miles per hour", - "minutes": "Minutes", - "plates": "Plates", - "pull_up_bar": "Pull up bar", - "quads": "Quads", - "repetitions": "Repetitions", - "sz_bar": "SZ bar", - "seconds": "Seconds", - "shoulders": "Shoulders", - "swiss_ball": "Swiss ball", - "triceps": "Triceps", - "until_failure": "Until failure", - "kg": "kg", - "lb": "lb", - "none__bodyweight_exercise_": "none (bodyweight exercise)" - }, - "calendar": "Calendar", - "entries": "Entries", - "no_entries_for_day": "No entries for this day" + "calendar": "Calendar", + "entries": "Entries", + "no_entries_for_day": "No entries for this day" } diff --git a/public/locales/es/translation.json b/public/locales/es/translation.json index 36780422f..42a418659 100644 --- a/public/locales/es/translation.json +++ b/public/locales/es/translation.json @@ -11,6 +11,7 @@ "nutritionalPlan": "Plan nutricional", "submit": "Enviar", "weight": "Peso", + "syncedEntryInfo": "Esta entrada se sincronizó desde una aplicación de salud y solo se puede cambiar allí", "workout": "Entrenamiento", "exercises": { "secondaryMuscles": "Músculos secundarios", @@ -252,8 +253,79 @@ "seeDetails": "Ver los detalles", "measurements": { "measurements": "Mediciones", + "reorderCategories": "Reordenar categorías", "unitFormHelpText": "La unidad en la que se medirá la categoría, como cm o %", - "deleteInfo": "Esto eliminará la categoría así como todas sus entradas" + "deleteInfo": "Esto eliminará la categoría así como todas sus entradas", + "deleteInfoGroup": "Esto eliminará el grupo así como todos sus componentes y sus entradas", + "metricType": "Tipo de métrica", + "chartType": "Tipo de gráfico", + "chartTypes": { + "auto": "Automático", + "line": "Línea", + "bar": "Barras", + "heatmap": "Mapa de calor", + "delta": "Variación", + "distribution": "Distribución" + }, + "chartTrend": "Línea de tendencia", + "trends": { + "reactive": "Reactiva", + "balanced": "Equilibrada", + "sluggish": "Suavizada" + }, + "chartAverageWindow": "Promedio de", + "chartAverageWindowDays_one": "1 día", + "chartAverageWindowDays_other": "{{count}} días", + "chartAverageWindowDays_many": "{{count}} días", + "distributionMedian": "Mediana", + "distributionLatest": "Actual", + "distributionEntryCount_one": "1 entrada", + "distributionEntryCount_other": "{{count}} entradas", + "distributionEntryCount_many": "{{count}} entradas", + "distributionDayCount_one": "1 día", + "distributionDayCount_other": "{{count}} días", + "distributionDayCount_many": "{{count}} días", + "partOfGroup": "Parte del grupo", + "noGroup": "Sin grupo", + "metricTypes": { + "custom": "Personalizado", + "body_weight": "Peso corporal", + "body_fat": "Grasa corporal", + "lean_body_mass": "Masa magra", + "height": "Altura", + "blood_pressure": "Presión arterial", + "blood_pressure_systolic": "Sistólica", + "blood_pressure_diastolic": "Diastólica", + "heart_rate": "Frecuencia cardíaca", + "resting_heart_rate": "Frecuencia cardíaca en reposo", + "blood_oxygen": "Saturación de oxígeno", + "steps": "Pasos", + "distance": "Distancia", + "energy": "Energía", + "sleep": "Sueño", + "sleep_total": "Sueño total", + "sleep_light": "Sueño ligero", + "sleep_deep": "Sueño profundo", + "sleep_rem": "Sueño REM", + "sleep_awake": "Despierto" + }, + "indicatorRaw": "Bruto", + "indicatorAvg": "medio", + "indicatorTrend": "tendencia", + "overallChangeWeight": "Cambio general", + "chartRangeAll": "Todo", + "chartRangeMonths_one": "1 mes", + "chartRangeMonths_other": "{{count}} meses", + "chartRangeMonths_many": "{{count}} meses", + "chartRangeWeeks_one": "1 semana", + "chartRangeWeeks_other": "{{count}} semanas", + "chartRangeWeeks_many": "{{count}} semanas", + "chartRangeYears_one": "1 año", + "chartRangeYears_other": "{{count}} años", + "chartRangeYears_many": "{{count}} años", + "customMeasurement": "Medición personalizada", + "metricAlreadyTracked": "Ya se está registrando", + "categoryFormHelpText": "Categoría de medición, como \"bíceps\" o \"grasa corporal\"" }, "deleteConfirmation": "¿Estás seguro de que quieres borrar \"{{name}}\"?", "nutrition": { diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 325d163b8..81844eabe 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -5,6 +5,7 @@ "days": "Jours", "edit": "Modifier", "weight": "Poids", + "syncedEntryInfo": "Cette entrée a été synchronisée depuis une app de santé et ne peut être modifiée que là-bas", "submit": "Envoyer", "add": "Ajouter", "close": "Fermer", @@ -335,8 +336,79 @@ "filters": "Filtres", "measurements": { "measurements": "Mesures", + "reorderCategories": "Réorganiser les catégories", "unitFormHelpText": "L'unité dans laquelle la catégorie sera mesurée, telle que cm ou %", - "deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées" + "deleteInfo": "Ceci supprimera la catégorie ainsi que toutes ses entrées", + "deleteInfoGroup": "Ceci supprimera le groupe ainsi que tous ses composants et leurs entrées", + "metricType": "Type de métrique", + "chartType": "Type de graphique", + "chartTypes": { + "auto": "Automatique", + "line": "Ligne", + "bar": "Barres", + "heatmap": "Carte thermique", + "delta": "Variation", + "distribution": "Répartition" + }, + "chartTrend": "Courbe de tendance", + "trends": { + "reactive": "Réactive", + "balanced": "Équilibrée", + "sluggish": "Lissée" + }, + "chartAverageWindow": "Moyenne sur", + "chartAverageWindowDays_one": "1 jour", + "chartAverageWindowDays_other": "{{count}} jours", + "chartAverageWindowDays_many": "{{count}} jours", + "distributionMedian": "Médiane", + "distributionLatest": "Actuel", + "distributionEntryCount_one": "1 entrée", + "distributionEntryCount_other": "{{count}} entrées", + "distributionEntryCount_many": "{{count}} entrées", + "distributionDayCount_one": "1 jour", + "distributionDayCount_other": "{{count}} jours", + "distributionDayCount_many": "{{count}} jours", + "partOfGroup": "Fait partie du groupe", + "noGroup": "Aucun groupe", + "metricTypes": { + "custom": "Personnalisé", + "body_weight": "Poids corporel", + "body_fat": "Graisse corporelle", + "lean_body_mass": "Masse maigre", + "height": "Taille", + "blood_pressure": "Pression artérielle", + "blood_pressure_systolic": "Systolique", + "blood_pressure_diastolic": "Diastolique", + "heart_rate": "Fréquence cardiaque", + "resting_heart_rate": "Fréquence cardiaque au repos", + "blood_oxygen": "Saturation en oxygène", + "steps": "Pas", + "distance": "Distance", + "energy": "Énergie", + "sleep": "Sommeil", + "sleep_total": "Sommeil total", + "sleep_light": "Sommeil léger", + "sleep_deep": "Sommeil profond", + "sleep_rem": "Sommeil paradoxal", + "sleep_awake": "Éveillé" + }, + "indicatorRaw": "brut", + "indicatorAvg": "moy", + "indicatorTrend": "tendance", + "overallChangeWeight": "Changement global", + "chartRangeAll": "Tout", + "chartRangeMonths_one": "1 mois", + "chartRangeMonths_other": "{{count}} mois", + "chartRangeMonths_many": "{{count}} mois", + "chartRangeWeeks_one": "1 semaine", + "chartRangeWeeks_other": "{{count}} semaines", + "chartRangeWeeks_many": "{{count}} semaines", + "chartRangeYears_one": "1 an", + "chartRangeYears_other": "{{count}} ans", + "chartRangeYears_many": "{{count}} ans", + "customMeasurement": "Mesure personnalisée", + "metricAlreadyTracked": "Déjà suivi", + "categoryFormHelpText": "Catégorie de mesure, comme « biceps » ou « graisse corporelle »" }, "downloadAsPdf": "Télécharger en PDF", "calendar": "Calendrier", diff --git a/src/components/Calendar/Components/CalendarComponent.test.tsx b/src/components/Calendar/Components/CalendarComponent.test.tsx index 43104e885..b2bacd7b3 100644 --- a/src/components/Calendar/Components/CalendarComponent.test.tsx +++ b/src/components/Calendar/Components/CalendarComponent.test.tsx @@ -1,11 +1,18 @@ import { MeasurementCategory, MeasurementEntry } from "@/components/Measurements"; -import { WeightEntry } from "@/components/Weight"; -import { getMeasurementCategories } from "@/components/Measurements/api/measurements"; +import { + getAllMeasurementEntries, + getMeasurementCategories +} from "@/components/Measurements/api/measurements"; import { getNutritionalDiaryEntries } from "@/components/Nutrition/api/nutritionalDiary"; import { getSessions } from "@/components/Routines/api/session"; -import { getWeights } from "@/components/Weight/api/weight"; +import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight"; import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata"; import { testQueryClient } from "@/tests/queryClient"; +import { + makeWeightEntry, + TEST_BODY_WEIGHT_CATEGORY_UUID, + testBodyWeightCategory +} from "@/tests/weight/testData"; import { testWorkoutSession } from "@/tests/workoutLogsRoutinesTestData"; import { dateToYYYYMMDD } from "@/core/lib/date"; import { QueryClientProvider } from "@tanstack/react-query"; @@ -21,7 +28,10 @@ import CalendarComponent from "./CalendarComponent"; vi.mock("@/components/Measurements/api/measurements"); vi.mock("@/components/Nutrition/api/nutritionalDiary"); vi.mock("@/components/Routines/api/session"); -vi.mock("@/components/Weight/api/weight"); +vi.mock("@/components/Measurements/api/bodyWeight"); +vi.mock('@/components/User/queries/profile', () => ({ + useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }), +})); /* @@ -42,27 +52,53 @@ describe('CalendarComponent', () => { vi.setSystemTime(today); user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + (getBodyWeightCategory as Mock).mockImplementation(() => Promise.resolve(testBodyWeightCategory)); (getWeights as Mock).mockImplementation(() => Promise.resolve([ - new WeightEntry( - new Date(currentYear, currentMonth, 2, 12, 0), - 70 - ), + makeWeightEntry(new Date(currentYear, currentMonth, 2, 12, 0), 70), ])); (getSessions as Mock).mockImplementation(() => Promise.resolve( [testWorkoutSession] )); + const group = new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000002', + "Blood pressure", + "mmHg", + ); + group.children = [new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000003', + "Systolic", + "mmHg", + 'custom', + false, + group.id, + )]; (getMeasurementCategories as Mock).mockImplementation(() => Promise.resolve([ new MeasurementCategory( 'cccccccc-cccc-cccc-cccc-000000000001', "Body Fat", "%", - [new MeasurementEntry( - 'dddddddd-dddd-dddd-dddd-000000000001', - 'cccccccc-cccc-cccc-cccc-000000000001', - new Date(currentYear, currentMonth, 1, 12, 0), 20, "Normal" - )] + ), + group, + ])); + // the entries of the month, over all categories, which is where the + // components of a group and the body weight arrive in as well + (getAllMeasurementEntries as Mock).mockImplementation(() => Promise.resolve([ + new MeasurementEntry( + 'dddddddd-dddd-dddd-dddd-000000000001', + 'cccccccc-cccc-cccc-cccc-000000000001', + new Date(currentYear, currentMonth, 1, 12, 0), 20, "Normal" + ), + new MeasurementEntry( + 'dddddddd-dddd-dddd-dddd-000000000002', + 'cccccccc-cccc-cccc-cccc-000000000003', + new Date(currentYear, currentMonth, 1, 12, 0), 120, "" + ), + new MeasurementEntry( + 'dddddddd-dddd-dddd-dddd-000000000003', + TEST_BODY_WEIGHT_CATEGORY_UUID, + new Date(currentYear, currentMonth, 1, 12, 0), 65, "" ), ])); @@ -166,8 +202,18 @@ describe('CalendarComponent', () => { const day = await screen.findByTestId(`day-${dateToYYYYMMDD(new Date(currentYear, currentMonth, 1))}`); await user.click(day); + // more than one measurement, so they are behind the expander + await user.click(await screen.findByText('measurements.measurements')); + // Assert - expect(await screen.findByText(/body fat: 20 %/i)).toBeInTheDocument(); + expect(await screen.findByText('Body Fat')).toBeInTheDocument(); + expect(screen.getByText(/20 %/i)).toBeInTheDocument(); + // the components of a group are categories of their own, and the only + // place their readings can come from + expect(screen.getByText('Systolic')).toBeInTheDocument(); + expect(screen.getByText(/120 mmHg/i)).toBeInTheDocument(); + // body weight has its own row on a day, it is not listed a second time + expect(screen.queryByText(/65/)).toBeNull(); }); test('displays weight details for selected day', async () => { @@ -175,10 +221,10 @@ describe('CalendarComponent', () => { renderComponent(); // Act - const day = screen.getByTestId(`day-${dateToYYYYMMDD(new Date(currentYear, currentMonth, 2))}`); + const day = await screen.findByTestId(`day-${dateToYYYYMMDD(new Date(currentYear, currentMonth, 2))}`); await user.click(day); // Assert - expect(screen.getByText('70.0')).toBeInTheDocument(); + expect(await screen.findByText('70.0 server.kg')).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/src/components/Calendar/Components/CalendarComponent.tsx b/src/components/Calendar/Components/CalendarComponent.tsx index 56f7bffe7..b6a1cd2c7 100644 --- a/src/components/Calendar/Components/CalendarComponent.tsx +++ b/src/components/Calendar/Components/CalendarComponent.tsx @@ -1,12 +1,17 @@ import CalendarDayGrid from "@/components/Calendar/Components/CalendarDayGrid"; import CalendarHeader from "@/components/Calendar/Components/CalendarHeader"; import { CalendarMeasurement } from "@/components/Calendar/Helpers/CalendarMeasurement"; -import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; -import { useMeasurementsCategoryQuery } from "@/components/Measurements"; +import { + categoryDisplayName, + MeasurementEntry, + useAllMeasurementEntriesQuery, + useBodyWeightQuery, + useMeasurementsCategoryQuery +} from "@/components/Measurements"; import { DiaryEntry, useNutritionDiaryQuery } from "@/components/Nutrition"; import { useSessionsQuery, WorkoutSession } from "@/components/Routines"; -import { useBodyWeightQuery, WeightEntry } from "@/components/Weight"; import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date"; +import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import { Box, Card, CardContent, CardHeader, useMediaQuery, useTheme } from '@mui/material'; import React, { useEffect, useMemo, useState } from 'react'; @@ -15,7 +20,7 @@ import Entries from './Entries'; export interface DayProps { date: Date, - weightEntry: WeightEntry | undefined, + weightEntry: MeasurementEntry | undefined, measurements: CalendarMeasurement[], nutritionLogs: DiaryEntry[], workoutSession: WorkoutSession | undefined, @@ -35,7 +40,12 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { const isStandalone = props.isStandalone ?? true; - const weightsQuery = useBodyWeightQuery(); + // The calendar shows one month, so body weight is read for the same window + // as everything else on it + const weightsQuery = useBodyWeightQuery({ + "date__gte": dateToYYYYMMDD(startOfMonth), + "date__lte": dateToYYYYMMDD(endOfMonth), + }); const sessionQuery = useSessionsQuery({ filtersetQuerySessions: { "date__gte": dateToYYYYMMDD(startOfMonth), @@ -46,11 +56,14 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { "date__lte": dateToYYYYMMDD(endOfMonth), } }); - const measurementQuery = useMeasurementsCategoryQuery({ - filtersetQueryEntries: { - "date__gte": dateToYYYYMMDD(startOfMonth), - "date__lte": dateToYYYYMMDD(endOfMonth), - } + // The categories name the entries below, which arrive from one read over + // all of them: asking per category would be a request each, and would + // leave out the components of a group, which are categories the list does + // not return on their own + const categoryQuery = useMeasurementsCategoryQuery(); + const measurementQuery = useAllMeasurementEntriesQuery({ + "date__gte": dateToYYYYMMDD(startOfMonth), + "date__lte": dateToYYYYMMDD(endOfMonth), }); const nutritionDiaryQuery = useNutritionDiaryQuery({ filtersetQuery: { @@ -59,8 +72,8 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { } }); - const isLoading = weightsQuery.isLoading || sessionQuery.isLoading || measurementQuery.isLoading || nutritionDiaryQuery.isLoading; - const isSuccess = weightsQuery.isSuccess && sessionQuery.isSuccess && measurementQuery.isSuccess && nutritionDiaryQuery.isSuccess; + const isLoading = weightsQuery.isLoading || sessionQuery.isLoading || categoryQuery.isLoading || measurementQuery.isLoading || nutritionDiaryQuery.isLoading; + const isSuccess = weightsQuery.isSuccess && sessionQuery.isSuccess && categoryQuery.isSuccess && measurementQuery.isSuccess && nutritionDiaryQuery.isSuccess; const defaultDay: DayProps = { date: currentDate, @@ -76,9 +89,25 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { const date = new Date(year, month, 1); const result: DayProps[] = []; - const measurements = measurementQuery.data?.flatMap(category => - category.entries.map(entry => new CalendarMeasurement(category.name, category.unit, entry.value, entry.date)) - ) ?? []; + // Body weight has its own row on a day, and the official category it + // is stored in is not in this list; an entry of it is skipped here + // rather than shown a second time + const byId = new Map((categoryQuery.data ?? []) + .flatMap(category => [category, ...category.children]) + .map(category => [category.id, category])); + + const measurements = (measurementQuery.data ?? []).flatMap(entry => { + const category = byId.get(entry.category); + + return category === undefined + ? [] + : [new CalendarMeasurement( + categoryDisplayName(category, t), + category.unit, + entry.value, + entry.date, + )]; + }); const firstDayOfMonth = new Date(year, month, 1); let dayOfWeek = firstDayOfMonth.getDay(); @@ -120,7 +149,7 @@ const CalendarComponent = (props: { isStandalone?: boolean }) => { } return result; - }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, measurementQuery.data, nutritionDiaryQuery.data]); + }, [currentYear, currentMonth, weightsQuery.data, sessionQuery.data, categoryQuery.data, measurementQuery.data, nutritionDiaryQuery.data, t]); const [selectedDay, setSelectedDay] = useState(days.find(day => isSameDay(day.date, currentDate)) || defaultDay); const theme = useTheme(); diff --git a/src/components/Calendar/Components/CalendarDay.tsx b/src/components/Calendar/Components/CalendarDay.tsx index 9dd607e02..84680fc93 100644 --- a/src/components/Calendar/Components/CalendarDay.tsx +++ b/src/components/Calendar/Components/CalendarDay.tsx @@ -1,7 +1,7 @@ import { useMediaQuery, useTheme } from '@mui/material'; import React from 'react'; import { dateToYYYYMMDD, isSameDay } from "@/core/lib/date"; -import { DayProps } from "./CalendarComponent"; +import type { DayProps } from "./CalendarComponent"; interface CalendarDayProps { day: DayProps; diff --git a/src/components/Calendar/Components/CalendarDayGrid.tsx b/src/components/Calendar/Components/CalendarDayGrid.tsx index 8f085140a..e2d236ec5 100644 --- a/src/components/Calendar/Components/CalendarDayGrid.tsx +++ b/src/components/Calendar/Components/CalendarDayGrid.tsx @@ -2,7 +2,7 @@ import { Typography } from '@mui/material'; import Grid from "@mui/material/Grid"; import React from 'react'; import { useTranslation } from "react-i18next"; -import { DayProps } from "./CalendarComponent"; +import type { DayProps } from "./CalendarComponent"; import CalendarDay from './CalendarDay'; interface CalendarDayGridProps { diff --git a/src/components/Calendar/Components/Entries.test.tsx b/src/components/Calendar/Components/Entries.test.tsx index 48c2d0d32..7f7fae9a8 100644 --- a/src/components/Calendar/Components/Entries.test.tsx +++ b/src/components/Calendar/Components/Entries.test.tsx @@ -1,7 +1,10 @@ +import { QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { WeightEntry } from '@/components/Weight'; +import { MeasurementEntry } from "@/components/Measurements"; import { WorkoutSession } from "@/components/Routines/models/WorkoutSession"; +import { testQueryClient } from "@/tests/queryClient"; +import { makeWeightEntry } from "@/tests/weight/testData"; import React from 'react'; import { TEST_INGREDIENT_1 } from "@/tests/ingredientTestdata"; import { TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2 } from "@/tests/nutritionDiaryTestdata"; @@ -10,14 +13,15 @@ import { dateToLocale } from "@/core/lib/date"; import { DayProps } from './CalendarComponent'; import Entries from './Entries'; +vi.mock("@/components/Measurements/api/bodyWeight"); +vi.mock('@/components/User/queries/profile', () => ({ + useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }), +})); describe('Entries Component', () => { const mockDate = new Date('2025-4-25'); - const mockWeightEntry: WeightEntry = new WeightEntry( - mockDate, - 75.5 - ); + const mockWeightEntry: MeasurementEntry = makeWeightEntry(mockDate, 75.5); const defaultProps: DayProps = { date: mockDate, @@ -28,7 +32,11 @@ describe('Entries Component', () => { }; test('Correctly shows date and title', () => { - render(); + render( + + + + ); expect(screen.getByText(/entries/i)).toBeInTheDocument(); expect(screen.getByText(dateToLocale(mockDate), { exact: false })).toBeInTheDocument(); @@ -40,10 +48,14 @@ describe('Entries Component', () => { weightEntry: mockWeightEntry }; - render(); + render( + + + + ); expect(screen.getByText('weight')).toBeInTheDocument(); - expect(screen.getByText('75.5')).toBeInTheDocument(); + expect(screen.getByText('75.5 server.kg')).toBeInTheDocument(); }); test('Shows measurement directly, if theres only one entry', () => { @@ -54,7 +66,11 @@ describe('Entries Component', () => { ] }; - render(); + render( + + + + ); expect(screen.getByText('measurements.measurements')).toBeInTheDocument(); expect(screen.getByText('Chest size: 95 cm')).toBeInTheDocument(); @@ -69,7 +85,11 @@ describe('Entries Component', () => { ] }; - render(); + render( + + + + ); // Initially only the header is visible expect(screen.getByText('measurements.measurements')).toBeInTheDocument(); @@ -89,7 +109,11 @@ describe('Entries Component', () => { workoutSession: new WorkoutSession({ ...testWorkoutSession, logs: testWorkoutLogs }) }; - render(); + render( + + + + ); // Initially only the session header with its summary is visible expect(screen.getByText('routines.workoutSession')).toBeInTheDocument(); @@ -111,7 +135,11 @@ describe('Entries Component', () => { nutritionLogs: [TEST_DIARY_ENTRY_1, TEST_DIARY_ENTRY_2] }; - render(); + render( + + + + ); // Initially only the header is visible expect(screen.getByText('nutrition.nutritionalDiary')).toBeInTheDocument(); diff --git a/src/components/Calendar/Components/Entries.tsx b/src/components/Calendar/Components/Entries.tsx index 9b5b23497..8149763d7 100644 --- a/src/components/Calendar/Components/Entries.tsx +++ b/src/components/Calendar/Components/Entries.tsx @@ -12,8 +12,9 @@ import { } from '@mui/material'; import React from 'react'; import { useTranslation } from "react-i18next"; +import { useBodyWeightCategoryQuery, useDisplayWeightUnit } from "@/components/Measurements"; import { dateToLocale } from "@/core/lib/date"; -import { DayProps } from "./CalendarComponent"; +import type { DayProps } from "./CalendarComponent"; interface LogProps { selectedDay: DayProps; @@ -22,6 +23,9 @@ interface LogProps { const Entries: React.FC = ({ selectedDay, isStandalone }) => { const [t] = useTranslation(); + const displayWeightUnit = useDisplayWeightUnit(); + // Entries without their own unit fall back to the one of the category + const categoryUnit = useBodyWeightCategoryQuery().data?.unit ?? 'kg'; const [openMeasurements, setOpenMeasurements] = React.useState(false); const [openSession, setOpenSession] = React.useState(false); @@ -65,7 +69,7 @@ const Entries: React.FC = ({ selectedDay, isStandalone }) => { } diff --git a/src/components/Dashboard/MeasurementCard.test.tsx b/src/components/Dashboard/MeasurementCard.test.tsx index ab08af80f..78eedab46 100644 --- a/src/components/Dashboard/MeasurementCard.test.tsx +++ b/src/components/Dashboard/MeasurementCard.test.tsx @@ -1,15 +1,32 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import { MeasurementCard } from "@/components/Dashboard/MeasurementCard"; -import { useMeasurementsCategoryQuery } from "@/components/Measurements"; -import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData"; +import { + MeasurementCategory, + useMeasurementEntriesQuery, + useMeasurementsCategoryQuery +} from "@/components/Measurements"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { + TEST_MEASUREMENT_CATEGORY_1, + TEST_MEASUREMENT_CATEGORY_2, + TEST_MEASUREMENT_SEED_1, + TEST_MEASUREMENT_SEED_2 +} from "@/tests/measurementsTestData"; import type { Mock } from 'vitest'; +import { mockChartQueries } from "@/tests/chartQueries"; vi.mock("@/components/Measurements/queries"); vi.useFakeTimers(); const queryClient = new QueryClient(); +/** Answers the entry reads of the table under each chart, by category */ +const mockEntryQueries = (byCategory: Record) => + (useMeasurementEntriesQuery as Mock).mockImplementation( + (categoryId: string) => ({ data: byCategory[categoryId] ?? [] }) + ); + describe("smoke test the MeasurementCard component", () => { describe("Measurements available", () => { @@ -23,6 +40,12 @@ describe("smoke test the MeasurementCard component", () => { TEST_MEASUREMENT_CATEGORY_2 ] })); + // The cards read their points from the aggregated queries + mockChartQueries([TEST_MEASUREMENT_SEED_1, TEST_MEASUREMENT_SEED_2]); + mockEntryQueries({ + [TEST_MEASUREMENT_CATEGORY_1.id!]: TEST_MEASUREMENT_SEED_1.entries, + [TEST_MEASUREMENT_CATEGORY_2.id!]: TEST_MEASUREMENT_SEED_2.entries, + }); }); test('renders the current categories correctly', async () => { @@ -45,6 +68,57 @@ describe("smoke test the MeasurementCard component", () => { }); + describe("Multi-value group", () => { + + beforeEach(() => { + const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg'); + const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure', false, 'g-1'); + const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure', false, 'g-1'); + group.children = [systolic, diastolic]; + const systolicEntries = [ + // sorted by date descending, like the server delivers them + new MeasurementEntry('d-2', 'c-sys', new Date(2023, 1, 2, 8), 125, ''), + new MeasurementEntry('d-1', 'c-sys', new Date(2023, 1, 1, 8), 120, ''), + ]; + + (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({ + isSuccess: true, + isLoading: false, + data: [group] + })); + mockChartQueries([ + { category: group }, + { category: systolic, entries: systolicEntries }, + { category: diastolic }, + ]); + // the component row reads the newest entry, the group parent none + mockEntryQueries({ 'c-sys': systolicEntries }); + }); + + test('lists the latest reading of each component', async () => { + + // Act + render( + + + + ); + + // Assert + expect(screen.getAllByText('Blood pressure').length).toBeGreaterThan(0); + expect(screen.getAllByText('Systolic').length).toBeGreaterThan(0); + + // scoped to the table, the values also appear on the chart's axis + const table = within(screen.getByRole('table')); + expect(table.getByText('125 mmHg')).toBeInTheDocument(); + // no reading yet for the diastolic component + expect(table.getByText('—')).toBeInTheDocument(); + // only the latest reading is listed + expect(table.queryByText('120 mmHg')).toBeNull(); + }); + }); + + describe("No data available", () => { beforeEach(() => { diff --git a/src/components/Dashboard/MeasurementCard.tsx b/src/components/Dashboard/MeasurementCard.tsx index 93ace1b80..cb98d8be7 100644 --- a/src/components/Dashboard/MeasurementCard.tsx +++ b/src/components/Dashboard/MeasurementCard.tsx @@ -3,13 +3,23 @@ import { DashboardCard } from "@/components/Dashboard/DashboardCard"; import { EmptyCard } from "@/components/Dashboard/EmptyCard"; import { CategoryForm, + componentColor, + componentPalette, + chartQueryFor, + DEFAULT_CHART_RANGE, + groupChart, + groupComponentPoints, MeasurementCategory, MeasurementChart, - useMeasurementsCategoryQuery + useMeasurementBucketsQuery, + useMeasurementEntriesQuery, + useMeasurementsCategoryQuery, + valueWithUnit } from "@/components/Measurements"; import i18n from "@/i18n"; import { makeLink, WgerLink } from "@/core/lib/url"; import "slick-carousel/slick/slick.css"; +import { Box, Stack } from "@mui/material"; import Button from "@mui/material/Button"; import Table from "@mui/material/Table"; import TableBody from "@mui/material/TableBody"; @@ -23,6 +33,9 @@ import Slider, { Settings } from "react-slick"; import "slick-carousel/slick/slick-theme.css"; +/** Entries the table under each chart lists, at most */ +const TABLE_ROWS = 5; + export const MeasurementCard = () => { const { t } = useTranslation(); const categoryQuery = useMeasurementsCategoryQuery(); @@ -82,9 +95,61 @@ const MeasurementCardContent = (props: { categories: MeasurementCategory[] }) => }; +/** + * One component of a group, with its latest reading. + * + * The dot ties the row to the component's line in the chart above, and is + * left out where the chart draws something else than one line per component. + */ +const ComponentRow = (props: { component: MeasurementCategory, unit: string, color?: string }) => { + // Only the newest one is shown, so only the newest one is read + const latest = useMeasurementEntriesQuery(props.component.id!, {}, 1).data?.[0]; + + return + + + {props.color !== undefined && } + {props.component.name} + + + + {latest !== undefined + ? valueWithUnit(latest.valueIn(props.unit, props.unit), props.unit, i18n.language) + : '—'} + + ; +}; + const MeasurementCardTableContent = (props: { category: MeasurementCategory }) => { const { t } = useTranslation(); + // The dot ties a component row to its line in the chart above. A range is + // a single bar, where the ends speak for themselves. The same derivation + // the chart uses, so both decide over one span and one cached request. + const { ids, level, filters } = chartQueryFor(props.category, DEFAULT_CHART_RANGE); + const buckets = useMeasurementBucketsQuery( + ids, + level, + filters, + props.category.isGroup, + ).data ?? []; + const showComponentColors = props.category.isGroup + && groupChart(props.category, groupComponentPoints(props.category, buckets)).kind + === 'components'; + const palette = componentPalette(props.category.children.length); + // A group lists its components instead, each of which reads its own + const entries = useMeasurementEntriesQuery( + props.category.id!, + {}, + TABLE_ROWS, + !props.category.isGroup, + ).data ?? []; + return (<> {props.category.name} @@ -93,17 +158,32 @@ const MeasurementCardTableContent = (props: { category: MeasurementCategory }) = - {t('date')} + {props.category.isGroup ? t('name') : t('date')} {t('value')} - {[...props.category.entries].slice(0, 5).map(entry => ( - - {entry.date.toLocaleDateString()} - {entry.value} {props.category.unit} - - ))} + {props.category.isGroup + // group parents hold no entries themselves, list the + // latest reading of each component instead + ? props.category.children.map((child, index) => + ) + : entries.map(entry => ( + + {entry.date.toLocaleDateString()} + + {valueWithUnit( + entry.valueIn(props.category.unit, props.category.unit), + props.category.unit, + i18n.language, + )} + + + ))}
); diff --git a/src/components/Dashboard/NutritionCard.tsx b/src/components/Dashboard/NutritionCard.tsx index 2d55a0eb7..75653341f 100644 --- a/src/components/Dashboard/NutritionCard.tsx +++ b/src/components/Dashboard/NutritionCard.tsx @@ -1,4 +1,5 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; +import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; import { EmptyCard } from "@/components/Dashboard/EmptyCard"; import { @@ -130,6 +131,7 @@ const MealListItem = (props: { meal: Meal; planId: string }) => { return ( <> + {expandView ? : } diff --git a/src/components/Dashboard/TrophiesCard.test.tsx b/src/components/Dashboard/TrophiesCard.test.tsx index b2243566b..aa94aef08 100644 --- a/src/components/Dashboard/TrophiesCard.test.tsx +++ b/src/components/Dashboard/TrophiesCard.test.tsx @@ -1,11 +1,9 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from '@testing-library/react'; import { TrophiesCard } from "@/components/Dashboard/TrophiesCard"; -import { useUserTrophiesQuery } from "@/components/Trophies"; -import { Trophy } from "@/components/Trophies/models/trophy"; -import { UserTrophy } from "@/components/Trophies/models/userTrophy"; +import { Trophy, UserTrophy, useUserTrophiesQuery } from "@/components/Trophies"; import { testQueryClient } from "@/tests/queryClient"; -import { testUserTrophies } from "@/tests/trophies/trophiesTestData"; +import { testTrophies, testUserTrophies } from "@/tests/trophies/trophiesTestData"; import type { Mock } from 'vitest'; vi.mock("@/components/Trophies/queries/trophies"); @@ -137,6 +135,54 @@ describe("test the TrophiesCard component", () => { }); + describe("Same trophy awarded twice", () => { + beforeEach(() => { + // Two user-trophy rows for one trophy, as a repeatable award creates + const trophy = testTrophies()[0]; + (useUserTrophiesQuery as Mock).mockImplementation(() => ({ + isSuccess: true, + isLoading: false, + data: [ + new UserTrophy({ + id: 1, + trophy: trophy, + earnedAt: new Date('2025-12-19T10:00:00Z'), + progress: 100, + isNotified: true, + }), + new UserTrophy({ + id: 2, + trophy: trophy, + earnedAt: new Date('2025-12-20T10:00:00Z'), + progress: 100, + isNotified: true, + }), + ] + })); + }); + + test('renders both awards, with unique keys', async () => { + // Arrange + const errorSpy = vi.spyOn(console, 'error'); + + // Act + render( + + + + ); + + // Assert + expect(screen.getAllByText('Beginner')).toHaveLength(2); + const duplicateKeyErrors = errorSpy.mock.calls.filter( + (args) => String(args[0]).includes('same key') + ); + expect(duplicateKeyErrors).toHaveLength(0); + errorSpy.mockRestore(); + }); + }); + + describe("No trophies available", () => { beforeEach(() => { diff --git a/src/components/Dashboard/TrophiesCard.tsx b/src/components/Dashboard/TrophiesCard.tsx index 5096ea5c8..a5f95b812 100644 --- a/src/components/Dashboard/TrophiesCard.tsx +++ b/src/components/Dashboard/TrophiesCard.tsx @@ -46,7 +46,8 @@ function TrophiesCardContent(props: { trophies: UserTrophy[] }) { > - {/* Keyed by the award, the same trophy can be awarded more than once */} + {/* Keyed by the user-trophy row: repeatable trophies can + * legitimately award the same trophy more than once */} {props.trophies.map((userTrophy) => ( diff --git a/src/components/Dashboard/WeightCard.test.tsx b/src/components/Dashboard/WeightCard.test.tsx index abe30d20d..b27d0a46f 100644 --- a/src/components/Dashboard/WeightCard.test.tsx +++ b/src/components/Dashboard/WeightCard.test.tsx @@ -1,12 +1,12 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from '@testing-library/react'; -import { useBodyWeightQuery } from "@/components/Weight"; +import { useBodyWeightCategoryQuery, useBodyWeightQuery, useDisplayWeightUnit } from "@/components/Measurements"; import { WeightCard } from "@/components/Dashboard/WeightCard"; import { testQueryClient } from "@/tests/queryClient"; -import { testWeightEntries } from "@/tests/weight/testData"; +import { testBodyWeightCategory, testWeightEntries } from "@/tests/weight/testData"; import type { Mock } from 'vitest'; -vi.mock("@/components/Weight/queries"); +vi.mock("@/components/Measurements/queries/bodyWeight"); describe("test the WeightCard component", () => { @@ -17,6 +17,11 @@ describe("test the WeightCard component", () => { isLoading: false, data: testWeightEntries })); + (useDisplayWeightUnit as Mock).mockReturnValue('kg'); + (useBodyWeightCategoryQuery as Mock).mockImplementation(() => ({ + isLoading: false, + data: testBodyWeightCategory + })); }); afterEach(() => { diff --git a/src/components/Dashboard/WeightCard.tsx b/src/components/Dashboard/WeightCard.tsx index 00f81ce1e..1aa069f5c 100644 --- a/src/components/Dashboard/WeightCard.tsx +++ b/src/components/Dashboard/WeightCard.tsx @@ -1,7 +1,16 @@ import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; import { EmptyCard } from "@/components/Dashboard/EmptyCard"; -import { useBodyWeightQuery, WeightChart, WeightEntry, WeightForm, WeightTableDashboard } from "@/components/Weight"; +import { + entryFilterFor, + MeasurementEntry, + useBodyWeightCategoryQuery, + useBodyWeightQuery, + useDisplayWeightUnit, + WeightChart, + WeightForm, + WeightTableDashboard +} from "@/components/Measurements"; import { makeLink, WgerLink } from "@/core/lib/url"; import AddIcon from "@mui/icons-material/Add"; import { Box, Button, IconButton } from "@mui/material"; @@ -12,7 +21,7 @@ import { DashboardCard } from "./DashboardCard"; export const WeightCard = () => { const [t] = useTranslation(); - const weightyQuery = useBodyWeightQuery("lastYear"); + const weightyQuery = useBodyWeightQuery(entryFilterFor('lastYear')); if (weightyQuery.isLoading) { return ; @@ -24,11 +33,16 @@ export const WeightCard = () => { } /> ); }; -export const WeightCardContent = (props: { entries: WeightEntry[] }) => { +export const WeightCardContent = (props: { entries: MeasurementEntry[] }) => { const [openModal, setOpenModal] = React.useState(false); const handleOpenModal = () => setOpenModal(true); const handleCloseModal = () => setOpenModal(false); const [t, i18n] = useTranslation(); + const displayUnit = useDisplayWeightUnit(); + const categoryQuery = useBodyWeightCategoryQuery(); + + // Entries without their own unit fall back to the one of the category + const categoryUnit = categoryQuery.data?.unit ?? 'kg'; return ( <> @@ -48,9 +62,17 @@ export const WeightCardContent = (props: { entries: WeightEntry[] }) => { } > - + - + diff --git a/src/components/Exercises/screens/Add/Step1Basics.tsx b/src/components/Exercises/screens/Add/Step1Basics.tsx index a671be24b..f9671f305 100644 --- a/src/components/Exercises/screens/Add/Step1Basics.tsx +++ b/src/components/Exercises/screens/Add/Step1Basics.tsx @@ -1,7 +1,7 @@ import { Autocomplete, Box, Button, MenuItem, Stack, TextField, } from "@mui/material"; import Grid from '@mui/material/Grid'; import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget"; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; import { ExerciseEquipmentSelect } from "@/components/Exercises/forms/ExerciseEquipmentSelect"; import { ExerciseName } from "@/components/Exercises/forms/ExerciseName"; diff --git a/src/components/Exercises/screens/Add/Step2Variations.tsx b/src/components/Exercises/screens/Add/Step2Variations.tsx index c3eb87b54..6fb4f9063 100644 --- a/src/components/Exercises/screens/Add/Step2Variations.tsx +++ b/src/components/Exercises/screens/Add/Step2Variations.tsx @@ -1,7 +1,7 @@ import { Exercise } from "@/components/Exercises/models/exercise"; import { useExercisesQuery } from "@/components/Exercises/queries"; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; import { useExerciseSubmissionStateValue } from "@/components/Exercises/screens/Add/state"; import { setNewBaseVariationId, diff --git a/src/components/Exercises/screens/Add/Step3Description.tsx b/src/components/Exercises/screens/Add/Step3Description.tsx index 0e62a6582..e0519a49e 100644 --- a/src/components/Exercises/screens/Add/Step3Description.tsx +++ b/src/components/Exercises/screens/Add/Step3Description.tsx @@ -1,8 +1,8 @@ import { Box, Button, Stack } from "@mui/material"; import Grid from '@mui/material/Grid'; import { useLanguageCheckQuery } from "@/core/queries"; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; -import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { MarkdownEditor } from "@/core/forms/MarkdownEditor"; import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes"; import { descriptionValidator, noteValidator } from "@/components/Exercises/forms/yupValidators"; diff --git a/src/components/Exercises/screens/Add/Step4Translations.tsx b/src/components/Exercises/screens/Add/Step4Translations.tsx index 5f0f1a013..df831f29b 100644 --- a/src/components/Exercises/screens/Add/Step4Translations.tsx +++ b/src/components/Exercises/screens/Add/Step4Translations.tsx @@ -14,8 +14,8 @@ import Grid from '@mui/material/Grid'; import { MarkdownEditor } from "@/core/forms/MarkdownEditor"; import { LoadingWidget } from "@/core/ui/LoadingWidget/LoadingWidget"; import { useLanguageCheckQuery } from "@/core/queries"; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; -import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; import { ExerciseName } from "@/components/Exercises/forms/ExerciseName"; import { ExerciseNotes } from "@/components/Exercises/forms/ExerciseNotes"; diff --git a/src/components/Exercises/screens/Add/Step5Images.test.tsx b/src/components/Exercises/screens/Add/Step5Images.test.tsx index 7f72ee7a3..f5aa999ab 100644 --- a/src/components/Exercises/screens/Add/Step5Images.test.tsx +++ b/src/components/Exercises/screens/Add/Step5Images.test.tsx @@ -1,7 +1,5 @@ -import { - exerciseSubmissionInitialState, - ExerciseSubmissionStateContext -} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState"; +import { exerciseSubmissionInitialState } from "@/components/Exercises/screens/Add/state/stateTypes"; +import { ExerciseSubmissionStateContext } from "@/components/Exercises/screens/Add/state/exerciseSubmissionState"; import { Step5Images } from "@/components/Exercises/screens/Add/Step5Images"; import { ImageFormData } from "@/components/Exercises/models/exercise"; import { ImageStyle } from "@/components/Exercises/models/image"; diff --git a/src/components/Exercises/screens/Add/Step5Images.tsx b/src/components/Exercises/screens/Add/Step5Images.tsx index b472ffacd..d89e66750 100644 --- a/src/components/Exercises/screens/Add/Step5Images.tsx +++ b/src/components/Exercises/screens/Add/Step5Images.tsx @@ -11,7 +11,7 @@ import { } from "@mui/material"; import Grid from '@mui/material/Grid'; import ImageList from '@mui/material/ImageList'; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; import { ImageFormModal } from "@/components/Exercises/forms/ImageModal"; import { ImageFormData } from "@/components/Exercises/models/exercise"; import { ImageStyle } from "@/components/Exercises/models/image"; diff --git a/src/components/Exercises/screens/Add/Step6Overview.tsx b/src/components/Exercises/screens/Add/Step6Overview.tsx index e0e4f2d8a..19e3ae820 100644 --- a/src/components/Exercises/screens/Add/Step6Overview.tsx +++ b/src/components/Exercises/screens/Add/Step6Overview.tsx @@ -16,7 +16,7 @@ import Grid from '@mui/material/Grid'; import ImageList from "@mui/material/ImageList"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; -import { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; +import type { StepProps } from "@/components/Exercises/screens/Add/AddExerciseStepper"; import { useAddExerciseFullQuery, useAddExerciseImageQuery, diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts index 024592e11..31ab155f9 100644 --- a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts +++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.test.ts @@ -22,7 +22,7 @@ import { import { ExerciseSubmissionAction, ExerciseSubmissionState -} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState"; +} from "@/components/Exercises/screens/Add/state/stateTypes"; import { ImageFormData } from "@/components/Exercises/models/exercise"; import { ImageStyle } from "@/components/Exercises/models/image"; diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts index acaca668d..fed64f43b 100644 --- a/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts +++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionReducer.ts @@ -1,8 +1,9 @@ -import { exerciseSubmissionInitialState, SetExerciseSubmissionState } from '@/components/Exercises/screens/Add/state'; import { + exerciseSubmissionInitialState, ExerciseSubmissionAction, - ExerciseSubmissionState -} from "@/components/Exercises/screens/Add/state/exerciseSubmissionState"; + ExerciseSubmissionState, + SetExerciseSubmissionState +} from "@/components/Exercises/screens/Add/state/stateTypes"; import { ImageFormData } from "@/components/Exercises/models/exercise"; diff --git a/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx b/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx index 95b153a21..4abfc5860 100644 --- a/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx +++ b/src/components/Exercises/screens/Add/state/exerciseSubmissionState.tsx @@ -1,57 +1,10 @@ -import { ImageFormData } from "@/components/Exercises/models/exercise"; import React, { createContext, useContext, useReducer } from "react"; import { exerciseSubmissionReducer } from "@/components/Exercises/screens/Add/state/exerciseSubmissionReducer"; -import { SetExerciseSubmissionState } from "@/components/Exercises/screens/Add/state/stateTypes"; - -export type ExerciseSubmissionAction = { - type: SetExerciseSubmissionState, - payload?: number | number[] | string | string[] | null | ImageFormData[], -} - -export type ExerciseSubmissionState = { - nameEn: string; - descriptionEn: string; - alternativeNamesEn: string[]; - notesEn: string[]; - - languageId: number | null; - nameI18n: string; - alternativeNamesI18n: string[]; - descriptionI18n: string; - notesI18n: string[]; - - category: number | null; - muscles: number[]; - musclesSecondary: number[]; - equipment: number[]; - variationGroup: string | null; - newVariationExerciseId: number | null; - - images: ImageFormData[]; -} - -export const exerciseSubmissionInitialState: ExerciseSubmissionState = { - category: null, - muscles: [], - musclesSecondary: [], - variationGroup: null, - newVariationExerciseId: null, - languageId: null, - equipment: [], - - nameEn: "", - descriptionEn: "", - alternativeNamesEn: [], - notesEn: [], - - nameI18n: "", - alternativeNamesI18n: [], - descriptionI18n: "", - notesI18n: [], - - images: [], -}; - +import { + ExerciseSubmissionAction, + exerciseSubmissionInitialState, + ExerciseSubmissionState +} from "@/components/Exercises/screens/Add/state/stateTypes"; export const ExerciseSubmissionStateContext = createContext<[ExerciseSubmissionState, React.Dispatch]>([ exerciseSubmissionInitialState, diff --git a/src/components/Exercises/screens/Add/state/index.ts b/src/components/Exercises/screens/Add/state/index.ts index cd88d7954..2468550f5 100644 --- a/src/components/Exercises/screens/Add/state/index.ts +++ b/src/components/Exercises/screens/Add/state/index.ts @@ -1,9 +1,10 @@ -export { SetExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/stateTypes'; - +export { + SetExerciseSubmissionState, exerciseSubmissionInitialState +} from '@/components/Exercises/screens/Add/state/stateTypes'; +export type { ExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/stateTypes'; -export type { ExerciseSubmissionState } from '@/components/Exercises/screens/Add/state/exerciseSubmissionState'; export { - ExerciseSubmissionStateProvider, useExerciseSubmissionStateValue, exerciseSubmissionInitialState + ExerciseSubmissionStateProvider, useExerciseSubmissionStateValue } from '@/components/Exercises/screens/Add/state/exerciseSubmissionState'; diff --git a/src/components/Exercises/screens/Add/state/stateTypes.ts b/src/components/Exercises/screens/Add/state/stateTypes.ts index efd96e5fb..ef458ffde 100644 --- a/src/components/Exercises/screens/Add/state/stateTypes.ts +++ b/src/components/Exercises/screens/Add/state/stateTypes.ts @@ -1,3 +1,5 @@ +import { ImageFormData } from "@/components/Exercises/models/exercise"; + export enum SetExerciseSubmissionState { RESET, @@ -18,3 +20,52 @@ export enum SetExerciseSubmissionState { SET_NOTES_I18N, SET_IMAGES } + +export type ExerciseSubmissionAction = { + type: SetExerciseSubmissionState, + payload?: number | number[] | string | string[] | null | ImageFormData[], +} + +export type ExerciseSubmissionState = { + nameEn: string; + descriptionEn: string; + alternativeNamesEn: string[]; + notesEn: string[]; + + languageId: number | null; + nameI18n: string; + alternativeNamesI18n: string[]; + descriptionI18n: string; + notesI18n: string[]; + + category: number | null; + muscles: number[]; + musclesSecondary: number[]; + equipment: number[]; + variationGroup: string | null; + newVariationExerciseId: number | null; + + images: ImageFormData[]; +} + +export const exerciseSubmissionInitialState: ExerciseSubmissionState = { + category: null, + muscles: [], + musclesSecondary: [], + variationGroup: null, + newVariationExerciseId: null, + languageId: null, + equipment: [], + + nameEn: "", + descriptionEn: "", + alternativeNamesEn: [], + notesEn: [], + + nameI18n: "", + alternativeNamesI18n: [], + descriptionI18n: "", + notesI18n: [], + + images: [], +}; diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx index 6fbb37977..879dcfd69 100644 --- a/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx +++ b/src/components/Exercises/screens/Detail/ExerciseDetailEdit.tsx @@ -1,4 +1,4 @@ -import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails"; +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { EditExerciseCategory } from "@/components/Exercises/forms/Category"; import { EditExerciseEquipment } from "@/components/Exercises/forms/Equipment"; import { ExerciseAliases } from "@/components/Exercises/forms/ExerciseAliases"; diff --git a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx index dfe247e1a..03a72b2b5 100644 --- a/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx +++ b/src/components/Exercises/screens/Detail/ExerciseDetailView.tsx @@ -1,6 +1,6 @@ import { Box, Button, Divider, Typography } from "@mui/material"; import Grid from '@mui/material/Grid'; -import { PaddingBox } from "@/components/Exercises/screens/Detail/ExerciseDetails"; +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; import { OverviewCard } from "@/components/Exercises/screens/Detail/OverviewCard"; import { SideGallery, SideVideoGallery } from "@/components/Exercises/screens/Detail/SideGallery"; import { Exercise } from "@/components/Exercises/models/exercise"; diff --git a/src/components/Exercises/screens/Detail/ExerciseDetails.tsx b/src/components/Exercises/screens/Detail/ExerciseDetails.tsx index da55b6c49..8c7a44543 100644 --- a/src/components/Exercises/screens/Detail/ExerciseDetails.tsx +++ b/src/components/Exercises/screens/Detail/ExerciseDetails.tsx @@ -4,15 +4,12 @@ import { ExerciseDetailView } from "@/components/Exercises/screens/Detail/Exerci import { getLanguageByShortName, Language } from "@/components/Exercises/models/language"; import { useExerciseQuery, useExercisesForVariationQuery, useLanguageQuery, } from "@/components/Exercises/queries"; import { ENGLISH_LANGUAGE_OBJ } from "@/core/lib/consts"; -import { Box, Container } from "@mui/material"; +import { Container } from "@mui/material"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useParams } from "react-router-dom"; import { Head } from "./Head"; - -export const PaddingBox = () => { - return ; -}; +import { PaddingBox } from "@/components/Exercises/widgets/PaddingBox"; export const ExerciseDetails = () => { const [language, setLanguage] = useState(ENGLISH_LANGUAGE_OBJ); diff --git a/src/components/Exercises/widgets/PaddingBox.tsx b/src/components/Exercises/widgets/PaddingBox.tsx new file mode 100644 index 000000000..86cbd96f2 --- /dev/null +++ b/src/components/Exercises/widgets/PaddingBox.tsx @@ -0,0 +1,7 @@ +import { Box } from "@mui/material"; +import React from "react"; + +/** Vertical space between the blocks of the exercise detail pages */ +export const PaddingBox = () => { + return ; +}; diff --git a/src/components/Measurements/api/bodyWeight.test.ts b/src/components/Measurements/api/bodyWeight.test.ts new file mode 100644 index 000000000..5c4b8323b --- /dev/null +++ b/src/components/Measurements/api/bodyWeight.test.ts @@ -0,0 +1,140 @@ +import axios from "axios"; +import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData"; +import { getBodyWeightCategory, getWeights } from "./bodyWeight"; +import type { Mock } from 'vitest'; + +vi.mock("axios"); + +const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000042'; +const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001'; +const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000002'; + +describe("weight service tests", () => { + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('an empty category response raises a clear error', async () => { + + (axios.get as Mock).mockImplementation(() => Promise.resolve({ + data: { count: 0, next: null, previous: null, results: [] } + })); + + await expect(getBodyWeightCategory()).rejects.toThrow('No official body weight category'); + }); + + test('GET the official body weight category', async () => { + + const categoryResponse = { + count: 1, + next: null, + previous: null, + results: [ + { id: CATEGORY_UUID, name: 'Body weight', unit: 'kg', metric_type: 'body_weight', is_official: true }, + ] + }; + (axios.get as Mock).mockImplementation(() => Promise.resolve({ data: categoryResponse })); + + const result = await getBodyWeightCategory(); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('metric_type=body_weight'), + expect.anything() + ); + expect(result!.id).toBe(CATEGORY_UUID); + expect(result!.metricType).toBe('body_weight'); + expect(result!.isOfficial).toBe(true); + }); + + test('GET weight entries', async () => { + + // one entry carries its own unit, one falls back to the category unit + const weightResponse = { + count: 2, + next: null, + previous: null, + results: [ + { + id: ENTRY_UUID, + category: CATEGORY_UUID, + value: 80, + date: '2021-12-10', + notes: '', + source: 'user', + extra_data: {} + }, + { + id: ENTRY_UUID_2, + category: CATEGORY_UUID, + value: 90, + date: '2021-12-20', + notes: '', + source: 'apple', + extra_data: { unit: 'lb' } + }, + ] + }; + + (axios.get as Mock).mockImplementation(() => Promise.resolve({ data: weightResponse })); + + const result = await getWeights(testBodyWeightCategory); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining(`category=${CATEGORY_UUID}`), + expect.anything() + ); + expect(result).toStrictEqual([ + // no unit of its own: the category unit applies when the value is read + makeWeightEntry(new Date('2021-12-10'), 80, { id: ENTRY_UUID, source: 'user' }), + makeWeightEntry(new Date('2021-12-20'), 90, { id: ENTRY_UUID_2, unit: 'lb', source: 'apple' }), + ]); + }); + + test('GET weight entries collects every page', async () => { + + const page = (id: string, value: number, next: string | null) => ({ + count: 2, + next: next, + previous: null, + results: [ + { + id: id, + category: CATEGORY_UUID, + value: value, + date: '2021-12-10', + notes: '', + source: 'user', + extra_data: {} + }, + ] + }); + + (axios.get as Mock) + .mockImplementationOnce(() => Promise.resolve({ + data: page(ENTRY_UUID, 80, 'http://server/api/v2/measurement/?offset=1') + })) + .mockImplementationOnce(() => Promise.resolve({ data: page(ENTRY_UUID_2, 90, null) })); + + const result = await getWeights(testBodyWeightCategory); + + expect(axios.get).toHaveBeenCalledTimes(2); + expect(result.map(entry => entry.id)).toStrictEqual([ENTRY_UUID, ENTRY_UUID_2]); + }); + + test('GET weight entries passes the filterset on', async () => { + + (axios.get as Mock).mockImplementation(() => Promise.resolve({ + data: { count: 0, next: null, previous: null, results: [] } + })); + + await getWeights(testBodyWeightCategory, { "date__gte": '2021-01-01T00:00:00.000Z' }); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('date__gte=2021-01-01'), + expect.anything() + ); + }); + +}); diff --git a/src/components/Measurements/api/bodyWeight.ts b/src/components/Measurements/api/bodyWeight.ts new file mode 100644 index 000000000..5519f2daf --- /dev/null +++ b/src/components/Measurements/api/bodyWeight.ts @@ -0,0 +1,52 @@ +import { + API_MEASUREMENTS_CATEGORY_PATH, + getMeasurementEntries +} from "@/components/Measurements/api/measurements"; +import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { ResponseType } from "@/core/api/responseType"; +import { makeHeader, makeUrl } from "@/core/lib/url"; +import { ApiMeasurementCategoryType } from '@/types'; +import axios from 'axios'; + +/* + * Fetch the user's official body weight category + * + * The server guarantees that every user has exactly one + */ +export const getBodyWeightCategory = async (): Promise => { + const url = makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { + query: { metric_type: METRIC_TYPE_BODY_WEIGHT, is_official: 'true' } + }); + const { data } = await axios.get>(url, { + headers: makeHeader(), + }); + + // The server guarantees the category exists; still fail with a clear + // message instead of a TypeError should that ever break + if (data.results.length === 0) { + throw new Error('No official body weight category found'); + } + + return MeasurementCategory.fromJson(data.results[0]); +}; + +/* + * Fetch the body weight entries the filter selects, newest first + * + * Body weight is measurement data, so this reads through the measurement + * loader: it collects every page instead of stopping after the first, which is + * what a history fed by the health sync (~365 entries a year) needs. + */ +export const getWeights = async ( + category: MeasurementCategory, + filtersetQueryEntries: object = {}, +): Promise => getMeasurementEntries(category.id!, { + // Consumers read the newest entry off the front (BMI, dashboard) + ordering: '-date', + ...filtersetQueryEntries, +}); + +// Writing a body weight entry is writing a measurement entry: the create, +// update and delete calls of `api/measurements.ts` are used unchanged, there +// is nothing body-weight-specific about them. diff --git a/src/components/Measurements/api/measurements.test.ts b/src/components/Measurements/api/measurements.test.ts index 9b13395c4..42ae0057e 100644 --- a/src/components/Measurements/api/measurements.test.ts +++ b/src/components/Measurements/api/measurements.test.ts @@ -5,8 +5,13 @@ import { deleteMeasurementEntry, editMeasurementCategory, editMeasurementEntry, + getCategoryEntryFlags, + getGroupEntryPage, getMeasurementCategories, getMeasurementCategory, + getMeasurementEntries, + getMeasurementEntryPage, + getOldestMeasurementEntry, } from "@/components/Measurements/api/measurements"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; @@ -20,6 +25,7 @@ const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000001'; const CATEGORY_UUID_2 = 'cccccccc-cccc-cccc-cccc-000000000009'; const ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001'; const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000005'; +const ENTRY_UUID_3 = 'dddddddd-dddd-dddd-dddd-000000000007'; describe('measurement service tests', () => { const measurementEntryResponse = { @@ -69,35 +75,209 @@ describe('measurement service tests', () => { }); }); - test('Correctly filters categories and entries', async () => { + test('Correctly filters the categories', async () => { - await getMeasurementCategories({ - filtersetQueryEntries: { foo: "bar" }, - filtersetQueryCategories: { baz: "1234" } - }); + await getMeasurementCategories({ filtersetQueryCategories: { baz: "1234" } }); - expect(axios.get).toHaveBeenCalledTimes(2); - expect(axios.get).toHaveBeenNthCalledWith(1, + expect(axios.get).toHaveBeenCalledWith( expect.stringContaining('baz=1234'), expect.anything() ); + }); + + test('GET measurement categories reads no entries along with them', async () => { + + const result = await getMeasurementCategories(); + + expect(axios.get).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual([ + new MeasurementCategory(CATEGORY_UUID, "Weight", "kg") + ]); + }); + + test('the entry flags ask for a single entry per category', async () => { + + const result = await getCategoryEntryFlags(); + expect(axios.get).toHaveBeenNthCalledWith(2, - expect.stringContaining('foo=bar'), + expect.stringContaining('limit=1'), + expect.anything() + ); + expect(result).toStrictEqual([{ + category: new MeasurementCategory(CATEGORY_UUID, "Weight", "kg"), + hasEntries: true, + }]); + }); + + test('an entry limit reads the newest entries in a single request', async () => { + + const result = await getMeasurementEntries(CATEGORY_UUID, {}, 5); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('limit=5'), + expect.anything() + ); + expect(result).toHaveLength(1); + }); + + test('a page is read with the row after it, which is no part of the page', async () => { + + const entry = (id: string, value: number) => ({ + "id": id, + "category": CATEGORY_UUID, + "value": value, + "date": "2021-01-01T08:00:00+01:00", + "notes": "" + }); + (axios.get as Mock).mockImplementation(() => Promise.resolve({ + data: { + count: 42, + next: null, + previous: null, + results: [entry(ENTRY_UUID, 80), entry(ENTRY_UUID_2, 79), entry(ENTRY_UUID_3, 78)], + } + })); + + const page = await getMeasurementEntryPage(CATEGORY_UUID, 10, 2); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('limit=3'), + expect.anything() + ); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('offset=10'), + expect.anything() + ); + expect(page.entries.map(e => e.value)).toStrictEqual([80, 79]); + expect(page.next!.value).toBe(78); + // What the table pages through, not what it was handed + expect(page.count).toBe(42); + }); + + test('the last page of a history has no row after it', async () => { + + const page = await getMeasurementEntryPage(CATEGORY_UUID, 0, 10); + + expect(page.entries).toHaveLength(1); + expect(page.next).toBeNull(); + }); + + test('the oldest entry is read as a single row, in a total order', async () => { + + const result = await getOldestMeasurementEntry(CATEGORY_UUID); + + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining(`ordering=${encodeURIComponent('date,id')}`), + expect.anything() + ); + expect(axios.get).toHaveBeenCalledWith( + expect.stringContaining('limit=1'), expect.anything() ); + expect(result!.id).toBe(ENTRY_UUID); + }); + + test('a category without entries has no oldest one', async () => { + + (axios.get as Mock).mockImplementation(() => Promise.resolve({ + data: { count: 0, next: null, previous: null, results: [] } + })); + expect(await getOldestMeasurementEntry(CATEGORY_UUID)).toBeNull(); }); - test('GET measurement categories', async () => { + describe('getGroupEntryPage', () => { + + const groupResponse = (next: string | null) => ({ + data: { + count: 4, + next: next, + previous: null, + results: [{ + "id": ENTRY_UUID, + "category": CATEGORY_UUID, + "value": 120, + "date": "2021-01-01T08:00:00+01:00", + "notes": "" + }], + } + }); + + test('reads the components together, below the cursor', async () => { + (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null))); + + await getGroupEntryPage( + [CATEGORY_UUID, CATEGORY_UUID_2], + 22, + new Date("2021-02-03T07:00:00.000Z"), + ); + + const [url] = (axios.get as Mock).mock.calls[0]; + expect(url).toContain(`category__in=${CATEGORY_UUID}%2C${CATEGORY_UUID_2}`); + expect(url).toContain('limit=22'); + expect(url).toContain('date__lt=2021-02-03T07%3A00%3A00.000Z'); + }); + + test('the newest page is read without a cursor', async () => { + (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null))); + + await getGroupEntryPage([CATEGORY_UUID], 22); + + expect((axios.get as Mock).mock.calls[0][0]).not.toContain('date__lt'); + }); + + test('what is left over comes from the server, not from the page size', async () => { + // A page the server capped below the limit that was asked for: it + // still says there is more, and counting the rows would not + (axios.get as Mock).mockImplementation( + () => Promise.resolve(groupResponse('http://localhost/api/v2/measurement/?offset=999')) + ); + + const page = await getGroupEntryPage([CATEGORY_UUID], 1010); + + expect(page.truncated).toBe(true); + }); + + test('a page the server has nothing after is not truncated', async () => { + (axios.get as Mock).mockImplementation(() => Promise.resolve(groupResponse(null))); + + const page = await getGroupEntryPage([CATEGORY_UUID], 1); + + // Exactly as many rows as were asked for, and still the end + expect(page.entries).toHaveLength(1); + expect(page.truncated).toBe(false); + }); + }); + + test('GET measurement categories hides the official body weight category', async () => { + + (axios.get as Mock).mockImplementation((url: string) => { + if (url.includes("measurement-category")) { + return Promise.resolve({ + data: { + count: 2, + next: null, + previous: null, + results: [ + { id: CATEGORY_UUID, name: "Weight", unit: "kg" }, + { + id: CATEGORY_UUID_2, + name: "Body weight", + unit: "kg", + metric_type: "body_weight", + is_official: true + }, + ] + } + }); + } else if (url.includes(`measurement/?category=${CATEGORY_UUID}`)) { + return Promise.resolve({ data: measurementEntryResponse }); + } + }); const result = await getMeasurementCategories(); - expect(axios.get).toHaveBeenCalledTimes(2); - expect(result).toStrictEqual([ - new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [ - new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "") - ]) - ]); + expect(result.map(c => c.id)).toStrictEqual([CATEGORY_UUID]); }); test('GET measurement category', async () => { @@ -105,6 +285,8 @@ describe('measurement service tests', () => { (axios.get as Mock).mockImplementation((url: string) => { if (url.includes(`measurement-category/${CATEGORY_UUID}`)) { return Promise.resolve({ data: measurementDetailResponse }); + } else if (url.includes(`parent=${CATEGORY_UUID}`)) { + return Promise.resolve({ data: { count: 0, next: null, previous: null, results: [] } }); } else if (url.includes(`measurement/?category=${CATEGORY_UUID}`)) { return Promise.resolve({ data: measurementEntryResponse }); } @@ -114,13 +296,85 @@ describe('measurement service tests', () => { expect(axios.get).toHaveBeenCalledTimes(2); expect(result).toStrictEqual( - new MeasurementCategory(CATEGORY_UUID, "Weight", "kg", [ - new MeasurementEntry(ENTRY_UUID, CATEGORY_UUID, new Date("2021-01-01T08:00:00+01:00"), 80, "") - ]) + new MeasurementCategory(CATEGORY_UUID, "Weight", "kg") ); }); - test('addMeasurementCategory POSTs name + unit and returns the parsed category', async () => { + test('GET measurement category loads the children of a group', async () => { + + (axios.get as Mock).mockImplementation((url: string) => { + if (url.includes(`measurement-category/${CATEGORY_UUID}`)) { + return Promise.resolve({ + data: { id: CATEGORY_UUID, name: "Blood pressure", unit: "mmHg" } + }); + } else if (url.includes(`parent=${CATEGORY_UUID}`)) { + return Promise.resolve({ + data: { + count: 1, + next: null, + previous: null, + results: [{ + id: CATEGORY_UUID_2, + name: "Systolic", + unit: "mmHg", + parent: CATEGORY_UUID + }], + } + }); + } else if (url.includes(`measurement/?category=${CATEGORY_UUID_2}`)) { + return Promise.resolve({ + data: { + count: 1, + next: null, + previous: null, + results: [{ + id: ENTRY_UUID_2, + category: CATEGORY_UUID_2, + value: 120, + date: "2021-01-01T08:00:00+01:00", + notes: "" + }], + } + }); + } else if (url.includes(`measurement/?category=${CATEGORY_UUID}`)) { + return Promise.resolve({ data: { count: 0, next: null, previous: null, results: [] } }); + } + }); + + const result = await getMeasurementCategory(CATEGORY_UUID); + + expect(result.isGroup).toBe(true); + expect(result.children.map(c => c.id)).toStrictEqual([CATEGORY_UUID_2]); + }); + + test('GET measurement categories attaches children to their group', async () => { + + (axios.get as Mock).mockImplementation((url: string) => { + if (url.includes("measurement-category")) { + return Promise.resolve({ + data: { + count: 2, + next: null, + previous: null, + results: [ + { id: CATEGORY_UUID, name: "Blood pressure", unit: "mmHg" }, + { id: CATEGORY_UUID_2, name: "Systolic", unit: "mmHg", parent: CATEGORY_UUID }, + ] + } + }); + } + return Promise.resolve({ data: { count: 0, next: null, previous: null, results: [] } }); + }); + + const result = await getMeasurementCategories(); + + // only the group parent is top-level, the child hangs below it + expect(result.map(c => c.id)).toStrictEqual([CATEGORY_UUID]); + expect(result[0].isGroup).toBe(true); + expect(result[0].children.map(c => c.id)).toStrictEqual([CATEGORY_UUID_2]); + }); + + test('addMeasurementCategory POSTs the category and returns the parsed result', async () => { (axios.post as Mock).mockResolvedValue({ data: { id: CATEGORY_UUID_2, name: "Body fat", unit: "%" }, }); @@ -130,7 +384,16 @@ describe('measurement service tests', () => { expect(axios.post).toHaveBeenCalledTimes(1); const [url, body] = (axios.post as Mock).mock.calls[0]; expect(url).toMatch(/\/api\/v2\/measurement-category\/$/); - expect(body).toEqual({ name: "Body fat", unit: "%" }); + + expect(body).toEqual({ + name: "Body fat", + unit: "%", + metric_type: "custom", + chart_type: null, + chart_config: {}, + parent: null, + order: 0 + }); expect(result).toBeInstanceOf(MeasurementCategory); expect(result.id).toBe(CATEGORY_UUID_2); }); @@ -145,7 +408,17 @@ describe('measurement service tests', () => { expect(axios.patch).toHaveBeenCalledTimes(1); const [url, body] = (axios.patch as Mock).mock.calls[0]; expect(url).toMatch(new RegExp(`/api/v2/measurement-category/${CATEGORY_UUID_2}/$`)); - expect(body).toEqual({ id: CATEGORY_UUID_2, name: "Renamed", unit: "%" }); + + expect(body).toEqual({ + id: CATEGORY_UUID_2, + name: "Renamed", + unit: "%", + metric_type: "custom", + chart_type: null, + chart_config: {}, + parent: null, + order: 0 + }); expect(result.name).toBe("Renamed"); }); diff --git a/src/components/Measurements/api/measurements.ts b/src/components/Measurements/api/measurements.ts index f6346f0b2..80bc0538f 100644 --- a/src/components/Measurements/api/measurements.ts +++ b/src/components/Measurements/api/measurements.ts @@ -1,5 +1,6 @@ import axios from 'axios'; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket"; +import { MeasurementCategory, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements/models/Category"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { ApiMeasurementCategoryType } from '@/types'; import { API_MAX_PAGE_SIZE } from "@/core/lib/consts"; @@ -8,16 +9,261 @@ import { makeHeader, makeUrl } from "@/core/lib/url"; export const API_MEASUREMENTS_CATEGORY_PATH = 'measurement-category'; export const API_MEASUREMENTS_ENTRY_PATH = 'measurement'; +export const API_MEASUREMENTS_AGGREGATE_PATH = 'measurement/aggregate'; +export const API_MEASUREMENTS_VALUE_COUNTS_PATH = 'measurement/value-counts'; + +/** + * Calendar unit the server condenses into. 'auto' takes the finest one that + * keeps the series under the point limit, which is what a line chart wants; + * the others exist because the chart is built on a unit and coarser points + * would draw a grid of the wrong cells. + */ +export type BucketLevel = 'auto' | 'hour' | 'day' | 'week' | 'month'; + +/** The zone the buckets are cut in: a reading half an hour after midnight + * belongs to the day the user had it, not to the one UTC was on. */ +const browserTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone; + +/** + * The entries of one or more categories, condensed into chart points. + * + * [categoryIds] takes a group's components in one call, which is what lets the + * halves of a reading meet on the same bucket. + */ +export const getMeasurementBuckets = async ( + categoryIds: string[], + level: BucketLevel = 'auto', + filtersetQuery: object = {}, +): Promise => { + const url = makeUrl(API_MEASUREMENTS_AGGREGATE_PATH, { + query: { + category__in: categoryIds.join(','), + bucket: level, + tz: browserTimezone(), + ...filtersetQuery, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + + return data.map((item: unknown) => MeasurementBucket.fromJson(item)); +}; + +/** + * How often each value of a category occurred, which is what the histogram + * bins. [summedPerDay] counts daily totals instead, for the metrics whose + * samples mean nothing on their own. + */ +export const getMeasurementValueCounts = async ( + categoryId: string, + summedPerDay: boolean, + filtersetQuery: object = {}, +): Promise => { + const url = makeUrl(API_MEASUREMENTS_VALUE_COUNTS_PATH, { + query: { + category: categoryId, + summed_per_day: summedPerDay ? 'true' : 'false', + tz: browserTimezone(), + ...filtersetQuery, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + + return data.map((item: unknown) => MeasurementValueCount.fromJson(item)); +}; export type MeasurementQueryOptions = { filtersetQueryCategories?: object, - filtersetQueryEntries?: object, } +/** + * Every entry of a category, over all pages. + * + * [limit] stops at that many of the newest ones, in a single request: the + * server orders by date descending, so a caller that shows the latest handful + * has no reason to drain a history that runs into thousands of rows. + */ +export const getMeasurementEntries = async ( + categoryId: string, + filtersetQuery: object = {}, + limit?: number, +): Promise => { + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + category: categoryId, + limit: limit ?? API_MAX_PAGE_SIZE, + ...filtersetQuery, + } + }); + + if (limit !== undefined) { + const { data } = await axios.get(url, { headers: makeHeader() }); + + return data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData)); + } + + // Collect all pages of entries + const out: MeasurementEntry[] = []; + for await (const page of fetchPaginated(url, makeHeader())) { + for (const entryData of page) { + out.push(MeasurementEntry.fromJson(entryData)); + } + } + return out; +}; + +/** One page of a category's entries, newest first, as a table pages through them */ +export type MeasurementEntryPage = { + entries: MeasurementEntry[], + /** Entries the filter matches in total, i.e. how many pages there are */ + count: number, + /** + * The entry right after the page, none at the end of the history: the row + * before it is a difference to it, and it is the one row a page is + * otherwise missing. + */ + next: MeasurementEntry | null, +}; + +/** + * One page of a category's entries, rather than the history they are cut out + * of: a table shows ten rows at a time, and a synced category holds thousands. + */ +export const getMeasurementEntryPage = async ( + categoryId: string, + offset: number, + limit: number, + filtersetQuery: object = {}, +): Promise => { + // One row past the page, which is what its last row is measured against + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + category: categoryId, + limit: limit + 1, + offset: offset, + ...filtersetQuery, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + const entries = data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData)); + + return { + entries: entries.slice(0, limit), + count: data.count, + next: entries.length > limit ? entries[limit] : null, + }; +}; + +/** One page of the entries of a group's components, newest first */ +export type GroupEntryPage = { + entries: MeasurementEntry[], + /** Whether the server held entries back, see groupReadingPage */ + truncated: boolean, +}; + +/** + * The entries of a group's components down to {@link before}, the timestamp of + * the oldest reading already shown. A cursor rather than an offset: the limit + * cuts entries, which cannot be counted back into whole readings. + */ +export const getGroupEntryPage = async ( + categoryIds: string[], + limit: number, + before?: Date, + filtersetQuery: object = {}, +): Promise => { + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + category__in: categoryIds.join(','), + limit: limit, + ...(before !== undefined ? { date__lt: before.toISOString() } : {}), + ...filtersetQuery, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + + return { + entries: data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData)), + // What the server itself says is left over, rather than whether the + // page came back full: it caps `limit` at its own maximum, and a page + // cut by that cap looks unfilled + truncated: data.next !== null, + }; +}; + +/** + * The newest entries across the given categories, newest first, in a single + * request. + * + * What a card headline needs: for a leaf that is one entry, for a group one + * per component, since the components of a reading share its timestamp and + * the latest reading is therefore among the newest [categoryIds.length] + * entries of the components together. + */ +export const getLatestMeasurementEntries = async ( + categoryIds: string[], +): Promise => { + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + category__in: categoryIds.join(','), + limit: categoryIds.length, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + + return data.results.map((entryData: unknown) => MeasurementEntry.fromJson(entryData)); +}; + +/** + * The oldest entry the filter matches, or none at all. + * + * The total change of a row is measured against it, so a table that holds a + * page rather than the whole history has to ask for it: ordered by id as well, + * since entries can share a date and the column would otherwise pick either. + */ +export const getOldestMeasurementEntry = async ( + categoryId: string, + filtersetQuery: object = {}, +): Promise => { + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + category: categoryId, + limit: 1, + ordering: 'date,id', + ...filtersetQuery, + } + }); + const { data } = await axios.get(url, { headers: makeHeader() }); + + return data.results.length > 0 ? MeasurementEntry.fromJson(data.results[0]) : null; +}; + +/** + * The entries of every category at once, for the callers that show a window + * of time rather than one category: asking per category would be one request + * each, and would miss the components of a group, which are categories the + * category list does not return on their own. + */ +export const getAllMeasurementEntries = async (filtersetQuery: object = {}): Promise => { + const out: MeasurementEntry[] = []; + const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { + query: { + limit: API_MAX_PAGE_SIZE, + ...filtersetQuery, + } + }); + + for await (const page of fetchPaginated(url, makeHeader())) { + for (const entryData of page) { + out.push(MeasurementEntry.fromJson(entryData)); + } + } + return out; +}; + export const getMeasurementCategories = async (options?: MeasurementQueryOptions): Promise => { - const { filtersetQueryCategories = {}, filtersetQueryEntries = {} } = options || {}; + const { filtersetQueryCategories = {} } = options || {}; - const categories: MeasurementCategory[] = []; + let categories: MeasurementCategory[] = []; const categoryUrl = makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { query: { limit: API_MAX_PAGE_SIZE, @@ -31,37 +277,48 @@ export const getMeasurementCategories = async (options?: MeasurementQueryOptions } } - // Load entries for each category - const entryResponses = categories.map(async (category) => { - const out: MeasurementEntry[] = []; - const url = makeUrl(API_MEASUREMENTS_ENTRY_PATH, { - query: { - category: category.id, - limit: API_MAX_PAGE_SIZE, - ...filtersetQueryEntries, - } - }); - - // Collect all pages of entries - for await (const page of fetchPaginated(url, makeHeader())) { - for (const entries of page) { - out.push(MeasurementEntry.fromJson(entries)); - } - } - return out; - }); - const settingsResponses = await Promise.all(entryResponses); - - // Save entries to each category - let categoryId: string; - settingsResponses.forEach((entries) => { - if (entries.length > 0) { - categoryId = entries[0].category; - categories.findLast(c => c.id === categoryId)!.entries = entries; + // The official body weight category is managed via the body weight screens, + // don't surface it between the regular measurement categories + categories = categories.filter(c => !(c.isOfficial && c.metricType === METRIC_TYPE_BODY_WEIGHT)); + + // Multi-value groups: attach the children to their parent, only the + // top-level categories are returned + const byId = new Map(categories.map(c => [c.id, c])); + for (const category of categories) { + if (category.parentId !== null) { + byId.get(category.parentId)?.children.push(category); } - }); + } + // For children, order is the position within the group (systolic before + // diastolic); the chart colours the components by that position + for (const category of categories) { + category.children.sort((a, b) => a.order - b.order); + } - return categories; + return categories.filter(c => c.parentId === null); +}; + +/** A category, and whether it holds any entries at all */ +export type CategoryEntryFlag = { + category: MeasurementCategory, + hasEntries: boolean, +} + +/** + * The categories, each with whether it holds entries: what the group picker + * needs, since only an entry-free category may become a group parent. + * + * One entry per category is read to answer it, rather than a history that can + * run into thousands of rows (the sleep stages alone write five entries a + * night). The entries themselves are of no interest, so they don't leave here. + */ +export const getCategoryEntryFlags = async (): Promise => { + const categories = await getMeasurementCategories(); + + return Promise.all(categories.map(async (category) => ({ + category: category, + hasEntries: (await getMeasurementEntries(category.id!, {}, 1)).length > 0, + }))); }; export const getMeasurementCategory = async (id: string): Promise => { @@ -71,17 +328,17 @@ export const getMeasurementCategory = async (id: string): Promise a.order - b.order); return category; }; @@ -106,6 +363,14 @@ export const editMeasurementCategory = async (category: MeasurementCategory): Pr return MeasurementCategory.fromJson(response.data); }; +export const updateMeasurementCategoryOrder = async (id: string, order: number): Promise => { + await axios.patch( + makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }), + { order: order }, + { headers: makeHeader() } + ); +}; + export const deleteMeasurementCategory = async (id: string): Promise => { await axios.delete(makeUrl(API_MEASUREMENTS_CATEGORY_PATH, { id: id }), { headers: makeHeader() }); }; diff --git a/src/components/Measurements/charts/colors.ts b/src/components/Measurements/charts/colors.ts new file mode 100644 index 000000000..c430a1a0b --- /dev/null +++ b/src/components/Measurements/charts/colors.ts @@ -0,0 +1,44 @@ +import { Theme } from "@mui/material/styles"; +import { ChartSeriesRole } from "@/components/Measurements/charts/series"; +import { generateChartColors } from "@/core/lib/colors"; + +/** + * Colours the components of a group are drawn in, by position. Shared with the + * lists that name the components, so a row and its line match. + */ +export const componentPalette = (componentCount: number): string[] => + [...generateChartColors(componentCount)]; + +export const componentColor = (palette: string[], index: number): string => + palette[index % palette.length]; + +/** + * Colour of a change bar, by which way it points. Theme colours rather than + * green and red: which direction is the good one depends on the goal (losing + * weight, building muscle), and the chart should not assert one. The bar + * already points the way it points, so the colour only has to tell them apart. + */ +export const deltaColor = (theme: Theme, delta: number): string => + delta < 0 ? theme.palette.info.main : theme.palette.primary.main; + +/** + * Colour of a series. Components are coloured by their position, the other + * roles have a fixed colour each. + */ +export const seriesColor = ( + theme: Theme, + role: ChartSeriesRole, + componentIndex: number, + palette: string[], +): string => { + switch (role) { + case 'raw': + return theme.palette.primary.main; + case 'average': + return theme.palette.info.main; + case 'trend': + return theme.palette.secondary.main; + case 'component': + return componentColor(palette, componentIndex); + } +}; diff --git a/src/components/Measurements/charts/data.test.ts b/src/components/Measurements/charts/data.test.ts new file mode 100644 index 000000000..789c5d08a --- /dev/null +++ b/src/components/Measurements/charts/data.test.ts @@ -0,0 +1,776 @@ +import { MeasurementCategory, MetricType } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { + aggregatePerDay, + averagePerDay, + buildHeatmapGrid, + buildHistogram, + chartPointsFor, + downsample, + fillMissingDays, + heatmapDayAt, + HEATMAP_MAX_WEEKS, + groupChart, + groupComponentSeries, + groupComponentPoints, + groupRangeEntries, + groupReadingPage, + groupReadings, + groupStackedEntries, + movingAverage, + niceBinWidth, + overallChange, + smoothedTrendline, + stackableComponents, + weeklyDeltas +} from "@/components/Measurements/charts/data"; +import { ChartPoint } from "@/components/Measurements/charts/series"; +import { bucketsFor, CategorySeed } from "@/tests/chartQueries"; +import { describe, expect, test } from 'vitest'; + +const entry = (date: Date, value: number, extraData: Record = {}) => + new MeasurementEntry('d-1', 'c-1', date, value, '', 'user', extraData); + +const point = (date: Date, value: number): ChartPoint => ({ date: date.getTime(), value: value }); + +const day = (dayOfMonth: number, hour: number = 0) => new Date(2023, 1, dayOfMonth, hour); + +describe('chartPointsFor', () => { + test('returns the points chronologically', () => { + const points = chartPointsFor([ + entry(day(3), 30), + entry(day(1), 10), + entry(day(2), 20), + ], 'cm', 'cm'); + + expect(points.map(p => p.value)).toEqual([10, 20, 30]); + }); + + test('converts the value into the target unit', () => { + const points = chartPointsFor([entry(day(1), 176.37, { unit: 'lb' })], 'kg', 'kg'); + + expect(points[0].value).toBe(80); + }); + + test('lifts the bounds of a daily aggregate, converted along with the value', () => { + const points = chartPointsFor( + [entry(day(1), 176.37, { unit: 'lb', min: 154.32, max: 198.42 })], + 'kg', + 'kg', + ); + + expect(points[0].min).toBe(70); + expect(points[0].max).toBe(90); + }); + + test('leaves a plain sample without bounds', () => { + const points = chartPointsFor([entry(day(1), 80)], 'kg', 'kg'); + + expect(points[0]).toStrictEqual({ date: day(1).getTime(), value: 80 }); + }); + + test('ignores a half-written range', () => { + const points = chartPointsFor([entry(day(1), 80, { min: 70 })], 'kg', 'kg'); + + expect(points[0].min).toBeUndefined(); + expect(points[0].max).toBeUndefined(); + }); +}); + +describe('movingAverage', () => { + test('returns an empty series unchanged', () => { + expect(movingAverage([])).toEqual([]); + }); + + test('averages over the 7 days preceding each point', () => { + const result = movingAverage([ + point(day(1), 10), + point(day(2), 20), + point(day(3), 30), + ]); + + expect(result.map(p => p.value)).toEqual([10, 15, 20]); + }); + + test('drops points that fell out of the window', () => { + const result = movingAverage([ + point(day(1), 10), + point(day(20), 30), + point(day(21), 50), + ]); + + // the first point is more than 7 days away and no longer counts + expect(result.map(p => p.value)).toEqual([10, 30, 40]); + }); + + test('sorts the input before averaging', () => { + const result = movingAverage([point(day(2), 20), point(day(1), 10)]); + + expect(result.map(p => p.value)).toEqual([10, 15]); + }); + + test('a wider window reaches further back', () => { + const points = [point(day(1), 10), point(day(12), 20)]; + + // the first point is outside 7 days but inside 14 + expect(movingAverage(points, 7).map(p => p.value)).toEqual([10, 20]); + expect(movingAverage(points, 14).map(p => p.value)).toEqual([10, 15]); + }); + + test('carries no range, an average has no spread of its own', () => { + const result = movingAverage([{ date: day(1).getTime(), value: 10, min: 5, max: 15 }]); + + expect(result[0]).toStrictEqual({ date: day(1).getTime(), value: 10 }); + }); +}); + +describe('smoothedTrendline', () => { + test('returns an empty series unchanged', () => { + expect(smoothedTrendline([])).toEqual([]); + }); + + test('is seeded with the first value', () => { + const result = smoothedTrendline([point(day(1), 10), point(day(2), 20)]); + + expect(result[0].value).toBe(10); + expect(result[1].value).toBeGreaterThan(10); + expect(result[1].value).toBeLessThan(20); + }); +}); + +describe('downsample', () => { + test('returns a series that already fits unchanged', () => { + const points = [point(day(1), 10), point(day(2), 20)]; + + expect(downsample(points, 200)).toBe(points); + }); + + test('condenses into the finest calendar unit that fits', () => { + // three samples in each of two hours + const points = [ + point(day(1, 8), 10), point(day(1, 8), 20), point(day(1, 8), 30), + point(day(1, 9), 40), point(day(1, 9), 50), point(day(1, 9), 60), + ]; + + const result = downsample(points, 4); + + expect(result.map(p => p.value)).toEqual([20, 50]); + expect(result.map(p => p.date)).toEqual([day(1, 8).getTime(), day(1, 9).getTime()]); + }); + + test('carries the extremes of the bucket as a range', () => { + const points = [ + point(day(1, 8), 10), point(day(1, 8), 20), point(day(1, 8), 30), + point(day(1, 9), 40), point(day(1, 9), 50), point(day(1, 9), 60), + ]; + + const result = downsample(points, 4); + + expect(result[0].min).toBe(10); + expect(result[0].max).toBe(30); + }); + + test('an already condensed point contributes its bounds, not its value', () => { + const points = [ + { date: day(1, 8).getTime(), value: 20, min: 5, max: 95 }, + { date: day(1, 8).getTime(), value: 30, min: 25, max: 35 }, + { date: day(1, 9).getTime(), value: 40 }, + { date: day(1, 9).getTime(), value: 50 }, + ]; + + const result = downsample(points, 3); + + expect(result[0].min).toBe(5); + expect(result[0].max).toBe(95); + }); + + test('falls back to coarser units until the series fits', () => { + // one sample per hour over four days is too many for a per-hour bucket + const points = []; + for (let d = 1; d <= 4; d++) { + for (let hour = 0; hour < 24; hour++) { + points.push(point(day(d, hour), hour)); + } + } + + const result = downsample(points, 10); + + expect(result).toHaveLength(4); + expect(result.map(p => p.date)).toEqual([1, 2, 3, 4].map(d => day(d).getTime())); + }); + + test('returns the coarsest bucketing even when it still exceeds the limit', () => { + const points = []; + for (let month = 0; month < 12; month++) { + points.push({ date: new Date(2023, month, 1).getTime(), value: month }); + } + + expect(downsample(points, 3)).toHaveLength(12); + }); +}); + +describe('aggregatePerDay', () => { + test('returns an empty array for no points', () => { + expect(aggregatePerDay([])).toEqual([]); + }); + + test('sums all samples of the same calendar day', () => { + const result = aggregatePerDay([ + point(day(1, 8), 4000), + point(day(1, 18), 6000), + point(day(2, 9), 3000), + ]); + + expect(result).toEqual([ + { date: day(1).getTime(), value: 10000 }, + { date: day(2).getTime(), value: 3000 }, + ]); + }); + + test('sorts the buckets chronologically', () => { + const result = aggregatePerDay([point(day(3), 30), point(day(1), 10), point(day(2), 20)]); + + expect(result.map(r => r.value)).toEqual([10, 20, 30]); + }); +}); + +describe('averagePerDay', () => { + test('returns an empty array for no points', () => { + expect(averagePerDay([])).toEqual([]); + }); + + test('averages all samples of the same calendar day', () => { + const result = averagePerDay([point(day(1, 8), 80), point(day(1, 18), 82)]); + + expect(result).toEqual([{ date: day(1).getTime(), value: 81 }]); + }); + + test('sorts the buckets chronologically', () => { + const result = averagePerDay([point(day(3), 30), point(day(1), 10), point(day(2), 20)]); + + expect(result.map(r => r.value)).toEqual([10, 20, 30]); + }); +}); + +describe('weeklyDeltas', () => { + // 5 January 2026 is a Monday + const week = (index: number, dayOfWeek: number = 0) => new Date(2026, 0, 5 + 7 * index + dayOfWeek); + + test('returns an empty array for no points', () => { + expect(weeklyDeltas([])).toEqual([]); + }); + + test('has no bar for a single week, which has nothing to compare against', () => { + expect(weeklyDeltas([point(week(0), 80), point(week(0, 2), 81)])).toEqual([]); + }); + + test('subtracts the previous week, dated on the week it belongs to', () => { + const result = weeklyDeltas([point(week(0), 80), point(week(1), 79), point(week(2), 79.5)]); + + expect(result.map(r => r.date)).toEqual([week(1).getTime(), week(2).getTime()]); + expect(result.map(r => r.value)).toEqual([-1, 0.5]); + }); + + test('compares the weeks by their average, not by single readings', () => { + // the low reading is an outlier within its week and must not decide the bar + const result = weeklyDeltas([ + point(week(0), 80), point(week(0, 3), 82), + point(week(1), 75), point(week(1, 3), 87), + ]); + + expect(result.map(r => r.value)).toEqual([0]); + }); + + test('sums the weeks of a metric that is read as a total', () => { + const result = weeklyDeltas([ + point(week(0), 3000), point(week(0, 1), 4000), + point(week(1), 9000), + ], true); + + expect(result.map(r => r.value)).toEqual([2000]); + }); + + test('takes the week after a gap against the last week that has readings', () => { + // one bar on the week that was measured, holding the whole change, so + // the bars still add up to the change across the range + const result = weeklyDeltas([point(week(0), 80), point(week(3), 77)]); + + expect(result).toEqual([{ date: week(3).getTime(), value: -3 }]); + }); + + test('sorts unordered input by week first', () => { + const result = weeklyDeltas([point(week(1), 79), point(week(0), 80)]); + + expect(result.map(r => r.value)).toEqual([-1]); + }); + + test('leaves the running week out of a summed metric', () => { + // its total is still growing and would read as a drop until Sunday + const result = weeklyDeltas( + [point(week(0), 7000), point(week(1), 3000)], + true, + week(1, 2), + ); + + expect(result).toEqual([]); + }); + + test('keeps the running week of an averaged metric', () => { + const result = weeklyDeltas( + [point(week(0), 80), point(week(1), 79)], + false, + week(1, 2), + ); + + expect(result.map(r => r.value)).toEqual([-1]); + }); +}); + +describe('niceBinWidth', () => { + test('rounds the span split into ~20 bins up to 1, 2 or 5 times a power of ten', () => { + // span 14.6 / 20 = 0.73 -> 1, not an edge like 59.3-61.3 + expect(niceBinWidth(59.3, 73.9)).toBe(1); + // span 30000 / 20 = 1500 -> 2000 + expect(niceBinWidth(0, 30000)).toBe(2000); + // span 9 / 20 = 0.45 -> 0.5 + expect(niceBinWidth(1, 10)).toBe(0.5); + }); + + test('a span of nothing still has a width', () => { + expect(niceBinWidth(80, 80)).toBe(1); + }); +}); + +/** A group and the entries its components hold, which are read separately */ +type SeededGroup = { group: MeasurementCategory, seeds: CategorySeed[] }; + +/** The points the aggregated read returns for a group */ +const groupPoints = (seeded: SeededGroup) => + groupComponentPoints(seeded.group, seeded.seeds.flatMap(seed => bucketsFor(seed))); + +describe('buildHistogram', () => { + const counted = (...values: number[]) => values.map(value => ({ value: value, count: 1 })); + + test('aligns the bin edges to round multiples of the width', () => { + const result = buildHistogram(counted(79.7, 82.3), 0, 0.5); + + expect(result.firstEdge).toBe(79.5); + expect(result.firstEdge + result.counts.length * result.binWidth).toBe(82.5); + }); + + test('keeps empty bins between the occupied ones, a gap is information', () => { + const result = buildHistogram(counted(60, 61, 65), 0, 2); + + expect(result.counts).toEqual([2, 0, 1]); + }); + + test('counts a value as often as it occurred', () => { + // What the aggregated read hands over: a year of readings arrives as + // the distinct values it covers, with their counts + const result = buildHistogram([{ value: 60, count: 30 }, { value: 61, count: 5 }], 61, 1); + + expect(result.counts).toEqual([30, 5]); + }); + + test('takes the median of the values, odd and even', () => { + expect(buildHistogram(counted(60, 62, 70), 70, 2).median).toBe(62); + expect(buildHistogram(counted(60, 63, 65, 70), 70, 2).median).toBe(64); + }); + + test('the median weighs the counts, not the distinct values', () => { + // Thirty readings at 60 and one at 90: the middle reading is a 60, + // which an unweighted median over the two values would miss + const result = buildHistogram([{ value: 60, count: 30 }, { value: 90, count: 1 }], 90, 10); + + expect(result.median).toBe(60); + }); + + test('derives a width from the span when the type brings none', () => { + expect(buildHistogram(counted(59.3, 73.9), 0).binWidth).toBe(1); + }); + + test('doubles the width until an outlier no longer stretches it into hundreds of bins', () => { + // 20 to 350 at 0.5 kg would be 661 bins; doubling keeps the edges round + const result = buildHistogram(counted(20, 80, 350), 350, 0.5); + + expect(result.binWidth).toBe(4); + expect(result.counts.length).toBeLessThanOrEqual(100); + expect(result.counts.reduce((sum, count) => sum + count, 0)).toBe(3); + }); +}); + +describe('buildHeatmapGrid', () => { + // 2 March 2026 is a Monday, 18 March a Wednesday + const monday = new Date(2026, 2, 2); + const wednesday = new Date(2026, 2, 18); + const at = (date: Date, value: number): ChartPoint => ({ date: date.getTime(), value: value }); + + test('starts on the Monday of the oldest week and ends with today', () => { + const grid = buildHeatmapGrid([at(monday, 10), at(wednesday, 20)], HEATMAP_MAX_WEEKS, wednesday); + + expect(grid.start).toEqual(monday.getTime()); + expect(grid.weeks).toEqual(3); + expect(heatmapDayAt(grid, 2, 2)).toEqual(wednesday.getTime()); + }); + + test('leaves days without a measurement empty rather than zero', () => { + const grid = buildHeatmapGrid([at(monday, 10)], HEATMAP_MAX_WEEKS, monday); + + expect(grid.values.get(heatmapDayAt(grid, 0, 0))).toEqual(10); + expect(grid.values.get(heatmapDayAt(grid, 0, 1))).toBeUndefined(); + }); + + test('runs up to today, so a stretch without measurements stays visible', () => { + const grid = buildHeatmapGrid([at(monday, 10)], HEATMAP_MAX_WEEKS, wednesday); + + expect(grid.weeks).toEqual(3); + expect(grid.values.get(heatmapDayAt(grid, 2, 2))).toBeUndefined(); + }); + + test('caps a long history at a year of week columns', () => { + const grid = buildHeatmapGrid( + [at(new Date(2020, 0, 1), 10), at(wednesday, 20)], + HEATMAP_MAX_WEEKS, + wednesday, + ); + + expect(grid.weeks).toEqual(HEATMAP_MAX_WEEKS); + expect(heatmapDayAt(grid, grid.weeks - 1, 2)).toEqual(wednesday.getTime()); + }); + + test('anchors on the last measurement when the history ended long ago', () => { + // Anchoring on today would put the whole history outside the grid and + // draw an empty one + const grid = buildHeatmapGrid( + [at(monday, 10), at(wednesday, 20)], + HEATMAP_MAX_WEEKS, + new Date(2028, 0, 1), + ); + + expect(heatmapDayAt(grid, grid.weeks - 1, 2)).toEqual(wednesday.getTime()); + expect(grid.values.get(wednesday.getTime())).toEqual(20); + }); + + test('takes the top of the colour scale only from the days it shows', () => { + // A spike outside the window would scale the colours of every visible + // cell without being visible itself, washing out the whole grid + const grid = buildHeatmapGrid( + [at(new Date(2024, 0, 3), 45000), at(wednesday, 8000)], + HEATMAP_MAX_WEEKS, + wednesday, + ); + + expect(grid.maxValue).toEqual(8000); + expect(grid.values.has(new Date(2024, 0, 3).getTime())).toBe(false); + }); + + test('takes the top of the colour scale from the largest value', () => { + const grid = buildHeatmapGrid( + [at(monday, 10), at(wednesday, 8000)], + HEATMAP_MAX_WEEKS, + wednesday, + ); + + expect(grid.maxValue).toEqual(8000); + }); + + test('is a full grid of the last year when there is nothing to show', () => { + const grid = buildHeatmapGrid([]); + + expect(grid.weeks).toEqual(HEATMAP_MAX_WEEKS); + expect(grid.maxValue).toEqual(0); + expect(grid.values.size).toEqual(0); + }); +}); + +describe('fillMissingDays', () => { + test('returns an empty array for no data', () => { + expect(fillMissingDays([])).toEqual([]); + }); + + test('fills gaps with zero-value days', () => { + const result = fillMissingDays([point(day(1), 10), point(day(4), 40)]); + + expect(result).toEqual([ + { date: day(1).getTime(), value: 10 }, + { date: day(2).getTime(), value: 0 }, + { date: day(3).getTime(), value: 0 }, + { date: day(4).getTime(), value: 40 }, + ]); + }); + + test('keeps a contiguous series unchanged', () => { + const data = [point(day(1), 10), point(day(2), 20)]; + + expect(fillMissingDays(data)).toEqual(data); + }); +}); + +describe('groups', () => { + + const bloodPressure = (readings: [Date, number, number | null][]): SeededGroup => { + const group = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure'); + const systolic = new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'custom', false, 'g-1', 0); + const diastolic = new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'custom', false, 'g-1', 1); + const systolicEntries: MeasurementEntry[] = []; + const diastolicEntries: MeasurementEntry[] = []; + + for (const [date, high, low] of readings) { + systolicEntries.push(new MeasurementEntry(null, 'c-sys', date, high, '')); + if (low !== null) { + diastolicEntries.push(new MeasurementEntry(null, 'c-dia', date, low, '')); + } + } + group.children = [systolic, diastolic]; + + return { + group: group, + seeds: [ + { category: systolic, entries: systolicEntries }, + { category: diastolic, entries: diastolicEntries }, + ], + }; + }; + + test('pairs the components of a reading into one range', () => { + const result = groupRangeEntries(groupPoints(bloodPressure([[day(1), 120, 80]]))); + + expect(result).toStrictEqual([{ date: day(1).getTime(), value: 100, min: 80, max: 120 }]); + }); + + test('skips a half reading, it has no range', () => { + const result = groupRangeEntries(groupPoints(bloodPressure([[day(1), 120, 80], [day(2), 125, null]]))); + + expect(result.map(r => r.date)).toEqual([day(1).getTime()]); + }); + + test('sorts the readings chronologically', () => { + const result = groupRangeEntries(groupPoints(bloodPressure([[day(3), 130, 90], [day(1), 120, 80]]))); + + expect(result.map(r => r.max)).toEqual([120, 130]); + }); + + test('reads the low and high end from the values, not from the component order', () => { + const seeded = bloodPressure([[day(1), 80, 120]]); + + expect(groupRangeEntries(groupPoints(seeded))[0]).toMatchObject({ min: 80, max: 120 }); + }); + + test('builds one named component series per child', () => { + const series = groupComponentSeries(bloodPressure([[day(1), 120, 80]]).group, groupPoints(bloodPressure([[day(1), 120, 80]]))); + + expect(series.map(s => s.label)).toEqual(['Systolic', 'Diastolic']); + expect(series.map(s => s.role)).toEqual(['component', 'component']); + expect(series[0].points.map(p => p.value)).toEqual([120]); + }); + + test('two components are charted as ranges', () => { + const chart = groupChart(bloodPressure([[day(1), 120, 80]]).group, groupPoints(bloodPressure([[day(1), 120, 80]]))); + + expect(chart.kind).toBe('range'); + }); + + test('a group whose readings are all unpaired falls back to component lines', () => { + const chart = groupChart( + bloodPressure([[day(1), 120, null], [day(2), 125, null]]).group, + groupPoints(bloodPressure([[day(1), 120, null], [day(2), 125, null]])), + ); + + expect(chart.kind).toBe('components'); + }); + + test('three components cannot be a range', () => { + const seeded = bloodPressure([[day(1), 120, 80]]); + const third = new MeasurementCategory('c-map', 'Mean', 'mmHg', 'custom', false, 'g-1', 2); + const group = seeded.group; + group.children = [...group.children, third]; + seeded.seeds = [ + ...seeded.seeds, + { category: third, entries: [new MeasurementEntry(null, 'c-map', day(1), 93, '')] }, + ]; + + const chart = groupChart(group, groupPoints(seeded)); + + expect(chart.kind).toBe('components'); + }); +}); + +describe('groupReadings', () => { + + const group = () => { + const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure'); + bloodPressure.children = [ + new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0), + new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1), + ]; + return bloodPressure; + }; + + /** The entries of one reading, newest first as the API returns them */ + const reading = (date: Date, high: number, low: number | null) => [ + new MeasurementEntry('e-sys', 'c-sys', date, high, ''), + ...(low === null ? [] : [new MeasurementEntry('e-dia', 'c-dia', date, low, '')]), + ]; + + test('pairs the components sharing a timestamp into one reading', () => { + const readings = groupReadings(group(), reading(day(1, 8), 120, 80)); + + expect(readings).toHaveLength(1); + expect(readings[0].date).toEqual(day(1, 8)); + expect([...readings[0].values]).toEqual([['c-sys', 120], ['c-dia', 80]]); + }); + + test('keeps a reading only some components reported', () => { + const readings = groupReadings(group(), reading(day(1, 8), 120, null)); + + expect([...readings[0].values]).toEqual([['c-sys', 120]]); + }); + + test('returns the readings newest first', () => { + const readings = groupReadings(group(), [ + ...reading(day(1, 8), 120, 80), + ...reading(day(3, 8), 130, 90), + ]); + + expect(readings.map(r => r.date)).toEqual([day(3, 8), day(1, 8)]); + }); + + test('ignores entries of a category that is not a component', () => { + const stray = new MeasurementEntry('e-x', 'c-other', day(1, 8), 42, ''); + + expect(groupReadings(group(), [stray])).toEqual([]); + }); + + test('reads the values through the unit helper', () => { + const weight = new MeasurementCategory('g-w', 'Weights', 'kg', 'custom'); + weight.children = [new MeasurementCategory('c-kg', 'Left', 'kg', 'custom', false, 'g-w', 0)]; + const entries = [new MeasurementEntry('e-1', 'c-kg', day(1), 220, '', 'user', { unit: 'lb' })]; + + expect([...groupReadings(weight, entries)[0].values]).toEqual([['c-kg', 99.79]]); + }); +}); + +describe('groupReadingPage', () => { + + const group = () => { + const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure'); + bloodPressure.children = [ + new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0), + new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1), + ]; + return bloodPressure; + }; + + /** [count] complete readings, newest first */ + const entriesFor = (count: number) => { + const entries: MeasurementEntry[] = []; + for (let index = 0; index < count; index++) { + entries.push(new MeasurementEntry('e-sys', 'c-sys', day(count - index), 120, '')); + entries.push(new MeasurementEntry('e-dia', 'c-dia', day(count - index), 80, '')); + } + return entries; + }; + + test('hands over what it was given when the page was not truncated', () => { + const page = groupReadingPage(group(), entriesFor(3), 10, false); + + expect(page.readings).toHaveLength(3); + expect(page.hasMore).toBe(false); + }); + + test('drops the oldest reading of a truncated page, it may be missing components', () => { + const page = groupReadingPage(group(), entriesFor(3), 10, true); + + expect(page.readings.map(r => r.date)).toEqual([day(3), day(2)]); + expect(page.hasMore).toBe(true); + }); + + test('cuts at the page size, and says there is more', () => { + const page = groupReadingPage(group(), entriesFor(5), 2, false); + + expect(page.readings.map(r => r.date)).toEqual([day(5), day(4)]); + expect(page.hasMore).toBe(true); + }); + + test('keeps a page holding a single timestamp, there is nothing to drop it for', () => { + const page = groupReadingPage(group(), entriesFor(1), 10, true); + + expect(page.readings).toHaveLength(1); + expect(page.hasMore).toBe(true); + }); +}); + +describe('sleep group', () => { + /** A sleep group: the total plus two stages, all on the same night */ + const sleep = (withStages: boolean = true): SeededGroup => { + const group = new MeasurementCategory('g-s', 'Sleep', 'min', 'sleep'); + const child = ( + id: string, + name: string, + type: MetricType, + order: number, + value: number | null, + ): CategorySeed => ({ + category: new MeasurementCategory(id, name, 'min', type, false, 'g-s', order), + entries: value === null + ? [] + : [new MeasurementEntry(`e-${id}`, id, day(2), value, '')], + }); + + const seeds = [ + child('total', 'Total sleep', 'sleep_total', 0, 480), + child('deep', 'Deep sleep', 'sleep_deep', 1, withStages ? 90 : null), + child('rem', 'REM sleep', 'sleep_rem', 2, withStages ? 60 : null), + ]; + group.children = seeds.map(seed => seed.category); + + return { group: group, seeds: seeds }; + }; + + test('the roll-up component is left out of the stack', () => { + // Total sleep covers the stages, so stacking it would count the night + // twice + expect(stackableComponents(sleep().group).map(c => c.metricType)) + .toEqual(['sleep_deep', 'sleep_rem']); + }); + + test('stacked entries carry one value per component and day', () => { + const seeded = sleep(); + const stacked = groupStackedEntries(stackableComponents(seeded.group), groupPoints(seeded)); + + expect(stacked).toStrictEqual([{ date: day(2).getTime(), values: [90, 60] }]); + }); + + test('several entries of one day add up within their component', () => { + // A nap next to the night: the bar shows the day, not the segment + const seeded = sleep(); + const deep = seeded.seeds[1]; + deep.entries = [...deep.entries!, new MeasurementEntry('e-nap', 'deep', day(2, 14), 20, '')]; + + expect(groupStackedEntries(stackableComponents(seeded.group), groupPoints(seeded))[0].values) + .toEqual([110, 60]); + }); + + test('a summed group stacks its components', () => { + const chart = groupChart(sleep().group, groupPoints(sleep())); + + expect(chart.kind).toBe('stacked'); + expect(chart.kind === 'stacked' && chart.labels).toEqual(['Deep sleep', 'REM sleep']); + }); + + test('without stage data the group falls back to component lines', () => { + // Only the total reported, so there is nothing to stack. Falling + // through keeps the chart from going blank while data exists + expect(groupChart(sleep(false).group, groupPoints(sleep(false))).kind).toBe('components'); + }); +}); + +describe('overallChange', () => { + test('is null for an empty series', () => { + expect(overallChange([])).toBeNull(); + }); + + test('is the difference between the first and the last point', () => { + expect(overallChange([point(day(1), 80), point(day(2), 78)])).toBe(-2); + }); +}); diff --git a/src/components/Measurements/charts/data.ts b/src/components/Measurements/charts/data.ts new file mode 100644 index 000000000..be802bdf0 --- /dev/null +++ b/src/components/Measurements/charts/data.ts @@ -0,0 +1,918 @@ +import { BucketLevel } from "@/components/Measurements/api/measurements"; +import { + averageWindowOf, + ChartConfig, + ChartType, + isGroupTotalMetricType, + isSummedPerDay, + MeasurementCategory, + MetricType, + resolveChartType, + trendPeriodOf +} from "@/components/Measurements/models/Category"; +import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { convertStoredValue } from "@/core/lib/weightUnit"; +import { ChartRange, entryFilterFor, pointsSince } from "@/components/Measurements/charts/range"; +import { ChartPoint, ChartSeries, PlanPeriod } from "@/components/Measurements/charts/series"; +import { calculateEMA } from "@/core/lib/ema"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** Length of the moving average window for a category that configured none */ +const DEFAULT_AVERAGE_WINDOW_DAYS = 7; + +/** Point count above which a series is condensed, see downsample */ +export const MAX_CHART_POINTS = 200; + +/** + * Turns stored entries into chart points, converting the value to the target + * unit. Entries stored as a daily aggregate keep the range they summarise in + * extra_data (heart rate min/max); it is lifted onto the point so the chart + * can draw a band. Those bounds share the value's unit and are converted + * along with it. + * + * The result is chronological; entries arrive from the API newest first. + */ +export const chartPointsFor = ( + entries: MeasurementEntry[], + targetUnit: string, + categoryUnit: string, +): ChartPoint[] => [...entries] + .sort((a, b) => a.date.getTime() - b.date.getTime()) + .map(entry => { + const bound = (key: string) => { + const stored = entry.extraData[key]; + return typeof stored === 'number' + ? entry.boundIn(stored, targetUnit, categoryUnit) + : undefined; + }; + + const min = bound('min'); + const max = bound('max'); + + return { + date: entry.date.getTime(), + value: entry.valueIn(targetUnit, categoryUnit), + ...(min !== undefined && max !== undefined ? { min: min, max: max } : {}), + }; + }); + +/** + * Turns the server's condensed buckets into chart points, converting to the + * target unit. + * + * The counterpart of chartPointsFor for the aggregated read path. A bucket + * arrives once per unit its entries were written in, so the slices are + * converted before they are merged: a mean over kg and lb values is a number + * in neither. Their spread becomes the point's range, left off where it says + * nothing (a single reading, a summed total, which has no spread). + */ +export const chartPointsForBuckets = ( + buckets: MeasurementBucket[], + targetUnit: string, + categoryUnit: string, + summed: boolean = false, +): ChartPoint[] => { + const convert = (value: number, from: string | null) => + convertStoredValue(value, from, categoryUnit, targetUnit); + + const byStart = new Map(); + for (const bucket of buckets) { + const start = bucket.start.getTime(); + byStart.set(start, [...(byStart.get(start) ?? []), bucket]); + } + + return [...byStart.entries()] + .sort(([a], [b]) => a - b) + .map(([start, slices]) => { + const total = slices.reduce((sum, s) => sum + convert(s.sum, s.unit), 0); + if (summed) { + return { date: start, value: total }; + } + + const value = total / slices.reduce((count, s) => count + s.count, 0); + const min = Math.min(...slices.map(s => convert(s.min, s.unit))); + const max = Math.max(...slices.map(s => convert(s.max, s.unit))); + + return min < value || max > value + ? { date: start, value: value, min: min, max: max } + : { date: start, value: value }; + }); +}; + +/** + * What the chart of a category reads: which categories, at which level, over + * which span. + * + * Derived in one place because two widgets ask for it, the chart and the card + * whose component rows follow the chart's kind. Two different derivations + * would mean two query keys, i.e. two requests deciding over different spans. + */ +export const chartQueryFor = (category: MeasurementCategory, range: ChartRange): { + ids: string[], + level: BucketLevel, + filters: object, +} => ({ + // A group asks for its components in one call, so they share the calendar + // unit and the halves of a reading still meet on the same bucket + ids: category.isGroup ? category.children.map(child => child.id!) : [category.id!], + level: category.isGroup + ? (isSummedPerDay(category.metricType) ? 'day' : 'auto') + : bucketLevelFor(category.metricType, category.chartType), + // The points reach back beyond the range, so the moving average derived + // from them does not start over at the cutoff + filters: entryFilterFor(range), +}); + +/** + * The point level a category's chart needs. + * + * Two charts are built on a calendar unit and fix it: a heatmap draws days, a + * week-over-week chart weeks. A distribution has no time axis and reads + * counted values of its own; the points it gets here are what its fallback + * draws when there are too few values to bin. + */ +export const bucketLevelFor = (metricType: MetricType, chartType: ChartType): BucketLevel => { + switch (resolveChartType(metricType, chartType)) { + case 'heatmap': + return 'day'; + case 'delta': + return 'week'; + default: + // The summed types are drawn as daily totals whatever the range + return isSummedPerDay(metricType) ? 'day' : 'auto'; + } +}; + +/** + * For each point, the average of all points in the given days preceding it. + * + * The window total is carried along instead of re-summing the window for every + * point: with densely sampled metrics the window holds thousands of values, + * and re-adding them each time makes this quadratic. + */ +export const movingAverage = ( + points: ChartPoint[], + days: number = DEFAULT_AVERAGE_WINDOW_DAYS, +): ChartPoint[] => { + const sorted = [...points].sort((a, b) => a.date - b.date); + const out: ChartPoint[] = []; + let start = 0; + let sum = 0; + + for (let end = 0; end < sorted.length; end++) { + sum += sorted[end].value; + + // Users log measurements days or minutes apart, so the start of the + // window has to be advanced by date, not by a fixed number of points + const windowStart = sorted[end].date - days * DAY_MS; + while (start < end && sorted[start].date < windowStart) { + sum -= sorted[start].value; + start++; + } + + out.push({ date: sorted[end].date, value: sum / (end - start + 1) }); + } + + return out; +}; + +/** + * Smoothed trendline via an exponential moving average, seeded with the first + * point. A larger period tracks the values more loosely (smoother, more lag). + */ +export const smoothedTrendline = (points: ChartPoint[], period: number = 10): ChartPoint[] => + calculateEMA([...points].sort((a, b) => a.date - b.date), p => p.value, period) + .map(point => ({ date: point.date, value: point.ema })); + +/** + * Time units a dense series is condensed into, finest first. + * + * Buckets follow the calendar instead of being equal slices of the total span: + * these metrics have a daily rhythm (asleep, awake, a workout), so slices that + * do not line up with a day each catch a different phase of it, and the result + * oscillates at the slice frequency instead of showing the shape of the data. + */ +const BUCKET_STARTS: ((date: Date) => Date)[] = [ + d => new Date(d.getFullYear(), d.getMonth(), d.getDate(), d.getHours()), + d => new Date(d.getFullYear(), d.getMonth(), d.getDate()), + d => { + const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + // getDay() counts from Sunday, the week starts on Monday + monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7)); + return monday; + }, + d => new Date(d.getFullYear(), d.getMonth(), 1), +]; + +/** + * Condenses one bucket into a single point: the mean value at the start of the + * bucket, spanning the values it stands for. + */ +const summarise = (date: number, bucket: ChartPoint[]): ChartPoint => { + let sum = 0; + // An entry that already carries a range contributes its bounds, not just + // its value, so re-condensing an aggregate keeps the true extremes + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + + for (const point of bucket) { + sum += point.value; + min = Math.min(min, point.min ?? point.value); + max = Math.max(max, point.max ?? point.value); + } + + return { date: date, value: sum / bucket.length, min: min, max: max }; +}; + +/** + * Reduces a dense series to at most maxPoints, keeping its shape. + * + * Plotting more points than the chart has pixels only overdraws: a season of + * raw heart rate samples is tens of thousands of values on a few hundred + * pixels, which comes out as a solid block. Entries are therefore condensed + * into the finest calendar unit that gets under the limit; each unit becomes + * one point at its mean, carrying its minimum and maximum so the chart draws + * the spread as a band. That keeps exactly the information a line through + * every single sample buries. + * + * Series that already fit are returned unchanged, which is what the points of + * the aggregate endpoint are: the server condenses to the same limit. What is + * left for this are the charts that still read raw entries (WeightChart), and + * it goes once those read buckets too. + */ +export const downsample = (points: ChartPoint[], maxPoints: number = MAX_CHART_POINTS): ChartPoint[] => { + if (points.length <= maxPoints) { + return points; + } + + for (const [index, bucketStart] of BUCKET_STARTS.entries()) { + const grouped = new Map(); + for (const point of points) { + const key = bucketStart(new Date(point.date)).getTime(); + const bucket = grouped.get(key); + if (bucket === undefined) { + grouped.set(key, [point]); + } else { + bucket.push(point); + } + } + + if (grouped.size <= maxPoints || index === BUCKET_STARTS.length - 1) { + return [...grouped.entries()] + .sort(([a], [b]) => a - b) + .map(([date, bucket]) => summarise(date, bucket)); + } + } + + return points; +}; + +/** + * Sums points per local calendar day, for metric types where individual + * samples aren't meaningful on their own (steps, distance, energy, sleep) + */ +export const aggregatePerDay = (points: ChartPoint[]): ChartPoint[] => { + const sums = new Map(); + for (const point of points) { + const date = new Date(point.date); + const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + sums.set(day, (sums.get(day) ?? 0) + point.value); + } + + return [...sums.entries()] + .map(([date, value]) => ({ date: date, value: value })) + .sort((a, b) => a.date - b.date); +}; + +/** + * Averages points per local calendar day. + * + * The per-day counterpart of aggregatePerDay for the sample metrics (body + * weight, heart rate), where a day's readings are repeated measurements of the + * same thing and adding them up would be meaningless. Used by the charts that + * need exactly one value per day. + */ +export const averagePerDay = (points: ChartPoint[]): ChartPoint[] => { + const byDay = new Map(); + for (const point of points) { + const date = new Date(point.date); + const day = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + const values = byDay.get(day); + if (values === undefined) { + byDay.set(day, [point.value]); + } else { + values.push(point.value); + } + } + + return [...byDay.entries()] + .map(([date, values]) => ({ + date: date, + value: values.reduce((sum, value) => sum + value, 0) / values.length, + })) + .sort((a, b) => a.date - b.date); +}; + +/** Days of the week, the grid of a heatmap has one row per weekday */ +export const DAYS_PER_WEEK = 7; + +/** + * Widest a heatmap gets, in week columns. + * + * A year is where the grid stops being readable: 53 columns already put the + * cells at a few pixels each, and a history of several years would be a wall + * rather than a chart. The range selector above the chart can go further + * (all-time), so the heatmap caps itself here; the month labels along the top + * say which span is actually drawn. + */ +export const HEATMAP_MAX_WEEKS = 53; + +/** + * A calendar heatmap laid out as a grid of week columns and weekday rows, with + * the values it draws. + * + * Days are addressed by their position in the grid, so nothing downstream has + * to do calendar arithmetic: column 0 row 0 is the start, which is always a + * Monday. + */ +export interface HeatmapGrid { + /** Monday of the first (oldest) week column */ + start: number; + /** Number of week columns */ + weeks: number; + /** + * Value of each day the grid shows that has one, keyed by local midnight of + * that day. Days outside the window are not part of this chart and are left + * out, see buildHeatmapGrid. + */ + values: Map; + /** + * Largest value in the grid, the top of the colour scale. Zero for an empty + * grid, and for a history that holds nothing but zeroes. + */ + maxValue: number; +} + +// Calendar arithmetic, not milliseconds: a DST day is 23 or 25 hours long +const dayOf = (date: Date): Date => new Date(date.getFullYear(), date.getMonth(), date.getDate()); +const shiftDays = (date: Date, days: number): Date => + new Date(date.getFullYear(), date.getMonth(), date.getDate() + days); +// getDay() counts from Sunday, the week starts on Monday +const mondayOf = (date: Date): Date => shiftDays(date, -((date.getDay() + 6) % 7)); +const daysBetween = (from: Date, to: Date): number => Math.round( + (Date.UTC(to.getFullYear(), to.getMonth(), to.getDate()) + - Date.UTC(from.getFullYear(), from.getMonth(), from.getDate())) / DAY_MS +); + +/** + * The level a week is summarised at: its total for the summed metric types, its + * average for the sample ones, whose readings repeat the same measurement. + */ +const weekLevel = (values: number[], summed: boolean): number => { + const total = values.reduce((sum, value) => sum + value, 0); + + return summed ? total : total / values.length; +}; + +/** + * Week-over-week change: one point per calendar week against the last week + * with readings, summarised (see weekLevel) before subtracting so no single + * reading decides a bar. The running week of a summed metric is left out, + * its total is still growing and would read as a drop until Sunday. + */ +export const weeklyDeltas = ( + points: ChartPoint[], + summed: boolean = false, + today: Date = new Date(), +): ChartPoint[] => { + const byWeek = new Map(); + for (const point of points) { + const week = mondayOf(new Date(point.date)).getTime(); + const values = byWeek.get(week); + if (values === undefined) { + byWeek.set(week, [point.value]); + } else { + values.push(point.value); + } + } + + if (summed) { + byWeek.delete(mondayOf(today).getTime()); + } + + const weeks = [...byWeek.keys()].sort((a, b) => a - b); + + return weeks.slice(1).map((week, index) => ({ + date: week, + value: weekLevel(byWeek.get(week)!, summed) - weekLevel(byWeek.get(weeks[index])!, summed), + })); +}; + +/** + * Fewest values a distribution says anything about: below this a histogram is + * noise with gaps, and the chart falls back to the derived default. Same + * principle as a group whose readings are all unpaired falling back to lines: + * never an empty or misleading card. + */ +export const DISTRIBUTION_MIN_VALUES = 15; + +/** + * Widest a histogram gets, in bins. A single outlier (a lb reading stored into + * a kg category) would otherwise stretch a fixed-width histogram into hundreds + * of near-empty bins. + */ +const DISTRIBUTION_MAX_BINS = 100; + +/** + * A bin width for values nothing is known about: the span split into + * targetBins, rounded up to 1, 2 or 5 times a power of ten so the edges land + * on round numbers. For the typed metrics the maintained widths in binWidthFor + * are used instead. + */ +export const niceBinWidth = (min: number, max: number, targetBins: number = 20): number => { + const span = max - min; + if (span <= 0) { + return 1; + } + + const raw = span / targetBins; + const magnitude = Math.pow(10, Math.floor(Math.log10(raw))); + const normalized = raw / magnitude; + + return (normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10) * magnitude; +}; + +/** + * A distribution: the values of a period binned by size instead of plotted + * over time, which is what shows the spread and the outliers. + */ +export interface Histogram { + /** + * Lower edge of the first bin, a multiple of binWidth so the edges land on + * round numbers (60-62, not 59.3-61.3) + */ + firstEdge: number; + binWidth: number; + /** + * How many values each bin holds. Bins between the occupied ones are + * present with a zero: a gap in the distribution is worth seeing. + */ + counts: number[]; + /** Median of the binned values */ + median: number; + /** The newest value, i.e. where in the distribution the user is today */ + latest: number; +} + +/** + * Bins the points into a histogram of binWidth-wide bins aligned to round + * boundaries; without a width (free-form categories) one is derived from the + * span, see niceBinWidth. + * + * What one value stands for (a reading, a daily total) is the caller's + * decision, the same split as for the heatmap: the summed types distribute + * their days, the sample types every reading. + */ +export const buildHistogram = ( + values: ValueCount[], + latest: number, + binWidth?: number, +): Histogram => { + const sorted = [...values].sort((a, b) => a.value - b.value); + const minValue = sorted[0].value; + const maxValue = sorted[sorted.length - 1].value; + + let width = binWidth ?? niceBinWidth(minValue, maxValue); + // Doubling keeps the edges round, unlike recomputing a fitted width + while (Math.floor(maxValue / width) - Math.floor(minValue / width) >= DISTRIBUTION_MAX_BINS) { + width *= 2; + } + + const firstBin = Math.floor(minValue / width); + const counts = new Array(Math.floor(maxValue / width) - firstBin + 1).fill(0); + for (const entry of sorted) { + counts[Math.floor(entry.value / width) - firstBin] += entry.count; + } + + return { + firstEdge: firstBin * width, + binWidth: width, + counts: counts, + median: weightedMedian(sorted), + latest: latest, + }; +}; + +/** One value and how often it occurred, which is what a histogram bins */ +export interface ValueCount { + value: number; + count: number; +} + +/** The middle value, counting each one as often as it occurred */ +const weightedMedian = (sorted: ValueCount[]): number => { + const total = sorted.reduce((sum, entry) => sum + entry.count, 0); + const at = (index: number) => { + let seen = 0; + for (const entry of sorted) { + seen += entry.count; + if (index < seen) { + return entry.value; + } + } + return sorted[sorted.length - 1].value; + }; + + const first = at(Math.floor((total - 1) / 2)); + + return total % 2 === 1 ? first : (first + at(Math.floor(total / 2))) / 2; +}; + +/** + * The counted values of a category in the target unit, and where the user + * stands today. + * + * Values are counted per unit they were entered in, so each goes through the + * conversion helper before equal ones are added up. + */ +export const valueHistogram = ( + counts: MeasurementValueCount[], + targetUnit: string, + categoryUnit: string, +): { values: ValueCount[], latest: number } => { + const convert = (value: number, from: string | null) => + convertStoredValue(value, from, categoryUnit, targetUnit); + + const merged = new Map(); + for (const count of counts) { + const value = convert(count.value, count.unit); + merged.set(value, (merged.get(value) ?? 0) + count.count); + } + + const newest = counts.reduce( + (a, b) => b.newest > a.newest ? b : a, + counts[0], + ); + + return { + values: [...merged.entries()].map(([value, count]) => ({ value: value, count: count })), + latest: newest === undefined ? 0 : convert(newest.value, newest.unit), + }; +}; + +/** The day in column week, row weekday (0 = Monday), as a local-midnight timestamp */ +export const heatmapDayAt = (grid: HeatmapGrid, week: number, weekday: number): number => + shiftDays(new Date(grid.start), week * DAYS_PER_WEEK + weekday).getTime(); + +/** + * Lays per-day points out as a calendar grid, newest week last. + * + * Expects one point per calendar day (see aggregatePerDay and averagePerDay). + * The grid ends with the current week, so a stretch without measurements at the + * end stays visible as empty cells; only a history that ended longer ago than + * the grid is wide is anchored at its own last day instead, since an empty grid + * shows nothing at all. + */ +export const buildHeatmapGrid = ( + days: ChartPoint[], + maxWeeks: number = HEATMAP_MAX_WEEKS, + today: Date = new Date(), +): HeatmapGrid => { + const values = new Map(days.map(point => [dayOf(new Date(point.date)).getTime(), point.value])); + const now = dayOf(today); + const window = DAYS_PER_WEEK * (maxWeeks - 1); + + if (values.size === 0) { + return { + start: shiftDays(mondayOf(now), -window).getTime(), + weeks: maxWeeks, + values: values, + maxValue: 0, + }; + } + + const timestamps = [...values.keys()]; + const first = new Date(Math.min(...timestamps)); + const last = new Date(Math.max(...timestamps)); + const oldestVisible = shiftDays(mondayOf(now), -window); + const end = mondayOf(last) < oldestVisible ? last : now; + + const endMonday = mondayOf(end); + const weeks = Math.min( + maxWeeks, + Math.floor(daysBetween(mondayOf(first), endMonday) / DAYS_PER_WEEK) + 1, + ); + const start = shiftDays(endMonday, -DAYS_PER_WEEK * (weeks - 1)); + const lastDay = shiftDays(start, DAYS_PER_WEEK * weeks - 1).getTime(); + + // Only the days the grid actually shows. A history longer than the grid is + // wide keeps its older days out of the window, and a spike among them would + // otherwise set the top of the colour scale without being visible itself, + // washing out every cell that is + const visible = new Map( + [...values.entries()].filter(([day]) => day >= start.getTime() && day <= lastDay) + ); + + return { + start: start.getTime(), + weeks: weeks, + values: visible, + maxValue: visible.size === 0 ? 0 : Math.max(...visible.values()), + }; +}; + +/** + * Fills gaps in a per-day series with zero-value days so a band axis keeps + * the spacing between bars proportional to time + */ +export const fillMissingDays = (points: ChartPoint[]): ChartPoint[] => { + if (points.length === 0) { + return []; + } + + const byDay = new Map(points.map(p => [p.date, p.value])); + const last = points[points.length - 1].date; + const out: ChartPoint[] = []; + // aggregatePerDay emits local-midnight timestamps; stepping via setDate + // stays on local midnight across DST changes + for (const day = new Date(points[0].date); day.getTime() <= last; day.setDate(day.getDate() + 1)) { + out.push({ date: day.getTime(), value: byDay.get(day.getTime()) ?? 0 }); + } + + return out; +}; + +/** + * The readings of a multi-value group as ranges: one point per timestamp, + * spanning from the lower component to the upper one. + * + * A reading is one event, so it is drawn as a single bar (diastolic to + * systolic) rather than as two lines: the components belong together, and + * nothing was measured between two readings. Components are paired by their + * shared timestamp, which is how both the importer and the group form write + * them; an unpaired half-reading is skipped, it has no range. + */ +export const groupComponentPoints = ( + group: MeasurementCategory, + buckets: MeasurementBucket[], + cutoff: Date | null = null, +): Map => new Map(group.children.map(child => [ + child.id!, + pointsSince( + chartPointsForBuckets( + buckets.filter(bucket => bucket.category === child.id), + child.unit, + child.unit, + // A stage the night was slept in twice is that night's total, not + // the average of its two stretches + isSummedPerDay(child.metricType), + ), + cutoff, + ), +])); + +export const groupRangeEntries = (points: Map): ChartPoint[] => { + const byDate = new Map(); + for (const component of points.values()) { + for (const point of component) { + const values = byDate.get(point.date); + if (values === undefined) { + byDate.set(point.date, [point.value]); + } else { + values.push(point.value); + } + } + } + + const ranges = [...byDate.entries()] + .filter(([, values]) => values.length > 1) + .map(([date, values]) => ({ + date: date, + value: values.reduce((sum, value) => sum + value, 0) / values.length, + // The low/high assignment comes from the values, not from the + // component order, so a reordered group still reads correctly + min: Math.min(...values), + max: Math.max(...values), + })) + .sort((a, b) => a.date - b.date); + + return ranges; +}; + +/** + * One series per component of a multi-value group, in the children's in-group + * order and named after them + */ +export const groupComponentSeries = ( + group: MeasurementCategory, + points: Map, + labelOf: (category: MeasurementCategory) => string = category => category.name, +): ChartSeries[] => + group.children.map(child => ({ + points: points.get(child.id!) ?? [], + role: 'component' as const, + label: labelOf(child), + })); + +/** + * The values of a category with the average and trend derived from them. + * + * The points are condensed before anything is derived: a trend line over raw + * samples follows the swings within a single day instead of the trend across + * weeks, and the average would be as dense as the values it summarises. The + * average itself is computed over every point and only condensed afterwards, + * so it stays a 7-day average rather than an average of bucket means. + */ +export const measurementSeries = ( + all: ChartPoint[], + cutoff: Date | null = null, + config: ChartConfig = {}, +): ChartSeries[] => { + // The average is computed over the full history and only then cut, so the + // first points of the range average the days before it instead of + // starting over at the cutoff + const average = pointsSince(movingAverage(all, averageWindowOf(config)), cutoff); + const points = pointsSince(all, cutoff); + + const condensed = downsample(points); + const raw: ChartSeries = { points: condensed, role: 'raw' }; + + // A single reading has nothing to average or trend, and recharts draws a + // dot for a one-point series even where the dots are turned off + if (points.length < 2) { + return [raw]; + } + + return [ + raw, + { points: downsample(average), role: 'average' }, + { points: smoothedTrendline(condensed, trendPeriodOf(config)), role: 'trend' }, + ]; +}; + +/** The points of the series with the given role, empty when there is none */ +export const pointsOfRole = (series: ChartSeries[], role: ChartSeries['role']): ChartPoint[] => + series.find(s => s.role === role)?.points ?? []; + +/** + * The components of a group that stack into one whole, i.e. everything but a + * roll-up component (see isGroupTotalMetricType) + */ +export const stackableComponents = (group: MeasurementCategory): MeasurementCategory[] => + group.children.filter(child => !isGroupTotalMetricType(child.metricType)); + +/** One stacked bar: a day, and what each component contributed to it */ +export interface StackedPoint { + date: number; + /** Runs parallel to the labels of the chart, a 0 where nothing was reported */ + values: number[]; +} + +/** + * One stacked bar per day for the given components, stacked in the order they + * are given. + * + * Only days that any component reported are returned. Values are read through + * the unit helper, like everywhere else, so a component holding mixed units + * still stacks correctly. + */ +export const groupStackedEntries = ( + components: MeasurementCategory[], + points: Map, +): StackedPoint[] => { + const byDay = new Map(); + components.forEach((child, index) => { + for (const point of points.get(child.id!) ?? []) { + const values = byDay.get(point.date) ?? new Array(components.length).fill(0); + values[index] += point.value; + byDay.set(point.date, values); + } + }); + + return [...byDay.entries()] + .map(([date, values]) => ({ date: date, values: values })) + .sort((a, b) => a.date - b.date); +}; + +/** + * How the readings of a group are charted. + * + * Components that are parts of one whole (the sleep stages) stack into one bar + * per day. Two components that are the ends of a reading are drawn as a bar + * spanning it. Anything else stays one line per component: more than two + * components cannot be a range, and neither can readings that are not paired, + * which happens once the date of one half is edited apart from the other. + * Without that fallback the card would go blank while there is data. + */ +export type GroupChart = + | { kind: 'stacked', points: StackedPoint[], labels: string[] } + | { kind: 'range', points: ChartPoint[] } + | { kind: 'components', series: ChartSeries[] }; + +export const groupChart = ( + group: MeasurementCategory, + points: Map, + labelOf: (category: MeasurementCategory) => string = category => category.name, +): GroupChart => { + if (isSummedPerDay(group.metricType)) { + const components = stackableComponents(group); + const stacked = groupStackedEntries(components, points); + if (stacked.length > 0) { + return { kind: 'stacked', points: stacked, labels: components.map(labelOf) }; + } + } + + const ranges = group.children.length === 2 ? groupRangeEntries(points) : []; + + return ranges.length > 0 + ? { kind: 'range', points: ranges } + : { kind: 'components', series: groupComponentSeries(group, points, labelOf) }; +}; + +/** One reading of a group: a timestamp, and what each component holds for it */ +export interface GroupReading { + date: Date; + /** Keyed by component id, the value in that component's own unit */ + values: Map; +} + +/** + * The readings of a group, newest first: one per timestamp, paired the way the + * importer and the group form write them. A reading only some components + * reported is kept, a night without deep sleep is not a broken pair. + */ +export const groupReadings = ( + group: MeasurementCategory, + entries: MeasurementEntry[], +): GroupReading[] => { + const unitOf = new Map(group.children.map(child => [child.id!, child.unit])); + + // Keyed by component id, not by name: two components can share a name + const byDate = new Map>(); + for (const entry of entries) { + const unit = unitOf.get(entry.category); + if (unit === undefined) { + continue; + } + const values = byDate.get(entry.date.getTime()) ?? new Map(); + const value = entry.valueIn(unit, unit); + values.set(entry.category, (values.get(entry.category) ?? 0) + value); + byDate.set(entry.date.getTime(), values); + } + + return [...byDate.entries()] + .map(([date, values]) => ({ date: new Date(date), values: values })) + .sort((a, b) => b.date.getTime() - a.date.getTime()); +}; + +/** + * One page of a group's readings, cut where a reading ends. + * {@link truncated} says the server returned fewer entries than it had, which + * leaves the oldest reading half-read: dropping it keeps it off two pages. + */ +export const groupReadingPage = ( + group: MeasurementCategory, + entries: MeasurementEntry[], + pageSize: number, + truncated: boolean, +): { readings: GroupReading[], hasMore: boolean } => { + const all = groupReadings(group, entries); + const whole = truncated && all.length > 1 ? all.slice(0, -1) : all; + + return { + readings: whole.slice(0, pageSize), + hasMore: truncated || whole.length > pageSize, + }; +}; + +/** + * The parts of the periods that overlap the span the chart covers, clamped to + * it. Periods entirely outside it are dropped, so a band never draws past the + * axes. + */ +export const clampPeriods = (periods: PlanPeriod[], points: ChartPoint[]): PlanPeriod[] => { + if (points.length === 0) { + return []; + } + + const first = points[0].date; + const last = points[points.length - 1].date; + + return periods + .filter(period => period.start < last && period.end > first) + .map(period => ({ + ...period, + start: Math.max(period.start, first), + end: Math.min(period.end, last), + })); +}; + +/** Names of the plans whose period contains the given date */ +export const planNamesAt = (periods: PlanPeriod[], date: number): string[] => + periods.filter(period => date >= period.start && date <= period.end).map(period => period.name); + +/** Difference between the first and the last point, null for an empty series */ +export const overallChange = (points: ChartPoint[]): number | null => + points.length === 0 ? null : points[points.length - 1].value - points[0].value; diff --git a/src/components/Measurements/charts/density.test.ts b/src/components/Measurements/charts/density.test.ts new file mode 100644 index 000000000..ba9c42440 --- /dev/null +++ b/src/components/Measurements/charts/density.test.ts @@ -0,0 +1,25 @@ +import { dotRadius, MAX_DOT_RADIUS } from "@/components/Measurements/charts/density"; +import { describe, expect, test } from 'vitest'; + +describe('dotRadius', () => { + test('starts at the maximum while the chart has not been measured', () => { + expect(dotRadius(0, 500)).toBe(MAX_DOT_RADIUS); + }); + + test('is the maximum for a chart with room to spare', () => { + expect(dotRadius(400, 10)).toBe(MAX_DOT_RADIUS); + }); + + test('shrinks as the points get denser', () => { + expect(dotRadius(400, 100)).toBe(2); + expect(dotRadius(400, 200)).toBe(1); + }); + + test('never goes below a visible minimum', () => { + expect(dotRadius(400, 100000)).toBe(0.5); + }); + + test('is the maximum for a series without points', () => { + expect(dotRadius(400, 0)).toBe(MAX_DOT_RADIUS); + }); +}); diff --git a/src/components/Measurements/charts/density.ts b/src/components/Measurements/charts/density.ts new file mode 100644 index 000000000..7acc55d18 --- /dev/null +++ b/src/components/Measurements/charts/density.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef, useState } from "react"; + +/** Radius of a dot on a chart with room to spare */ +export const MAX_DOT_RADIUS = 4; + +/** Smallest dot that is still visible */ +const MIN_DOT_RADIUS = 0.5; + +/** + * Widest a single bar gets, for charts with only a handful of entries. + * + * Unlike the dots, the width of a bar does not have to be computed: recharts + * sizes bars to the band of the axis, which already is the available width + * divided by how many bars share it. Only the upper bound is ours. + */ +export const MAX_BAR_WIDTH = 12; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +/** + * Radius of the dots of a series with the given number of points. + * + * Mark size is in pixels, so it has to follow from how many marks share the + * available space: fixed sizes look fine on demo data and turn a season of + * readings into a solid block. Before the chart has been measured the width is + * 0 and the marks start out at their largest. + */ +export const dotRadius = (availableWidth: number, markCount: number): number => + availableWidth <= 0 || markCount <= 0 + ? MAX_DOT_RADIUS + : clamp(availableWidth / markCount / 2, MIN_DOT_RADIUS, MAX_DOT_RADIUS); + +/** + * The current width of the element the returned ref is put on, 0 until it has + * been measured. Charts need it to size their marks. + */ +export const useChartWidth = () => { + const ref = useRef(null); + const [width, setWidth] = useState(0); + + useEffect(() => { + const element = ref.current; + if (element === null) { + return; + } + + const observer = new ResizeObserver(entries => setWidth(entries[0].contentRect.width)); + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + return [ref, width] as const; +}; diff --git a/src/components/Measurements/charts/format.test.ts b/src/components/Measurements/charts/format.test.ts new file mode 100644 index 000000000..cdc316c7c --- /dev/null +++ b/src/components/Measurements/charts/format.test.ts @@ -0,0 +1,118 @@ +import { + dateTick, + durationAxis, + hoursAndMinutes, + spansYears, + valueOnly, + valueWithUnit +} from "@/components/Measurements/charts/format"; +import { ChartPoint } from "@/components/Measurements/charts/series"; +import { describe, expect, test } from 'vitest'; + +const point = (date: Date): ChartPoint => ({ date: date.getTime(), value: 0 }); + +describe('spansYears', () => { + test('is false for an empty series', () => { + expect(spansYears([])).toBe(false); + }); + + test('is false while the points stay within one year', () => { + expect(spansYears([point(new Date(2023, 0, 1)), point(new Date(2023, 11, 31))])).toBe(false); + }); + + test('is true once they cross into another one', () => { + expect(spansYears([point(new Date(2023, 11, 31)), point(new Date(2024, 0, 1))])).toBe(true); + }); +}); + +describe('dateTick', () => { + const date = new Date(2023, 4, 17).getTime(); + + test('leaves the year out while the chart stays within one', () => { + expect(dateTick(false)(date)).not.toContain('23'); + }); + + test('shows the year once the ticks need it', () => { + expect(dateTick(true)(date)).toContain('23'); + }); +}); + +describe('valueWithUnit', () => { + test('separates the value from its unit', () => { + expect(valueWithUnit(42, 'cm', 'en')).toBe('42 cm'); + }); + + test('cuts the artefacts of summing floats down to what the server stores', () => { + expect(valueWithUnit(11529.939999999999, 'count', 'en')).toBe('11,529.94 count'); + }); + + test('formats the number for the locale', () => { + expect(valueWithUnit(1234.5, 'kcal', 'de')).toBe('1.234,5 kcal'); + }); + + test('caps the fraction digits for at-a-glance readings', () => { + expect(valueWithUnit(61.87, 'bpm', 'en', 0)).toBe('62 bpm'); + expect(valueWithUnit(82.46, 'kg', 'en', 1)).toBe('82.5 kg'); + }); + + test('shows a value stored in minutes as hours and minutes', () => { + expect(valueWithUnit(452, 'min', 'de')).toBe('7:32 h'); + }); +}); + +describe('hoursAndMinutes', () => { + test('splits the minutes into hours and minutes', () => { + expect(hoursAndMinutes(452, 'en')).toBe('7:32'); + }); + + test('pads the minutes so the values line up', () => { + expect(hoursAndMinutes(425, 'en')).toBe('7:05'); + }); + + test('keeps a duration below an hour in the same shape', () => { + expect(hoursAndMinutes(45, 'en')).toBe('0:45'); + }); + + test('rounds to whole minutes', () => { + expect(hoursAndMinutes(59.6, 'en')).toBe('1:00'); + }); + + test('keeps the sign of a negative change', () => { + expect(hoursAndMinutes(-95, 'en')).toBe('-1:35'); + }); +}); + +describe('durationAxis', () => { + test('leaves the ticks to the library for every other unit', () => { + expect(durationAxis('kg', 60, 100)).toBeUndefined(); + }); + + test('puts every tick on a whole hour', () => { + expect(durationAxis('min', 0, 300)?.ticks).toEqual([0, 60, 120, 180, 240, 300]); + }); + + test('widens the step until the ticks are few enough', () => { + expect(durationAxis('min', 0, 540)?.ticks).toEqual([0, 120, 240, 360, 480, 600]); + }); + + test('keeps the domain from cutting the values it was derived from', () => { + const axis = durationAxis('min', 0, 540); + + expect(axis?.domain[1]).toBeGreaterThanOrEqual(540); + expect(axis?.domain[1]).toBe(axis?.ticks[axis.ticks.length - 1]); + }); + + test('starts at the hour below the data instead of at zero', () => { + expect(durationAxis('min', 385, 460)?.domain[0]).toBe(360); + }); +}); + +describe('valueOnly', () => { + test('leaves the unit off', () => { + expect(valueOnly(42, 'cm', 'en')).toBe('42'); + }); + + test('reads a duration as hours and minutes', () => { + expect(valueOnly(452, 'min', 'en')).toBe('7:32'); + }); +}); diff --git a/src/components/Measurements/charts/format.ts b/src/components/Measurements/charts/format.ts new file mode 100644 index 000000000..d2cd3fc74 --- /dev/null +++ b/src/components/Measurements/charts/format.ts @@ -0,0 +1,109 @@ +import { dateToLocale } from "@/core/lib/date"; +import { numberDecimalLocale } from "@/core/lib/numbers"; + +/** Whether the points fall into more than one calendar year */ +export const spansYears = (points: { date: number }[]): boolean => { + if (points.length === 0) { + return false; + } + + const years = points.map(point => new Date(point.date).getFullYear()); + + return Math.min(...years) !== Math.max(...years); +}; + +/** + * Label of a date on an axis. The year is left out while the chart stays + * within one, where it is the same on every tick and only costs space. + */ +export const dateTick = (withYear: boolean) => (value: number): string => + dateToLocale(new Date(value), undefined, withYear + ? { year: '2-digit', month: '2-digit', day: '2-digit' } + : { month: '2-digit', day: '2-digit' }); + +/** The unit a duration is stored in, which is what the health platforms deliver */ +const MINUTES = 'min'; + +/** + * A duration in minutes as hours and minutes, e.g. 452 as "7:32". + * + * Intl does the splitting, which gets the padding and the locale's own digits + * right (7:32 reads ۷:۳۲ in Persian). The sign is ours: a duration is only + * ever negative here as a change between two of them. + */ +export const hoursAndMinutes = (minutes: number, locale: string): string => { + const rounded = Math.round(minutes); + const absolute = Math.abs(rounded); + + return (rounded < 0 ? '-' : '') + new Intl.DurationFormat(locale, { + style: 'digital', + secondsDisplay: 'auto', + }).format({ hours: Math.floor(absolute / 60), minutes: absolute % 60 }); +}; + +/** + * A measured value on its own, formatted the way its unit is read. For the + * ends of a range, where only the last one carries the unit. + * + * [decimals] caps the fraction digits, for at-a-glance readings (see + * displayDecimalsFor); without it the stored precision shows. A duration + * ignores it, hours and minutes have no decimals to cap. + */ +export const valueOnly = (value: number, unit: string, locale: string, decimals?: number): string => + unit === MINUTES ? hoursAndMinutes(value, locale) : numberDecimalLocale(value, locale, decimals); + +/** + * The unit as it is shown. A duration is stored in minutes but read in hours, + * and the symbol stays untranslated like every other category unit. + */ +export const unitLabel = (unit: string): string => unit === MINUTES ? 'h' : unit; + +/** + * A measured value with its unit, both localised. A value stands on its own + * where there is no unit: a step count is a bare number, and so may be a + * free-form category. [decimals] as in valueOnly. + */ +export const valueWithUnit = (value: number, unit: string, locale: string, decimals?: number): string => + unit === '' + ? valueOnly(value, unit, locale, decimals) + : `${valueOnly(value, unit, locale, decimals)} ${unitLabel(unit)}`; + +/** Ticks a duration axis aims for, few enough that the labels stay apart */ +const DURATION_TICKS = 6; + +const MINUTES_PER_HOUR = 60; + +/** + * Domain and ticks of an axis of durations, undefined for every other unit, + * where the library picks them. + * + * A duration is read in hours, so a tick belongs on a whole one: an axis + * labelled 6:40, 8:20, 10:00 is arithmetically correct and unreadable. The + * step grows in whole hours until few enough ticks are left, and the bounds + * are widened to the hours around the data so no tick falls outside them. + */ +export const durationAxis = ( + unit: string, + min: number, + max: number, +): { domain: [number, number], ticks: number[] } | undefined => { + if (unit !== MINUTES) { + return undefined; + } + + const from = Math.floor(min / MINUTES_PER_HOUR) * MINUTES_PER_HOUR; + const to = Math.ceil(max / MINUTES_PER_HOUR) * MINUTES_PER_HOUR; + const hours = Math.max(1, (to - from) / MINUTES_PER_HOUR); + const step = Math.ceil(hours / DURATION_TICKS) * MINUTES_PER_HOUR; + + // The top follows the step rather than the data: a domain that ended below + // the last tick would cut the values it was derived from + const top = from + Math.ceil((to - from) / step) * step; + + const ticks = []; + for (let tick = from; tick <= top; tick += step) { + ticks.push(tick); + } + + return { domain: [from, top], ticks }; +}; diff --git a/src/components/Measurements/charts/range.test.ts b/src/components/Measurements/charts/range.test.ts new file mode 100644 index 000000000..d56f1575f --- /dev/null +++ b/src/components/Measurements/charts/range.test.ts @@ -0,0 +1,72 @@ +import { + displayCutoffFor, + entryFilterFor, + fetchCutoffFor +} from "@/components/Measurements/charts/range"; +import { describe, expect, test } from 'vitest'; + +const noon = new Date(2026, 5, 15, 12, 30); + +describe('fetchCutoffFor', () => { + test('fetches the widest average window beyond the cutoff', () => { + // The first days in range average the days before them, so those have + // to be fetched as well, and how many depends on a setting this bound + // must not vary with. Rounding to midnight also makes it immune to the + // hour the clock change shifts cutoffFor by + expect(fetchCutoffFor('lastMonth', noon)).toStrictEqual(new Date(2026, 3, 16)); + expect(fetchCutoffFor('last3Months', noon)).toStrictEqual(new Date(2026, 1, 15)); + expect(fetchCutoffFor('lastYear', noon)).toStrictEqual(new Date(2025, 4, 16)); + }); + + test('a week is today plus the six days before it', () => { + // 2026-06-15 minus 6 days minus the 30 day average lead + expect(fetchCutoffFor('lastWeek', noon)).toStrictEqual(new Date(2026, 4, 10)); + expect(displayCutoffFor('lastWeek', noon)).toStrictEqual(new Date(2026, 5, 9)); + }); + + test('is stable across the day, so it can go into a query key', () => { + // Derived from the current instant it would differ on every render, + // and the query would refetch forever + const morning = new Date(2026, 5, 15, 6, 0); + const evening = new Date(2026, 5, 15, 23, 59); + + expect(fetchCutoffFor('last3Months', morning)) + .toStrictEqual(fetchCutoffFor('last3Months', evening)); + }); + + test('the full history is fetched whole', () => { + expect(fetchCutoffFor('all', noon)).toBeNull(); + }); +}); + +describe('entryFilterFor', () => { + test('filters the entries by the fetch cutoff', () => { + expect(entryFilterFor('last3Months', noon)) + .toStrictEqual({ "date__gte": new Date(2026, 1, 15).toISOString() }); + }); + + test('the full history needs no filter', () => { + expect(entryFilterFor('all', noon)).toStrictEqual({}); + }); + + test('the display cutoff is the range itself, with no lead', () => { + // The counted values behind the histogram carry no date and cannot be + // trimmed afterwards, so reading them with the average lead would bin + // a month and a half into a chart labelled one month + const now = new Date(2026, 4, 20, 15, 30); + const display = displayCutoffFor('lastMonth', now)!; + const fetch = fetchCutoffFor('lastMonth', now)!; + + expect(display).toStrictEqual(new Date(2026, 3, 20)); + expect(display.getTime()).toBeGreaterThan(fetch.getTime()); + }); + + test('both query cutoffs sit at midnight, so they hold across renders', () => { + const now = new Date(2026, 4, 20, 15, 30); + + for (const cutoff of [displayCutoffFor('lastMonth', now)!, fetchCutoffFor('lastMonth', now)!]) { + expect(cutoff.getHours()).toBe(0); + expect(cutoff.getMinutes()).toBe(0); + } + }); +}); diff --git a/src/components/Measurements/charts/range.ts b/src/components/Measurements/charts/range.ts new file mode 100644 index 000000000..4cbebecc5 --- /dev/null +++ b/src/components/Measurements/charts/range.ts @@ -0,0 +1,104 @@ +import { ChartPoint } from "@/components/Measurements/charts/series"; +import { AVERAGE_WINDOWS } from "@/components/Measurements/models/Category"; + +/** + * How far back the charts go, in the order the selector offers them: widest + * first, narrowing left to right, like the flutter app. + */ +export const CHART_RANGES = ['all', 'lastYear', 'last3Months', 'lastMonth', 'lastWeek'] as const; +export type ChartRange = typeof CHART_RANGES[number]; + +/** + * The range the charts cover until the user picks another one. + */ +export const DEFAULT_CHART_RANGE: ChartRange = 'lastMonth'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +const DAYS: Record = { + all: null, + lastYear: 365, + last3Months: 90, + lastMonth: 30, + // Six, not seven: the cutoff lands six days back, so the window is today + // plus the six days before it, i.e. one week of calendar days + lastWeek: 6, +}; + +/** Oldest date still shown, null for the full history */ +export const cutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => { + const days = DAYS[range]; + + return days === null ? null : new Date(now.getTime() - days * DAY_MS); +}; + +/** + * Days fetched beyond the cutoff, so the moving average of the first days in + * range averages the days before them instead of starting over at the cutoff. + * + * The largest window a category can be set to, rather than its own: this ends + * up in a query key, so deriving it from the setting would refetch whenever + * the setting changes. + */ +const AVERAGE_LEAD_DAYS = Math.max(...AVERAGE_WINDOWS); + +/** + * The cutoff minus a lead, rounded down to midnight. + * + * The rounding is deliberate: these end up in query keys, and a bound derived + * from the current instant would differ on every render and refetch forever. + */ +const cutoffAtMidnight = (range: ChartRange, now: Date, leadDays: number): Date | null => { + const cutoff = cutoffFor(range, now); + if (cutoff === null) { + return null; + } + + const lead = new Date(cutoff.getTime() - leadDays * DAY_MS); + + return new Date(lead.getFullYear(), lead.getMonth(), lead.getDate()); +}; + +/** Oldest entry to fetch for a range, null for the full history */ +export const fetchCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => + cutoffAtMidnight(range, now, AVERAGE_LEAD_DAYS); + +/** + * Oldest entry to summarise for a range, null for the full history: the range + * itself, with no lead. + * + * For the reads that cannot be trimmed afterwards, i.e. the counted values + * behind the histogram: they carry no date, so a read with the average lead + * would bin a month and a half into a chart labelled one month. + */ +export const displayCutoffFor = (range: ChartRange, now: Date = new Date()): Date | null => + cutoffAtMidnight(range, now, 0); + +/** + * Entry filter that fetches only what a range needs, empty for the full + * history. The server has an index on (category, date), so this is cheaper + * than fetching everything and filtering here. + */ +export const entryFilterFor = (range: ChartRange, now: Date = new Date()): object => { + const cutoff = fetchCutoffFor(range, now); + + return cutoff === null ? {} : { "date__gte": cutoff.toISOString() }; +}; + +/** Filter for the reads that summarise exactly the range, see displayCutoffFor */ +export const displayFilterFor = (range: ChartRange, now: Date = new Date()): object => { + const cutoff = displayCutoffFor(range, now); + + return cutoff === null ? {} : { "date__gte": cutoff.toISOString() }; +}; + +/** + * The points from the cutoff on; a null cutoff covers the full history. + * + * A condensed point sits at the start of its bucket, so the bucket the cutoff + * falls into drops out whole rather than half: at a week bucket that is up to + * a week of readings the range technically covers. Deliberate, a part bucket + * drawn next to full ones reads as a real dip. + */ +export const pointsSince = (points: ChartPoint[], cutoff: Date | null): ChartPoint[] => + cutoff === null ? points : points.filter(point => point.date >= cutoff.getTime()); diff --git a/src/components/Measurements/charts/series.ts b/src/components/Measurements/charts/series.ts new file mode 100644 index 000000000..b4ae7f33f --- /dev/null +++ b/src/components/Measurements/charts/series.ts @@ -0,0 +1,56 @@ +/** + * A chart is given a list of series, not a single value list. That is what + * lets one chart show the components of a multi-value group. + */ + +/** + * One point of a series. min/max are set when the point stands for a range + * rather than a single reading — either because the entry is a stored daily + * aggregate (extra_data min/max) or because several points were condensed + * into it. Both are set or neither. + */ +export interface ChartPoint { + date: number; + value: number; + min?: number; + max?: number; +} + +/** + * What a series means, which decides how it is drawn. Colours come from the + * theme when the chart is built, never from the series itself. + */ +export type ChartSeriesRole = +/** the measured values themselves */ + | 'raw' + /** moving average over the raw values */ + | 'average' + /** smoothed trend through the raw values */ + | 'trend' + /** one component of a multi-value group (systolic, diastolic, ...) */ + | 'component'; + +export interface ChartSeries { + points: ChartPoint[]; + role: ChartSeriesRole; + /** + * Name for the legend and the tooltip. Undefined for the unnamed series of + * a plain category, where the chart title already says what is shown. + */ + label?: string; +} + +/** Whether the point carries a range that can be drawn as a band */ +export const hasRange = (point: ChartPoint): boolean => + point.min !== undefined && point.max !== undefined; + +/** + * A nutrition plan period shown for context: shaded as a vertical band in the + * chart, and named in the tooltip of the points it contains. + */ +export interface PlanPeriod { + start: number; + /** An open-ended plan runs up to now */ + end: number; + name: string; +} diff --git a/src/components/Measurements/index.ts b/src/components/Measurements/index.ts index 20faaaba2..f32b521c1 100644 --- a/src/components/Measurements/index.ts +++ b/src/components/Measurements/index.ts @@ -3,17 +3,73 @@ * * Other code may only import from `@/components/Measurements`, never from * internal sub-paths. + * + * Body weight lives here too: it is the user's official body weight category, + * i.e. measurement data with its own screens (see the plan's locked decision + * #2), not a domain of its own. */ +export { BodyWeight } from "./screens/BodyWeight"; export { MeasurementCategoryDetail } from "./screens/MeasurementCategoryDetail"; export { MeasurementCategoryOverview } from "./screens/MeasurementCategoryOverview"; // Models -export { MeasurementCategory } from "./models/Category"; +export { + categoryDisplayName, + correlatesWithNutrition, + isSummedPerDay, + limitsFor, + MeasurementCategory, + METRIC_TYPE_BODY_WEIGHT +} from "./models/Category"; export { MeasurementEntry } from "./models/Entry"; +export { weightUnitOf } from "./models/bodyWeight"; + +// API endpoints +export { + API_MEASUREMENTS_CATEGORY_PATH, + API_MEASUREMENTS_ENTRY_PATH, + getMeasurementEntries +} from "./api/measurements"; // Query hooks -export { useMeasurementsCategoryQuery } from "./queries"; +export { + useAddMeasurementEntryQuery, + useAllMeasurementEntriesQuery, + useDeleteMeasurementEntryQuery, + useEditMeasurementEntryQuery, + useMeasurementBucketsQuery, + useMeasurementEntriesQuery, + useMeasurementsCategoryQuery, + useMeasurementsQuery, + useMeasurementValueCountsQuery +} from "./queries"; +export { + useBodyWeightCategoryQuery, + useBodyWeightQuery, + useDisplayWeightUnit +} from "./queries/bodyWeight"; + +// Charts +export { componentColor, componentPalette } from "./charts/colors"; +export { + chartPointsFor, + chartQueryFor, + groupChart, + groupComponentPoints, + measurementSeries +} from "./charts/data"; +export { valueWithUnit } from "./charts/format"; +export { CHART_RANGES, cutoffFor, DEFAULT_CHART_RANGE, entryFilterFor } from "./charts/range"; +export type { ChartRange } from "./charts/range"; +export type { PlanPeriod } from "./charts/series"; // Widgets +export { CategoryDetailDataGrid } from "./widgets/CategoryDetailDataGrid"; export { CategoryForm } from "./widgets/CategoryForm"; +export { ChartRangeSelector } from "./widgets/ChartRangeSelector"; export { MeasurementChart } from "./widgets/MeasurementChart"; +export { MeasurementSeriesChart } from "./widgets/MeasurementSeriesChart"; +export { OverallChange } from "./widgets/OverallChange"; +export { WeightChart } from "./widgets/WeightChart"; +export { WeightForm } from "./widgets/WeightForm"; +export { WeightTableDashboard } from "./widgets/WeightTableDashboard"; diff --git a/src/components/Measurements/models/Bucket.ts b/src/components/Measurements/models/Bucket.ts new file mode 100644 index 000000000..297300384 --- /dev/null +++ b/src/components/Measurements/models/Bucket.ts @@ -0,0 +1,67 @@ +/** + * What the aggregate endpoints return: measurements condensed into what a + * chart draws. + * + * A chart shows a few hundred points and a watch-fed metric holds tens of + * thousands a year, so the condensing happens in the query. Both shapes are + * grouped by the unit the values were entered in as well, because a mean over + * kg and lb values is a number in neither: the client converts each row + * through `valueIn` before merging them. + */ + +/** One calendar bucket of a category's entries */ +export class MeasurementBucket { + constructor( + public category: string, + public start: Date, + /** The unit the values were entered in, null when they carry none */ + public unit: string | null, + public count: number, + public sum: number, + /** + * Lowest and highest value the bucket stands for. An entry that is + * itself a daily aggregate contributes its stored bounds rather than + * its value, so condensing one keeps the true extremes. + */ + public min: number, + public max: number, + ) { + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static fromJson(item: any): MeasurementBucket { + return new MeasurementBucket( + item.category, + new Date(item.start), + item.unit ?? null, + item.count, + parseFloat(item.sum), + parseFloat(item.min), + parseFloat(item.max), + ); + } +} + +/** How often one value occurred, the histogram's counterpart to a bucket */ +export class MeasurementValueCount { + constructor( + public category: string, + public value: number, + public unit: string | null, + public count: number, + /** Newest entry holding this value, i.e. where the user stands today */ + public newest: Date, + ) { + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + static fromJson(item: any): MeasurementValueCount { + return new MeasurementValueCount( + item.category, + parseFloat(item.value), + item.unit ?? null, + item.count, + new Date(item.newest), + ); + } +} diff --git a/src/components/Measurements/models/Category.test.ts b/src/components/Measurements/models/Category.test.ts new file mode 100644 index 000000000..a2c05999b --- /dev/null +++ b/src/components/Measurements/models/Category.test.ts @@ -0,0 +1,270 @@ +import { + availableChartTypes, + averageWindowOf, + binWidthFor, + categoryDisplayName, + isComponentMetricType, + isGroupMetricType, + isSummedPerDay, + limitsFor, + MEASUREMENT_SCHEMA_MAX_VALUE, + MeasurementCategory, + metricTypeFromApi, + resolveChartType, + TrendCharacter, + trendOf, + trendPeriodOf +} from "./Category"; + +describe('MeasurementCategory', () => { + + test('fromJson reads the metric type, parent and order', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', + name: 'Systolic', + unit: 'mmHg', + metric_type: 'blood_pressure', + is_official: false, + parent: 'c-parent', + order: 3, + }); + + expect(category.metricType).toBe('blood_pressure'); + expect(category.parentId).toBe('c-parent'); + expect(category.order).toBe(3); + }); + + test('metric type, parent and order survive the json round trip', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', + name: 'Steps', + unit: 'steps', + metric_type: 'steps', + is_official: false, + parent: null, + order: 2, + }); + + const cloned = MeasurementCategory.clone(category, { name: 'Daily steps' }); + expect(cloned.toJson()).toStrictEqual({ + id: 'c-1', + name: 'Daily steps', + unit: 'steps', + + metric_type: 'steps', + chart_type: null, + chart_config: {}, + parent: null, + order: 2, + }); + }); + + test('an unknown metric type from the server falls back to custom', () => { + expect(metricTypeFromApi('brain_waves')).toBe('custom'); + expect(metricTypeFromApi(undefined)).toBe('custom'); + expect(metricTypeFromApi('heart_rate')).toBe('heart_rate'); + }); + + test('clone treats a null parentId override as "remove from group"', () => { + const category = new MeasurementCategory('c-1', 'Systolic', 'mmHg', 'blood_pressure', false, 'c-parent', 1); + + expect(MeasurementCategory.clone(category).parentId).toBe('c-parent'); + expect(MeasurementCategory.clone(category, { name: 'x' }).parentId).toBe('c-parent'); + expect(MeasurementCategory.clone(category, { parentId: null }).parentId).toBeNull(); + expect(MeasurementCategory.clone(category, { parentId: 'c-other' }).parentId).toBe('c-other'); + }); + + test('only cumulative metric types are summed per day', () => { + expect(isSummedPerDay('steps')).toBe(true); + expect(isSummedPerDay('distance')).toBe(true); + expect(isSummedPerDay('energy')).toBe(true); + expect(isSummedPerDay('sleep')).toBe(true); + expect(isSummedPerDay('sleep_total')).toBe(true); + expect(isSummedPerDay('sleep_deep')).toBe(true); + + expect(isSummedPerDay('custom')).toBe(false); + expect(isSummedPerDay('body_weight')).toBe(false); + expect(isSummedPerDay('heart_rate')).toBe(false); + expect(isSummedPerDay('blood_pressure')).toBe(false); + }); + + test('value limits are per unit for body weight only', () => { + expect(limitsFor('body_weight', 'kg').max).toBe(350); + expect(limitsFor('body_weight', 'lb').max).toBe(770); + + // every other type has one unit, so the argument changes nothing + expect(limitsFor('heart_rate', 'bpm').max).toBe(limitsFor('heart_rate').max); + }); + + test('value limits of the components differ from each other', () => { + expect(limitsFor('blood_pressure_systolic').max).toBe(250); + expect(limitsFor('blood_pressure_diastolic').max).toBe(150); + }); + + test('an untyped category is only bounded by the column itself', () => { + expect(limitsFor('custom')).toEqual({ min: 0, max: MEASUREMENT_SCHEMA_MAX_VALUE }); + }); + + test('sleep is a group of stage components', () => { + expect(isGroupMetricType('sleep')).toBe(true); + expect(isComponentMetricType('sleep_total')).toBe(true); + expect(isComponentMetricType('sleep_awake')).toBe(true); + + // The group itself is never a component, and a leaf is neither + expect(isComponentMetricType('sleep')).toBe(false); + expect(isGroupMetricType('sleep_deep')).toBe(false); + expect(isGroupMetricType('heart_rate')).toBe(false); + }); + + describe('chart type', () => { + + test('fromJson reads the null the server sends as no override', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', + name: 'Steps', + unit: 'steps', + metric_type: 'steps', + chart_type: null, + }); + + expect(category.chartType).toBe('auto'); + }); + + test('fromJson falls back to auto for a type this release does not know', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', name: 'Steps', unit: 'steps', chart_type: 'sunburst', + }); + + expect(category.chartType).toBe('auto'); + }); + + test('toJson sends no override as null', () => { + const category = new MeasurementCategory('c-1', 'Steps', 'steps'); + + expect(category.toJson().chart_type).toBeNull(); + }); + + test('toJson sends the picked type', () => { + const category = new MeasurementCategory( + 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap', + ); + + expect(category.toJson().chart_type).toBe('heatmap'); + }); + + test('clone carries the chart type over and can override it', () => { + const category = new MeasurementCategory( + 'c-1', 'Steps', 'steps', 'steps', false, null, 0, 'heatmap', + ); + + expect(MeasurementCategory.clone(category).chartType).toBe('heatmap'); + expect(MeasurementCategory.clone(category, { chartType: 'auto' }).chartType) + .toBe('auto'); + }); + + test('the offered types follow the metric type', () => { + expect(availableChartTypes('steps')) + .toEqual(['bar', 'heatmap', 'delta', 'distribution']); + expect(availableChartTypes('custom')) + .toEqual(['line', 'heatmap', 'delta', 'distribution']); + + // a group is drawn by what its components are to each other + expect(availableChartTypes('blood_pressure')).toEqual([]); + }); + + test('a type that does not fit falls back to the derived chart', () => { + expect(resolveChartType('custom', 'bar')).toBe('line'); + expect(resolveChartType('steps', 'line')).toBe('bar'); + expect(resolveChartType('custom', 'auto')).toBe('line'); + }); + + test('a type that fits is kept', () => { + expect(resolveChartType('custom', 'heatmap')).toBe('heatmap'); + expect(resolveChartType('steps', 'bar')).toBe('bar'); + expect(resolveChartType('body_weight', 'delta')).toBe('delta'); + expect(resolveChartType('resting_heart_rate', 'distribution')).toBe('distribution'); + }); + }); + + describe('chart config', () => { + + test('an unconfigured category gets the defaults', () => { + expect(trendOf({})).toBe('balanced'); + expect(averageWindowOf({})).toBe(7); + }); + + test('reads what was configured', () => { + expect(trendOf({ trend: 'sluggish' })).toBe('sluggish'); + expect(averageWindowOf({ average_window: 30 })).toBe(30); + }); + + test('a value this release does not know falls back to the default', () => { + expect(trendOf({ trend: 'glacial' as TrendCharacter })).toBe('balanced'); + expect(averageWindowOf({ average_window: 21 })).toBe(7); + expect(averageWindowOf({ average_window: 'a fortnight' as unknown as number })).toBe(7); + }); + + test('the trend character maps to the EMA period the chart uses', () => { + expect(trendPeriodOf({ trend: 'reactive' })) + .toBeLessThan(trendPeriodOf({ trend: 'balanced' })); + expect(trendPeriodOf({ trend: 'sluggish' })) + .toBeGreaterThan(trendPeriodOf({ trend: 'balanced' })); + }); + + test('a setting is changed without dropping the keys of another client', () => { + const category = new MeasurementCategory('c-1', 'Biceps', 'cm'); + category.chartConfig = { goal_line: 75 }; + + expect(category.withChartSetting('trend', 'reactive').chartConfig) + .toEqual({ goal_line: 75, trend: 'reactive' }); + }); + + test('fromJson ignores a configuration that is not an object', () => { + const category = MeasurementCategory.fromJson({ + id: 'c-1', name: 'Steps', unit: 'steps', chart_config: null, + }); + + expect(category.chartConfig).toEqual({}); + }); + }); + + describe('binWidthFor', () => { + + test('body weight follows the unit, like its limits do', () => { + expect(binWidthFor('body_weight', 'kg')).toBe(0.5); + expect(binWidthFor('body_weight', 'lb')).toBe(1); + }); + + test('the typed metrics carry a fixed width', () => { + expect(binWidthFor('resting_heart_rate')).toBe(1); + expect(binWidthFor('steps')).toBe(1000); + expect(binWidthFor('sleep_total')).toBe(30); + }); + + test('free-form categories and groups have none, theirs follows the data', () => { + expect(binWidthFor('custom')).toBeUndefined(); + expect(binWidthFor('blood_pressure')).toBeUndefined(); + }); + }); + + describe('categoryDisplayName', () => { + + // the tests' t() returns the key it is given + const t = ((key: string) => key) as never; + + test('a typed category is named after its metric type', () => { + const category = new MeasurementCategory( + 'c-1', 'Blutdruck', 'mmHg', 'blood_pressure_systolic', + ); + + expect(categoryDisplayName(category, t)) + .toBe('measurements.metricTypes.blood_pressure_systolic'); + }); + + test('a free-form category keeps the name the user gave it', () => { + const category = new MeasurementCategory('c-1', 'Bizeps', 'cm'); + + expect(categoryDisplayName(category, t)).toBe('Bizeps'); + }); + }); +}); diff --git a/src/components/Measurements/models/Category.ts b/src/components/Measurements/models/Category.ts index ff92dd2d4..6f7180187 100644 --- a/src/components/Measurements/models/Category.ts +++ b/src/components/Measurements/models/Category.ts @@ -1,28 +1,485 @@ -import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { Adapter } from "@/core/lib/Adapter"; +import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit"; +import { TFunction } from "i18next"; + +/** Semantic category types, the values mirror the Django MetricType choices */ +export const METRIC_TYPES = [ + 'custom', + 'body_weight', + 'body_fat', + 'lean_body_mass', + 'height', + 'blood_pressure', + 'blood_pressure_systolic', + 'blood_pressure_diastolic', + 'heart_rate', + 'resting_heart_rate', + 'blood_oxygen', + 'steps', + 'distance', + 'energy', + 'sleep', + 'sleep_total', + 'sleep_light', + 'sleep_deep', + 'sleep_rem', + 'sleep_awake', +] as const; +export type MetricType = typeof METRIC_TYPES[number]; + +/** Server-side MetricType value marking a category as holding body weight data */ +export const METRIC_TYPE_BODY_WEIGHT: MetricType = 'body_weight'; + +/** + * The chart a category is drawn as. + * + * The values mirror the Django ChartType choices, where the override is a + * nullable column: 'auto' is that null, i.e. "derive the chart from the metric + * type", which is what every category does unless the user picked something + * else. Only the shapes that are a matter of taste are offered; a floating bar + * (two components) and a stacked bar (a summed group) follow from what the + * group is and are not choices. + */ +export const CHART_TYPES = ['auto', 'line', 'bar', 'heatmap', 'delta', 'distribution'] as const; +export type ChartType = typeof CHART_TYPES[number]; + +/** + * How closely the trend line follows the values, as the EMA period it maps to. + * + * Stored as the character rather than the number, so the periods stay tunable + * without touching what users configured. + */ +export const TREND_CHARACTERS = ['reactive', 'balanced', 'sluggish'] as const; +export type TrendCharacter = typeof TREND_CHARACTERS[number]; + +const TREND_EMA_PERIODS: Record = { + reactive: 5, + balanced: 10, + sluggish: 20, +}; + +/** Windows the moving average may be computed over, in days */ +export const AVERAGE_WINDOWS = [7, 14, 30]; + +/** Taste-level chart settings, see chart_config on the server */ +export interface ChartConfig { + trend?: TrendCharacter; + average_window?: number; + + /** Keys another client wrote, kept so a write from here does not drop them */ + [key: string]: unknown; +} + +/** Falls back to 'balanced', which is the unconfigured chart */ +export function trendOf(config: ChartConfig): TrendCharacter { + return TREND_CHARACTERS.includes(config.trend as TrendCharacter) + ? config.trend as TrendCharacter + : 'balanced'; +} + +/** The EMA period the trend line of this configuration is smoothed with */ +export function trendPeriodOf(config: ChartConfig): number { + return TREND_EMA_PERIODS[trendOf(config)]; +} + +/** + * Window the moving average covers, in days. Anything the picker does not + * offer falls back to the first window, the same rule an unfitting chart type + * follows. + */ +export function averageWindowOf(config: ChartConfig): number { + const window = config.average_window; + + return typeof window === 'number' && AVERAGE_WINDOWS.includes(window) + ? window + : AVERAGE_WINDOWS[0]; +} + +/** + * Narrows a server value to a known chart type. Null is the server's "no + * override"; an unrecognised value is one added after this release, and + * falling back to 'auto' is what keeps such a category readable here. + */ +export function chartTypeFromApi(value: unknown): ChartType { + return CHART_TYPES.includes(value as ChartType) ? value as ChartType : 'auto'; +} + +/** + * Name to show the user for a category. + * + * A typed category is created by the server or by the health importer and + * carries an English name ("Systolic", "Deep sleep"), while its metric type + * already has a translated label. Only a free-form category holds a name the + * user picked themselves. + */ +export function categoryDisplayName( + category: { name: string, metricType: MetricType }, + t: TFunction, +): string { + return category.metricType === 'custom' + ? category.name + : t(`measurements.metricTypes.${category.metricType}`); +} + +/** Narrows a server value to a known metric type, unknown values fall back to 'custom' */ +export function metricTypeFromApi(value: unknown): MetricType { + return METRIC_TYPES.includes(value as MetricType) ? value as MetricType : 'custom'; +} + +/** + * Metric types whose individual samples aren't meaningful on their own: + * they are summed per day and charted as bars instead of a line + */ +export function isSummedPerDay(type: MetricType): boolean { + return type === 'steps' + || type === 'distance' + || type === 'energy' + || type === 'sleep' + || type === 'sleep_total' + || type === 'sleep_light' + || type === 'sleep_deep' + || type === 'sleep_rem' + || type === 'sleep_awake'; +} + +/** + * The chart a category of this metric type is drawn as when the user picked + * none. + * + * Summed types are one value per day and are drawn as that day's bar, + * everything else is a series of samples and gets the line chart. A group has + * no default here: its chart follows from what its components are to each + * other, see groupChart. + */ +export function defaultChartType(type: MetricType): ChartType { + return isSummedPerDay(type) ? 'bar' : 'line'; +} + +/** + * The chart types a category of this metric type may be drawn as, i.e. what + * the picker offers on top of 'auto'. + * + * The alternatives fit every leaf type: the heatmap answers how regularly + * rather than how much, and is the only chart of the set where a missing day is + * visible instead of being spanned by a line; the delta chart answers which way + * it is going, which a line only implies; the distribution answers what is + * normal and what is an outlier, which no chart over time shows. A group is + * left out, its chart is structural rather than a preference. + */ +export function availableChartTypes(type: MetricType): ChartType[] { + return isGroupMetricType(type) + ? [] + : [defaultChartType(type), 'heatmap', 'delta', 'distribution']; +} + +/** + * The chart a category of this metric type is drawn as, given what the user + * picked. + * + * A pick that does not fit the type falls back to the derived default instead + * of being refused: the server stores the string without judging it, so this is + * also what keeps a category configured on another client from showing nothing + * here. + */ +export function resolveChartType(type: MetricType, picked: ChartType): ChartType { + return availableChartTypes(type).includes(picked) ? picked : defaultChartType(type); +} + +/** + * Metric types whose charts show nutrition plan periods for context. Custom + * categories are typically hand-kept body measurements (waist, biceps), so + * they qualify; the typed health metrics do not. + */ +export function correlatesWithNutrition(type: MetricType): boolean { + return type === 'body_weight' || type === 'body_fat' || type === 'lean_body_mass' || type === 'custom'; +} + +/** + * Metric types reserved for the official categories the server manages: + * users cannot create categories of these types + */ +export function isOfficialMetricType(type: MetricType): boolean { + return type === METRIC_TYPE_BODY_WEIGHT; +} + +/** + * The components of the multi-value metric types, in group order. Mirrors + * GROUP_COMPONENTS on the server, which is what creates these categories. + */ +export const GROUP_COMPONENTS: Partial> = { + // eslint-disable-next-line camelcase + blood_pressure: ['blood_pressure_systolic', 'blood_pressure_diastolic'], + // The total is a component of its own because a group carries no + // measurements. It is not the sum of the three stages next to it: platforms + // also report sleep without a stage breakdown, which counts towards the + // total and has no stage category to live in + sleep: ['sleep_total', 'sleep_light', 'sleep_deep', 'sleep_rem', 'sleep_awake'], +}; + +/** + * A container type whose readings live in its components, e.g. blood pressure. + * A group category never carries entries of its own. + */ +export function isGroupMetricType(type: MetricType): boolean { + return type in GROUP_COMPONENTS; +} + +/** + * The types a user can pick when creating a category. Body weight is the + * server's, a component comes with its group, and a free-form category is not + * picked but described. + */ +export function isPickableMetricType(type: MetricType): boolean { + return type !== 'custom' && !isOfficialMetricType(type) && !isComponentMetricType(type); +} + +/* eslint-disable camelcase */ +/** + * Name and unit a category of this type is created under. Users see the + * translated label instead, this is what ends up in the database. + * + * The server and the flutter health importer create their categories under the + * same values, so whoever gets there first, the row looks the same. The unit is + * also the one METRIC_LIMITS below is expressed in. + */ +const METRIC_DEFAULTS: Partial> = { + body_weight: { name: 'Weight', unit: 'kg' }, + body_fat: { name: 'Body fat', unit: '%' }, + lean_body_mass: { name: 'Lean body mass', unit: 'kg' }, + height: { name: 'Height', unit: 'cm' }, + blood_pressure: { name: 'Blood pressure', unit: 'mmHg' }, + blood_pressure_systolic: { name: 'Systolic', unit: 'mmHg' }, + blood_pressure_diastolic: { name: 'Diastolic', unit: 'mmHg' }, + heart_rate: { name: 'Heart rate', unit: 'bpm' }, + resting_heart_rate: { name: 'Resting heart rate', unit: 'bpm' }, + blood_oxygen: { name: 'Blood oxygen', unit: '%' }, + // A step count is a bare number, not a quantity in some unit + steps: { name: 'Steps', unit: '' }, + distance: { name: 'Distance', unit: 'km' }, + energy: { name: 'Energy', unit: 'kcal' }, + sleep: { name: 'Sleep', unit: 'min' }, + sleep_total: { name: 'Total sleep', unit: 'min' }, + sleep_light: { name: 'Light sleep', unit: 'min' }, + sleep_deep: { name: 'Deep sleep', unit: 'min' }, + sleep_rem: { name: 'REM sleep', unit: 'min' }, + sleep_awake: { name: 'Awake', unit: 'min' }, +}; + +/* eslint-enable camelcase */ + +/** Empty for a free-form category, whose name and unit the user gives it */ +export function defaultsForMetricType(type: MetricType): { name: string, unit: string } { + return METRIC_DEFAULTS[type] ?? { name: '', unit: '' }; +} + +/** + * Largest value the server's column can hold (numeric(8, 2)). It is what a + * category without a metric type is bounded by, since nothing about a free-form + * category says more. + */ +export const MEASUREMENT_SCHEMA_MAX_VALUE = 999999.99; + +/** + * The range a measurement value of one metric type may be in. min/max are what + * the API enforces, a value outside them comes back as a 400; softMin/softMax + * are the everyday range, meant for warnings and chart axes, and are enforced + * nowhere. + */ +export interface MetricLimits { + min: number; + max: number; + softMin?: number; + softMax?: number; +} + +/** + * The bounds per metric type, in the unit the type is stored in. + * + * MUST stay identical to METRIC_LIMITS on the server. Bounds may be widened + * over releases, never tightened: a client that still knows the wider one would + * write values the server then rejects permanently. + */ +/* eslint-disable camelcase */ +const METRIC_LIMITS: Partial> = { + body_fat: { min: 2, max: 60, softMin: 5, softMax: 50 }, + // Always below the body weight it is part of, so the floor can sit lower + lean_body_mass: { min: 10, max: 250, softMin: 30, softMax: 90 }, + height: { min: 50, max: 250, softMin: 140, softMax: 210 }, + blood_pressure_systolic: { min: 50, max: 250, softMin: 90, softMax: 180 }, + blood_pressure_diastolic: { min: 30, max: 150, softMin: 50, softMax: 110 }, + heart_rate: { min: 30, max: 250, softMin: 40, softMax: 200 }, + resting_heart_rate: { min: 30, max: 120, softMin: 40, softMax: 100 }, + // A saturation cannot exceed 100 %, and the floor is deliberately far below + // what a pulse oximeter still displays + blood_oxygen: { min: 50, max: 100, softMin: 90, softMax: 100 }, + // The cumulative types hold a whole day, and a rest day really is 0 steps + steps: { min: 0, max: 100000, softMin: 0, softMax: 30000 }, + distance: { min: 0, max: 500, softMin: 0, softMax: 30 }, + energy: { min: 0, max: 10000, softMin: 0, softMax: 2000 }, + // Sleep is stored in minutes, so the upper bound is not a rarity but + // arithmetic: a day has 1440 of them + sleep_total: { min: 0, max: 1440, softMin: 180, softMax: 720 }, + sleep_light: { min: 0, max: 1440, softMin: 0, softMax: 720 }, + sleep_deep: { min: 0, max: 1440, softMin: 0, softMax: 720 }, + sleep_rem: { min: 0, max: 1440, softMin: 0, softMax: 720 }, + sleep_awake: { min: 0, max: 1440, softMin: 0, softMax: 720 }, +}; +/* eslint-enable camelcase */ + +/** Body weight is the only metric whose values come in more than one unit */ +const BODY_WEIGHT_LIMITS: Record = { + kg: { min: 20, max: 350, softMin: 30, softMax: 300 }, + lb: { min: 44, max: 770, softMin: 66, softMax: 661 }, +}; + +/** + * The range a value in a category of this metric type may be in. Free-form + * categories, and the group containers that carry no entries at all, are only + * bounded by the column itself. + */ +export function limitsFor(type: MetricType, unit?: string): MetricLimits { + if (type === METRIC_TYPE_BODY_WEIGHT) { + return BODY_WEIGHT_LIMITS[isWeightUnit(unit) ? unit : 'kg']; + } + + return METRIC_LIMITS[type] ?? { min: 0, max: MEASUREMENT_SCHEMA_MAX_VALUE }; +} + +/** + * Width of one distribution-histogram bin per metric type, in the unit the + * type is stored in. + * + * Fixed per type rather than computed (Freedman-Diaconis and friends): a + * computed width changes with every range switch, which makes two looks at the + * same category incomparable, and it lands on edges like 0.73 kg where a + * maintained table lands on round ones. + * + * MUST stay identical to MetricType.binWidth in flutter, or the same category + * bins differently per client. + */ +/* eslint-disable camelcase */ +const BIN_WIDTHS: Partial> = { + body_fat: 0.5, + lean_body_mass: 0.5, + height: 1, + blood_pressure_systolic: 5, + blood_pressure_diastolic: 5, + heart_rate: 2, + resting_heart_rate: 1, + blood_oxygen: 1, + steps: 1000, + distance: 1, + energy: 100, + sleep_total: 30, + sleep_light: 15, + sleep_deep: 15, + sleep_rem: 15, + sleep_awake: 15, +}; +/* eslint-enable camelcase */ + +/** Body weight bins follow the unit, like its limits do */ +const BODY_WEIGHT_BIN_WIDTHS: Record = { kg: 0.5, lb: 1 }; + +/** + * Width of one histogram bin for a category of this metric type, undefined for + * the types nothing is known about (free-form categories, and the groups, + * which are never drawn as a distribution): their width is derived from the + * data instead. + */ +export function binWidthFor(type: MetricType, unit?: string): number | undefined { + if (type === METRIC_TYPE_BODY_WEIGHT) { + return BODY_WEIGHT_BIN_WIDTHS[isWeightUnit(unit) ? unit : 'kg']; + } + + return BIN_WIDTHS[type]; +} + +/** + * Most decimals a value of this type is shown with at a glance (the card + * headline). Detail tables, forms and tooltips keep the stored value. + * + * Follows the resolution the metric is measured at, like the bin widths: a + * pulse has no meaningful tenths, a body weight does, and a short walk needs + * its hundredths of a kilometre. Durations never ask, they are read as hours + * and minutes. Mirrors MetricType.displayDecimals in flutter. + */ +export function displayDecimalsFor(type: MetricType): number { + switch (type) { + case 'body_weight': + case 'lean_body_mass': + case 'body_fat': + case 'custom': + return 1; + case 'distance': + return 2; + default: + return 0; + } +} + +/** + * One component of a group, e.g. systolic. Components exist only as the + * children of their group, which the server creates them with, so they are + * never offered when creating a category. + */ +export function isComponentMetricType(type: MetricType): boolean { + return Object.values(GROUP_COMPONENTS).some(components => components.includes(type)); +} + +/** + * The component that rolls its siblings up instead of being one part next to + * them. Total sleep already covers the stages beside it, so a stacked chart + * has to leave it out or it counts every night twice. + */ +export function isGroupTotalMetricType(type: MetricType): boolean { + return type === 'sleep_total'; +} export class MeasurementCategory { - entries: MeasurementEntry[] = []; + /** + * Child categories (components) of a multi-value group such as blood + * pressure. Populated by the API layer for display, never persisted + * directly; only leaf categories carry entries. + */ + children: MeasurementCategory[] = []; constructor( public id: string | null, public name: string, public unit: string, - entries?: MeasurementEntry[] + public metricType: MetricType = 'custom', + public isOfficial: boolean = false, + public parentId: string | null = null, + public order: number = 0, + /** Chart the user picked, 'auto' (the server's null) for the derived one */ + public chartType: ChartType = 'auto', + /** Taste-level chart settings, read through trendOf and averageWindowOf */ + public chartConfig: ChartConfig = {}, ) { - if (entries) { - this.entries = entries; - } } - static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory { - return new MeasurementCategory( + get isGroup(): boolean { + return this.children.length > 0; + } + + static clone(other: MeasurementCategory, overrides?: Partial>): MeasurementCategory { + const category = new MeasurementCategory( overrides?.id ?? other.id, overrides?.name ?? other.name, overrides?.unit ?? other.unit, - other.entries, + overrides?.metricType ?? other.metricType, + other.isOfficial, + // null is a meaningful override here (remove from group), so the + // usual ?? fallback doesn't work + overrides !== undefined && 'parentId' in overrides ? overrides.parentId ?? null : other.parentId, + other.order, + overrides?.chartType ?? other.chartType, + other.chartConfig, ); + category.children = other.children; + return category; } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -30,6 +487,16 @@ export class MeasurementCategory { return adapter.fromJson(json); } + /** + * A copy with one chart setting changed, keeping the keys this release + * does not know: a write replaces the whole object. + */ + withChartSetting(key: string, value: unknown): MeasurementCategory { + const category = MeasurementCategory.clone(this); + category.chartConfig = { ...this.chartConfig, [key]: value }; + return category; + } + toJson() { return adapter.toJson(this); } @@ -42,7 +509,16 @@ class MeasurementCategoryAdapter implements Adapter { return new MeasurementCategory( item.id, item.name, - item.unit + item.unit, + metricTypeFromApi(item.metric_type), + item.is_official, + item.parent ?? null, + item.order ?? 0, + chartTypeFromApi(item.chart_type), + // Anything that is not an object is not a configuration + typeof item.chart_config === 'object' && item.chart_config !== null + ? item.chart_config + : {}, ); } @@ -51,8 +527,18 @@ class MeasurementCategoryAdapter implements Adapter { ...(item.id != null ? { id: item.id } : {}), name: item.name, unit: item.unit, + // eslint-disable-next-line camelcase + metric_type: item.metricType, + // The column is nullable, and null is what makes the server derive + // the chart from the metric type + // eslint-disable-next-line camelcase + chart_type: item.chartType === 'auto' ? null : item.chartType, + // eslint-disable-next-line camelcase + chart_config: item.chartConfig, + parent: item.parentId, + order: item.order, }; } } -const adapter = new MeasurementCategoryAdapter(); \ No newline at end of file +const adapter = new MeasurementCategoryAdapter(); diff --git a/src/components/Measurements/models/Entry.test.ts b/src/components/Measurements/models/Entry.test.ts new file mode 100644 index 000000000..3af4610ae --- /dev/null +++ b/src/components/Measurements/models/Entry.test.ts @@ -0,0 +1,70 @@ +import { MeasurementEntry } from "./Entry"; + +describe('MeasurementEntry', () => { + + test('extra_data survives the json round trip', () => { + const entry = MeasurementEntry.fromJson({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00Z', + value: 42, + notes: '', + source: 'apple', + extra_data: { unit: 'bpm', device: 'Watch' }, + }); + + const cloned = MeasurementEntry.clone(entry, { value: 43 }); + expect(cloned.source).toBe('apple'); + expect(cloned.toJson()).toStrictEqual({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00.000Z', + value: 43, + notes: '', + + extra_data: { unit: 'bpm', device: 'Watch' }, + }); + }); + + test('missing extra_data defaults to an empty object', () => { + const entry = MeasurementEntry.fromJson({ + id: 'd-1', + category: 'c-1', + date: '2023-01-01T12:00:00Z', + value: 42, + notes: '', + }); + + expect(entry.extraData).toStrictEqual({}); + expect(entry.source).toBe('user'); + }); +}); + +describe('MeasurementEntry units', () => { + + const entry = (value: number, extraData: Record = {}) => + new MeasurementEntry('d-1', 'c-1', new Date(2023, 1, 1), value, '', 'user', extraData); + + test('falls back to the category unit when extra_data has none', () => { + expect(entry(80).unitOrFallback('kg')).toBe('kg'); + expect(entry(80, { unit: '' }).unitOrFallback('kg')).toBe('kg'); + expect(entry(80, { unit: 42 }).unitOrFallback('kg')).toBe('kg'); + }); + + test('converts a value stored in another unit', () => { + expect(entry(176.37, { unit: 'lb' }).valueIn('kg', 'kg')).toBe(80); + expect(entry(80, { unit: 'kg' }).valueIn('lb', 'kg')).toBe(176.37); + }); + + test('leaves free-form category units untouched', () => { + expect(entry(42).valueIn('cm', 'cm')).toBe(42); + expect(entry(42, { unit: 'cm' }).valueIn('kg', 'cm')).toBe(42); + }); + + test('bounds follow the value through the same conversion', () => { + const aggregate = entry(80, { unit: 'lb', min: 70, max: 90 }); + + expect(aggregate.boundIn(70, 'kg', 'kg')).toBe(31.75); + expect(aggregate.boundIn(90, 'kg', 'kg')).toBe(40.82); + }); +}); diff --git a/src/components/Measurements/models/Entry.ts b/src/components/Measurements/models/Entry.ts index 01df0ec59..7b02fa6c4 100644 --- a/src/components/Measurements/models/Entry.ts +++ b/src/components/Measurements/models/Entry.ts @@ -1,4 +1,5 @@ import { Adapter } from "@/core/lib/Adapter"; +import { convertStoredValue } from "@/core/lib/weightUnit"; export class MeasurementEntry { @@ -7,17 +8,67 @@ export class MeasurementEntry { public category: string, public date: Date, public value: number, - public notes: string + public notes: string, + public source: string = 'user', + public extraData: Record = {}, ) { } - static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry { + /** Entries synced from a health app are managed by the source app */ + get isEditable(): boolean { + return this.source === 'user'; + } + + /** + * The unit the value was entered in: extra_data.unit, falling back to the + * category unit when absent (same chain as the server) + */ + unitOrFallback(categoryUnit: string): string { + const stored = this.extraData['unit']; + return typeof stored === 'string' && stored !== '' ? stored : categoryUnit; + } + + /** + * The value in the given unit. The only way to read a measurement for + * display or calculation: a category can hold entries in mixed units, so + * the raw value on its own is meaningless. + */ + valueIn(targetUnit: string, categoryUnit: string): number { + return this.convert(this.value, targetUnit, categoryUnit); + } + + /** + * The entry's extra_data with the unit its value is in. + * + * The server replaces extra_data as a whole on update, so the keys we do + * not know about have to travel back with it. + */ + extraDataInUnit(unit: string): Record { + return { ...this.extraData, unit: unit }; + } + + /** + * A number stored in extra_data next to the value, such as the bounds of a + * daily aggregate. They are written in the value's unit, so they have to + * follow it through the same conversion. + */ + boundIn(bound: number, targetUnit: string, categoryUnit: string): number { + return this.convert(bound, targetUnit, categoryUnit); + } + + private convert(value: number, targetUnit: string, categoryUnit: string): number { + return convertStoredValue(value, this.extraData.unit as string, categoryUnit, targetUnit); + } + + static clone(other: MeasurementEntry, overrides?: Partial>): MeasurementEntry { return new MeasurementEntry( overrides?.id ?? other.id, overrides?.category ?? other.category, overrides?.date ?? other.date, overrides?.value ?? other.value, overrides?.notes ?? other.notes, + other.source, + overrides?.extraData ?? other.extraData, ); } @@ -41,7 +92,9 @@ class MeasurementEntryAdapter implements Adapter { // full ISO datetime from the server, parsing is timezone-safe new Date(item.date), item.value, - item.notes + item.notes, + item.source, + item.extra_data ?? {}, ); } @@ -52,7 +105,11 @@ class MeasurementEntryAdapter implements Adapter { // the server field is a datetime, send the full timestamp date: item.date.toISOString(), value: item.value, - notes: item.notes + notes: item.notes, + // The server replaces extra_data as a whole on update, so send + // every stored key back + // eslint-disable-next-line camelcase + extra_data: item.extraData, }; } } diff --git a/src/components/Measurements/models/bodyWeight.ts b/src/components/Measurements/models/bodyWeight.ts new file mode 100644 index 000000000..963be62d1 --- /dev/null +++ b/src/components/Measurements/models/bodyWeight.ts @@ -0,0 +1,15 @@ +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { isWeightUnit, WeightUnit } from "@/core/lib/weightUnit"; + +/** + * Body weight is stored as a measurement in the user's official body weight + * category, so an entry is a plain MeasurementEntry. What is specific to it is + * that its value is in one of the two units the app can convert between. + */ + +/** The unit an entry's value is stored in, narrowed to what we can convert */ +export const weightUnitOf = (entry: MeasurementEntry, categoryUnit: string): WeightUnit => { + const stored = entry.unitOrFallback(categoryUnit); + + return isWeightUnit(stored) ? stored : 'kg'; +}; diff --git a/src/components/Measurements/queries/bodyWeight.test.tsx b/src/components/Measurements/queries/bodyWeight.test.tsx new file mode 100644 index 000000000..3e78a9b55 --- /dev/null +++ b/src/components/Measurements/queries/bodyWeight.test.tsx @@ -0,0 +1,36 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from '@testing-library/react'; +import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight"; +import { useBodyWeightQuery } from "@/components/Measurements/queries/bodyWeight"; +import { QueryKey } from "@/core/lib/consts"; +import { testBodyWeightCategory } from "@/tests/weight/testData"; +import React from "react"; +import type { Mock } from 'vitest'; + +vi.mock("@/components/Measurements/api/bodyWeight"); + +describe("body weight queries", () => { + + beforeEach(() => { + vi.clearAllMocks(); + (getBodyWeightCategory as Mock).mockResolvedValue(testBodyWeightCategory); + (getWeights as Mock).mockResolvedValue([]); + }); + + test('an entry written anywhere invalidates the body weight view', async () => { + + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: React.ReactNode }) => + {children}; + + const { result } = renderHook(() => useBodyWeightQuery(), { wrapper }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(getWeights).toHaveBeenCalledTimes(1); + + // What the measurement entry mutations invalidate. Body weight rows are + // measurement rows, so this view has to follow + await queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS] }); + + await waitFor(() => expect(getWeights).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/src/components/Measurements/queries/bodyWeight.ts b/src/components/Measurements/queries/bodyWeight.ts new file mode 100644 index 000000000..1fbf93d6f --- /dev/null +++ b/src/components/Measurements/queries/bodyWeight.ts @@ -0,0 +1,64 @@ +import { keepPreviousData, useQuery, useQueryClient } from "@tanstack/react-query"; +import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight"; +import { useProfileQuery } from "@/components/User"; +import { QueryKey, } from "@/core/lib/consts"; +import { WeightUnit } from "@/core/lib/weightUnit"; + +/** + * Cache key of the official body weight category, standing in for its id. + * + * Body weight rows are measurement rows, so the queries below live under the + * measurement keys: an entry written through the measurement mutations + * invalidates the weight views and the other way round. The id itself cannot + * be the key, because it is only known once the category query resolved, and a + * query key has to exist before that. + */ +const OFFICIAL_BODY_WEIGHT = 'official-body-weight'; + +/* + * The official body weight category basically never changes, resolve it once + * per session (ensureQueryData returns the cached result on later calls) + */ +const bodyWeightCategoryQueryOptions = { + queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, OFFICIAL_BODY_WEIGHT], + queryFn: () => getBodyWeightCategory(), +}; + +export function useBodyWeightCategoryQuery() { + return useQuery(bodyWeightCategoryQueryOptions); +} + +/* + * The unit weight values are displayed in: the user's profile weight unit. + * Entries keep the unit they were entered in, only the presentation converts. + */ +export function useDisplayWeightUnit(): WeightUnit { + const profileQuery = useProfileQuery(); + + return profileQuery.data?.useMetric === false ? 'lb' : 'kg'; +} + +/** + * Body weight entries, newest first. + * + * The filterset is the one the measurement queries take (`entryFilterFor` for + * a chart range, explicit date bounds otherwise), so a screen fetches what it + * shows instead of the whole history. + * + * Writes go through the measurement entry mutations, which invalidate this key + * along with every other view of the same rows. + */ +export function useBodyWeightQuery(filtersetQueryEntries: object = {}) { + const queryClient = useQueryClient(); + + return useQuery({ + queryKey: [QueryKey.MEASUREMENTS, OFFICIAL_BODY_WEIGHT, filtersetQueryEntries], + queryFn: async () => { + const category = await queryClient.ensureQueryData(bodyWeightCategoryQueryOptions); + return getWeights(category, filtersetQueryEntries); + }, + // Widening the range refetches, and the chart would otherwise drop + // back to the loading placeholder while the longer history arrives + placeholderData: keepPreviousData, + }); +} diff --git a/src/components/Measurements/queries/groupReadings.test.tsx b/src/components/Measurements/queries/groupReadings.test.tsx new file mode 100644 index 000000000..d45ca43b5 --- /dev/null +++ b/src/components/Measurements/queries/groupReadings.test.tsx @@ -0,0 +1,104 @@ +import { getGroupEntryPage } from "@/components/Measurements/api/measurements"; +import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { useGroupReadingsQuery } from "@/components/Measurements/queries"; +import { getTestQueryClient } from "@/tests/queryClient"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from '@testing-library/react'; +import React from "react"; +import type { Mock } from 'vitest'; + +vi.mock("@/components/Measurements/api/measurements"); + +const group = () => { + const bloodPressure = new MeasurementCategory('g-1', 'Blood pressure', 'mmHg', 'blood_pressure'); + bloodPressure.children = [ + new MeasurementCategory('c-sys', 'Systolic', 'mmHg', 'blood_pressure_systolic', false, 'g-1', 0), + new MeasurementCategory('c-dia', 'Diastolic', 'mmHg', 'blood_pressure_diastolic', false, 'g-1', 1), + ]; + return bloodPressure; +}; + +/** A day's reading, as the two entries it is stored as */ +const reading = (day: number) => [ + new MeasurementEntry('e-sys', 'c-sys', new Date(2023, 1, day, 8, 0), 120 + day, ''), + new MeasurementEntry('e-dia', 'c-dia', new Date(2023, 1, day, 8, 0), 80 + day, ''), +]; + +const renderReadings = (pageSize: number) => { + const client = getTestQueryClient(); + const wrapper = ({ children }: { children: React.ReactNode }) => + {children}; + + // Spread rather than returned: the query result tracks which fields are + // read during a render and only re-renders on those, and a bare hook reads + // none of them. The widget reads them by rendering with them. + return renderHook(() => ({ ...useGroupReadingsQuery(group(), pageSize) }), { wrapper }); +}; + +describe("useGroupReadingsQuery", () => { + + beforeEach(() => vi.clearAllMocks()); + + test('cuts the readings into pages and reports there is more', async () => { + // Three readings' worth of entries for a page of two, i.e. the server + // had more than the page holds + (getGroupEntryPage as Mock).mockResolvedValue({ + entries: [...reading(9), ...reading(8), ...reading(7)], + truncated: true, + }); + + const { result } = renderReadings(2); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(result.current.data![0].readings.map(r => r.date)).toEqual([ + new Date(2023, 1, 9, 8, 0), + new Date(2023, 1, 8, 8, 0), + ]); + expect(result.current.hasNextPage).toBe(true); + }); + + /** + * A two-page history, answered by the cursor it is asked for rather than + * by call order, so a repeated read cannot shift the pages. + */ + const mockChain = () => (getGroupEntryPage as Mock).mockImplementation( + (_ids: string[], _limit: number, before?: Date) => Promise.resolve(before === undefined + ? { entries: [...reading(9), ...reading(8)], truncated: true } + : { entries: [...reading(7), ...reading(6)], truncated: false }) + ); + + test('the next page starts below the oldest reading of the one before it', async () => { + mockChain(); + + const { result } = renderReadings(1); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await result.current.fetchNextPage(); + + // The cursor is the oldest reading the first page kept, not the oldest + // one it read: page 0 dropped the 8th as possibly half-read + expect((getGroupEntryPage as Mock).mock.calls[1][2]).toEqual(new Date(2023, 1, 9, 8, 0)); + }); + + test('a second page holds other readings than the first', async () => { + mockChain(); + + const { result } = renderReadings(1); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + await result.current.fetchNextPage(); + await waitFor(() => expect(result.current.data).toHaveLength(2)); + + const dates = result.current.data!.map(page => page.readings[0].date); + expect(dates).toEqual([new Date(2023, 1, 9, 8, 0), new Date(2023, 1, 7, 8, 0)]); + }); + + test('asks for a page plus the reading it is cut at', async () => { + (getGroupEntryPage as Mock).mockResolvedValue({ entries: [], truncated: false }); + + const { result } = renderReadings(10); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // (10 + 1) readings times the two components + expect((getGroupEntryPage as Mock).mock.calls[0][1]).toBe(22); + }); +}); diff --git a/src/components/Measurements/queries/index.ts b/src/components/Measurements/queries/index.ts index 4cd688af2..032fb1d58 100644 --- a/src/components/Measurements/queries/index.ts +++ b/src/components/Measurements/queries/index.ts @@ -5,20 +5,70 @@ import { deleteMeasurementEntry, editMeasurementCategory, editMeasurementEntry, + BucketLevel, + getAllMeasurementEntries, + getCategoryEntryFlags, + getGroupEntryPage, + GroupEntryPage, + getLatestMeasurementEntries, + getMeasurementBuckets, getMeasurementCategories, getMeasurementCategory, - MeasurementQueryOptions + getMeasurementEntries, + getMeasurementEntryPage, + getMeasurementValueCounts, + getOldestMeasurementEntry, + MeasurementQueryOptions, + updateMeasurementCategoryOrder } from "@/components/Measurements/api/measurements"; +import { groupReadingPage } from "@/components/Measurements/charts/data"; import { MeasurementCategory } from "@/components/Measurements/models/Category"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import { QueryKey } from "@/core/lib/consts"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + keepPreviousData, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient +} from "@tanstack/react-query"; + + +/** + * Which categories hold entries. Under the category key so that adding or + * removing one refreshes it, like every other read of that list. + */ +const CATEGORY_ENTRY_FLAGS_KEY = [QueryKey.MEASUREMENTS_CATEGORIES, 'entry-flags']; + +/** The condensed reads behind the charts, which every write invalidates */ +const invalidateChartReads = (queryClient: ReturnType) => { + queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_BUCKETS,] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_VALUE_COUNTS,] }); +}; +/** + * What a written entry ages: the entries themselves, the charts drawn from + * them, and whether the category holds any. Not the categories, they carry + * nothing that an entry can change. + */ +const invalidateEntryReads = (queryClient: ReturnType) => { + queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENT_ENTRIES,] }); + queryClient.invalidateQueries({ queryKey: CATEGORY_ENTRY_FLAGS_KEY }); + invalidateChartReads(queryClient); +}; export function useMeasurementsCategoryQuery(options?: MeasurementQueryOptions) { return useQuery({ - queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, JSON.stringify(options || {})], - queryFn: () => getMeasurementCategories(options) + queryKey: [QueryKey.MEASUREMENTS_CATEGORIES, options ?? {}], + queryFn: () => getMeasurementCategories(options), + }); +} + +/** The categories, each with whether it holds entries: what a group parent may be */ +export function useCategoryEntryFlagsQuery() { + return useQuery({ + queryKey: CATEGORY_ENTRY_FLAGS_KEY, + queryFn: () => getCategoryEntryFlags(), }); } @@ -27,9 +77,10 @@ export const useAddMeasurementCategoryQuery = () => { return useMutation({ mutationFn: (category: MeasurementCategory) => addMeasurementCategory(category), - onSuccess: () => queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] - }) + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] }); + invalidateChartReads(queryClient); + } }); }; @@ -45,6 +96,7 @@ export const useEditMeasurementCategoryQuery = (id: string) => { queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] }); + invalidateChartReads(queryClient); } }); }; @@ -61,15 +113,150 @@ export const useDeleteMeasurementCategoryQuery = (id: string) => { queryClient.invalidateQueries({ queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] }); + invalidateChartReads(queryClient); } }); }; -export function useMeasurementsQuery(id: string) { +/** Persists a new top-level category order, the position in the list becomes the order value */ +export const useReorderMeasurementCategoriesQuery = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (categories: MeasurementCategory[]) => Promise.all( + categories.map((category, index) => updateMeasurementCategoryOrder(category.id!, index)) + ), + // Not the chart reads: the order decides where a card sits, not what + // it draws + onSuccess: () => queryClient.invalidateQueries({ + queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] + }) + }); +}; + +export function useMeasurementsQuery(id: string, enabled: boolean = true) { return useQuery({ queryKey: [QueryKey.MEASUREMENTS, id], - queryFn: () => getMeasurementCategory(id) + queryFn: () => getMeasurementCategory(id), + enabled: enabled, + }); +} + +/** + * The entries of one category. + * + * [limit] reads only that many of the newest ones, for the callers that show + * the latest handful rather than a span of time. + */ +export function useMeasurementEntriesQuery( + categoryId: string, + filtersetQuery: object = {}, + limit?: number, + enabled: boolean = true, +) { + return useQuery({ + queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, limit ?? null], + queryFn: () => getMeasurementEntries(categoryId, filtersetQuery, limit), + enabled: enabled, + // Picking another range refetches, and the table would otherwise drop + // back to the loading placeholder while the new one arrives + placeholderData: keepPreviousData, + }); +} + +/** + * The newest entries of a category, or of a group's components together, see + * getLatestMeasurementEntries. Under the entry key, so every write refreshes + * it along with the other entry reads. + */ +export function useLatestMeasurementEntriesQuery(categoryIds: string[]) { + return useQuery({ + queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'latest', categoryIds], + queryFn: () => getLatestMeasurementEntries(categoryIds), + // A group synced without its components yet has nothing to ask for + enabled: categoryIds.length > 0, + placeholderData: keepPreviousData, + }); +} + +/** + * One page of a category's entries, for the tables that show a page at a time. + * + * Kept apart from the query above, which hands over a whole span: a table + * shows ten rows, and a synced category holds thousands of them. + */ +export function useMeasurementEntryPageQuery( + categoryId: string, + offset: number, + limit: number, + filtersetQuery: object = {}, +) { + return useQuery({ + queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, 'page', offset, limit], + queryFn: () => getMeasurementEntryPage(categoryId, offset, limit, filtersetQuery), + // Turning the page refetches, and the table would otherwise drop back + // to an empty grid while the next one arrives + placeholderData: keepPreviousData, + }); +} + +/** + * The readings of a group, a page at a time. Each page carries the cursor of + * the next one, which is why they are fetched as a chain rather than by index. + */ +export function useGroupReadingsQuery( + group: MeasurementCategory, + pageSize: number, + filtersetQuery: object = {}, +) { + const categoryIds = group.children.map(child => child.id!); + // A page plus the reading it is cut at; asking for a page exactly would + // spend a row on the cut every time + const limit = (pageSize + 1) * categoryIds.length; + const readingsOf = (page: GroupEntryPage) => + groupReadingPage(group, page.entries, pageSize, page.truncated); + + return useInfiniteQuery({ + queryKey: [ + QueryKey.MEASUREMENT_ENTRIES, + 'group-readings', + categoryIds.join(','), + filtersetQuery, + pageSize, + ], + queryFn: ({ pageParam }) => getGroupEntryPage(categoryIds, limit, pageParam, filtersetQuery), + initialPageParam: undefined as Date | undefined, + getNextPageParam: page => { + const { readings, hasMore } = readingsOf(page); + + return hasMore && readings.length > 0 + ? readings[readings.length - 1].date + : undefined; + }, + select: data => data.pages.map(readingsOf), + // A group synced without its components yet has nothing to ask for + enabled: categoryIds.length > 0, + }); +} + +/** + * The oldest entry of a category, which the total change of every row is + * measured against. Its own query, so paging through the table doesn't read + * it again: it only changes with the range. + */ +export function useOldestMeasurementEntryQuery(categoryId: string, filtersetQuery: object = {}) { + return useQuery({ + queryKey: [QueryKey.MEASUREMENT_ENTRIES, categoryId, filtersetQuery, 'oldest'], + queryFn: () => getOldestMeasurementEntry(categoryId, filtersetQuery), + }); +} + +/** The entries of every category in one read, for the views that show a window of time */ +export function useAllMeasurementEntriesQuery(filtersetQuery: object = {}) { + return useQuery({ + queryKey: [QueryKey.MEASUREMENT_ENTRIES, 'all', filtersetQuery], + queryFn: () => getAllMeasurementEntries(filtersetQuery), }); } @@ -78,14 +265,17 @@ export const useAddMeasurementEntryQuery = () => { return useMutation({ mutationFn: (entry: MeasurementEntry) => addMeasurementEntry(entry), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS,] - }); - queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] - }); - } + onSuccess: () => invalidateEntryReads(queryClient) + }); +}; + +/** Adds one entry per component of a multi-value group, e.g. blood pressure */ +export const useAddGroupEntriesQuery = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (entries: MeasurementEntry[]) => Promise.all(entries.map(entry => addMeasurementEntry(entry))), + onSuccess: () => invalidateEntryReads(queryClient) }); }; @@ -94,24 +284,65 @@ export const useEditMeasurementEntryQuery = () => { return useMutation({ mutationFn: (entry: MeasurementEntry) => editMeasurementEntry(entry), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS,] - }); - queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS_CATEGORIES,] - }); - } + onSuccess: () => invalidateEntryReads(queryClient) }); }; -export const useDeleteMeasurementsQuery = (/*id: number*/) => { +export const useDeleteMeasurementEntryQuery = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => deleteMeasurementEntry(id), - onSuccess: () => queryClient.invalidateQueries({ - queryKey: [QueryKey.MEASUREMENTS,] - }) + onSuccess: () => invalidateEntryReads(queryClient) }); }; + + +/** + * The chart points of one or more categories, condensed by the server. + * + * Kept apart from the category queries, which hand over the entries + * themselves: a chart shows a few hundred points, and a watch-fed metric holds + * tens of thousands a year. A group passes its components in one call, so they + * share the calendar unit and their readings still meet on the same bucket. + */ +export function useMeasurementBucketsQuery( + categoryIds: string[], + level: BucketLevel, + filtersetQuery: object = {}, + enabled: boolean = true, +) { + return useQuery({ + queryKey: [ + QueryKey.MEASUREMENT_BUCKETS, + categoryIds.join(','), + level, + filtersetQuery, + ], + queryFn: () => getMeasurementBuckets(categoryIds, level, filtersetQuery), + enabled: enabled && categoryIds.length > 0, + // Picking another range refetches, and the chart would otherwise drop + // back to the loading placeholder while the new one arrives + placeholderData: keepPreviousData, + }); +} + +/** How often each value occurred, which is what the histogram bins */ +export function useMeasurementValueCountsQuery( + categoryId: string, + summedPerDay: boolean, + filtersetQuery: object = {}, + enabled: boolean = true, +) { + return useQuery({ + queryKey: [ + QueryKey.MEASUREMENT_VALUE_COUNTS, + categoryId, + summedPerDay, + filtersetQuery, + ], + queryFn: () => getMeasurementValueCounts(categoryId, summedPerDay, filtersetQuery), + enabled: enabled, + placeholderData: keepPreviousData, + }); +} diff --git a/src/components/Measurements/screens/BodyWeight.test.tsx b/src/components/Measurements/screens/BodyWeight.test.tsx new file mode 100644 index 000000000..6230f52b5 --- /dev/null +++ b/src/components/Measurements/screens/BodyWeight.test.tsx @@ -0,0 +1,81 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { DEFAULT_CHART_RANGE, entryFilterFor } from "@/components/Measurements"; +import { getBodyWeightCategory, getWeights } from "@/components/Measurements/api/bodyWeight"; +import { testQueryClient } from "@/tests/queryClient"; +import { resetChartRange } from "@/components/Measurements/state/chartRange"; +import { testBodyWeightCategory, makeWeightEntry } from "@/tests/weight/testData"; +import { BodyWeight } from "./BodyWeight"; +import type { Mock } from 'vitest'; + +vi.mock("@/components/Measurements/api/bodyWeight"); +vi.mock('@/components/User/queries/profile', () => ({ + useProfileQuery: () => ({ isLoading: false, data: { useMetric: true } }), +})); +console.log = vi.fn(); + +describe("Test BodyWeight component", () => { + + beforeEach(() => { + testQueryClient.clear(); + (getBodyWeightCategory as Mock).mockImplementation(() => Promise.resolve(testBodyWeightCategory)); + }); + + // See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest + afterEach(() => { + vi.restoreAllMocks(); + // The range store is shared module state, a picked range would leak + // into the next test + resetChartRange(); + }); + + // Arrange + const weightData = [ + makeWeightEntry(new Date('2021-12-10'), 80, { id: 'dddddddd-dddd-dddd-dddd-000000000001' }), + makeWeightEntry(new Date('2021-12-20'), 90, { id: 'dddddddd-dddd-dddd-dddd-000000000002' }), + ]; + + test('renders without crashing', async () => { + + (getWeights as Mock).mockImplementation(() => Promise.resolve(weightData)); + + // Act + render( + + + + ); + + // Assert - both weights are found in the document, in the unit the + // grid shows them in + expect(await screen.findByText("80 kg")).toBeInTheDocument(); + expect(await screen.findByText("90 kg")).toBeInTheDocument(); + // only the entries the range shows are fetched + expect(getWeights).toHaveBeenCalledWith( + testBodyWeightCategory, + entryFilterFor(DEFAULT_CHART_RANGE), + ); + }); + + test('picking a chart range fetches that range', async () => { + + (getWeights as Mock).mockImplementation(() => Promise.resolve(weightData)); + + render( + + + + ); + + expect(await screen.findByText("80 kg")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'measurements.chartRangeAll' })); + + // the full history has no lower bound, so the filterset is empty + await waitFor(() => { + expect(getWeights).toHaveBeenLastCalledWith(testBodyWeightCategory, {}); + }); + // the entries stay on screen while the wider range is loading + expect(screen.getByText("80 kg")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Measurements/screens/BodyWeight.tsx b/src/components/Measurements/screens/BodyWeight.tsx new file mode 100644 index 000000000..0bc6d0452 --- /dev/null +++ b/src/components/Measurements/screens/BodyWeight.tsx @@ -0,0 +1,63 @@ +import { Box, Stack } from "@mui/material"; +import { entryFilterFor } from "@/components/Measurements/charts/range"; +import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid"; +import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector"; +import { PlanPeriod } from "@/components/Measurements/charts/series"; +import { setChartRange, useChartRange } from "@/components/Measurements/state/chartRange"; +import { + useBodyWeightCategoryQuery, + useBodyWeightQuery, + useDisplayWeightUnit +} from "@/components/Measurements/queries/bodyWeight"; +import { WeightChart } from "@/components/Measurements/widgets/WeightChart"; +import { AddBodyWeightEntryFab } from "@/components/Measurements/widgets/fab"; +import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; +import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container"; +import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty"; +import { useTranslation } from "react-i18next"; + + +/** [planPeriods] come from the caller: measurements know nothing about nutrition */ +export const BodyWeight = (props: { planPeriods?: PlanPeriod[] }) => { + const [t] = useTranslation(); + // Shared with the other measurement screens, see useChartRange + const range = useChartRange(); + // Fetch what the range shows, rather than the whole history. The filter + // reaches a week further back than the chart draws, so the moving average + // of the first days in range still averages the days before them. The + // table below lists the same entries, so it follows the range too + const weightyQuery = useBodyWeightQuery(entryFilterFor(range)); + const categoryQuery = useBodyWeightCategoryQuery(); + const displayUnit = useDisplayWeightUnit(); + + if (weightyQuery.isLoading || categoryQuery.isLoading) { + return ; + } + + // Entries without their own unit fall back to the one of the category + const categoryUnit = categoryQuery.data!.unit; + + return + + {weightyQuery.data!.length === 0 && } + {weightyQuery.data!.length !== 0 && <> + + + + } + + } + fab={} + />; +}; diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx index 5c7d53a5d..299f54088 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.test.tsx @@ -1,6 +1,15 @@ -import { useMeasurementsQuery } from "@/components/Measurements/queries"; +import { + useMeasurementEntryPageQuery, + useMeasurementsQuery, + useOldestMeasurementEntryQuery +} from "@/components/Measurements/queries"; import { MeasurementCategoryDetail } from "@/components/Measurements/screens/MeasurementCategoryDetail"; -import { TEST_MEASUREMENT_CATEGORY_1 } from "@/tests/measurementsTestData"; +import { mockChartQueries } from "@/tests/chartQueries"; +import { + TEST_MEASUREMENT_CATEGORY_1, + TEST_MEASUREMENT_ENTRIES_1, + TEST_MEASUREMENT_SEED_1 +} from "@/tests/measurementsTestData"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from '@testing-library/react'; import React from 'react'; @@ -20,6 +29,18 @@ describe("Test the MeasurementCategoryDetail component", () => { isLoading: false, data: TEST_MEASUREMENT_CATEGORY_1 })); + // The chart reads its points from the aggregated queries, the grid + // under it one page of the entries themselves + mockChartQueries([TEST_MEASUREMENT_SEED_1]); + (useMeasurementEntryPageQuery as Mock).mockImplementation(() => ({ + data: { + entries: TEST_MEASUREMENT_ENTRIES_1, + count: TEST_MEASUREMENT_ENTRIES_1.length, + next: null, + }, + isFetching: false, + })); + (useOldestMeasurementEntryQuery as Mock).mockImplementation(() => ({ data: null })); }); afterEach(() => { @@ -43,12 +64,12 @@ describe("Test the MeasurementCategoryDetail component", () => { expect(useMeasurementsQuery).toHaveBeenCalled(); expect(screen.getByText('Biceps')).toBeInTheDocument(); - expect(screen.getByRole('gridcell', { name: /10cm/i })).toBeInTheDocument(); + expect(screen.getByRole('gridcell', { name: /10 cm/i })).toBeInTheDocument(); // the entries now show date and time expect(screen.getAllByText(/2\/1\/2023, 8:00 AM/i).length).toBeGreaterThanOrEqual(1); expect(screen.getByText('test note')).toBeInTheDocument(); - expect(screen.getByRole('gridcell', { name: /20cm/i })).toBeInTheDocument(); + expect(screen.getByRole('gridcell', { name: /20 cm/i })).toBeInTheDocument(); expect(screen.getByText(/2\/2\/2023, 7:45 AM/i)).toBeInTheDocument(); expect(screen.getByText('important note')).toBeInTheDocument(); }); diff --git a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx index 14d32509f..70a578986 100644 --- a/src/components/Measurements/screens/MeasurementCategoryDetail.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryDetail.tsx @@ -1,37 +1,120 @@ -import { Stack, } from "@mui/material"; +import { Stack } from "@mui/material"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container"; -import { useMeasurementsQuery } from "@/components/Measurements/queries"; +import { + categoryDisplayName, + MeasurementCategory, + METRIC_TYPE_BODY_WEIGHT +} from "@/components/Measurements/models/Category"; +import { + useMeasurementEntryPageQuery, + useMeasurementsQuery, + useOldestMeasurementEntryQuery +} from "@/components/Measurements/queries"; +import { PlanPeriod } from "@/components/Measurements/charts/series"; import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid"; import { CategoryDetailDropdown } from "@/components/Measurements/widgets/CategoryDetailDropdown"; +import { GroupReadingsGrid } from "@/components/Measurements/widgets/GroupReadingsGrid"; +import { ChartRange, displayFilterFor } from "@/components/Measurements/charts/range"; +import { setChartRange, useChartRange } from "@/components/Measurements/state/chartRange"; +import { PAGINATION_OPTIONS } from "@/core/lib/consts"; +import { GridPaginationModel } from "@mui/x-data-grid"; import { AddMeasurementEntryFab } from "@/components/Measurements/widgets/fab"; +import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector"; import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart"; +import { makeLink, WgerLink } from "@/core/lib/url"; import React from "react"; -import { useParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { Navigate, useParams } from "react-router-dom"; -export const MeasurementCategoryDetail = () => { +/** + * The grid of one category, a page at a time over the entries the range + * covers. + * + * A component of its own because a group renders one per component, and each + * of them reads its own entries. + */ +const CategoryEntriesGrid = (props: { category: MeasurementCategory, range: ChartRange }) => { + // The range as it is labelled, not the chart's read: that one takes a + // month of lead so the moving average has something to average over, and + // the table would list those rows as if they were part of the range + const filter = displayFilterFor(props.range); + const [pagination, setPagination] = React.useState({ + page: 0, + pageSize: PAGINATION_OPTIONS.pageSize, + }); + // Another range is another set of entries, and page seven of the last one + // says nothing about it + React.useEffect( + () => setPagination(model => ({ ...model, page: 0 })), + [props.range] + ); + + const pageQuery = useMeasurementEntryPageQuery( + props.category.id!, + pagination.page * pagination.pageSize, + pagination.pageSize, + filter, + ); + const oldestQuery = useOldestMeasurementEntryQuery(props.category.id!, filter); + const page = pageQuery.data; + + return entry != null), + isLoading: pageQuery.isFetching, + }} />; +}; + +/** [planPeriods] come from the caller: measurements know nothing about nutrition */ +export const MeasurementCategoryDetail = (props: { planPeriods?: PlanPeriod[] }) => { const params = useParams<{ categoryId: string }>(); const categoryId = params.categoryId ?? ''; if (!categoryId) { return

Please pass a category id.

; } + // eslint-disable-next-line react-hooks/rules-of-hooks + const range = useChartRange(); // eslint-disable-next-line react-hooks/rules-of-hooks const categoryQuery = useMeasurementsQuery(categoryId); + // eslint-disable-next-line react-hooks/rules-of-hooks + const [t, i18n] = useTranslation(); if (categoryQuery.isLoading) { return ; } + // Body weight is presented on its own screens, which read and write it + // through their own query cache. Rendering it here as well would show a + // second view of the same rows and leave the other one stale after an edit + if (categoryQuery.data!.isOfficial && categoryQuery.data!.metricType === METRIC_TYPE_BODY_WEIGHT) { + return ; + } + return } + title={categoryDisplayName(categoryQuery.data!, t)} + // official categories may neither be renamed nor deleted + optionsMenu={categoryQuery.data!.isOfficial + ? undefined + : } mainContent={ - - + + + {categoryQuery.data!.isGroup + ? + : } } - fab={} + fab={} />; }; diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx index 8de66f9f2..a7d609a32 100644 --- a/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryOverview.test.tsx @@ -1,10 +1,22 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, waitFor } from '@testing-library/react'; -import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries"; +import userEvent from "@testing-library/user-event"; +import { + useLatestMeasurementEntriesQuery, + useMeasurementsCategoryQuery, + useReorderMeasurementCategoriesQuery +} from "@/components/Measurements/queries"; import { MeasurementCategoryOverview } from "@/components/Measurements/screens/MeasurementCategoryOverview"; import React from 'react'; import { BrowserRouter } from "react-router-dom"; -import { TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2 } from "@/tests/measurementsTestData"; +import { mockChartQueries } from "@/tests/chartQueries"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { + TEST_MEASUREMENT_CATEGORY_1, + TEST_MEASUREMENT_CATEGORY_2, + TEST_MEASUREMENT_SEED_1, + TEST_MEASUREMENT_SEED_2 +} from "@/tests/measurementsTestData"; import type { Mock } from 'vitest'; vi.mock("@/components/Measurements/queries"); @@ -19,6 +31,17 @@ describe("Test the MeasurementCategoryOverview component", () => { isLoading: false, data: [TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2] })); + (useReorderMeasurementCategoriesQuery as Mock).mockImplementation(() => ({ + mutate: vi.fn() + })); + // The card headers show the newest entry of their category + (useLatestMeasurementEntriesQuery as Mock).mockImplementation((ids: string[]) => ({ + data: [new MeasurementEntry( + '22222222-2222-4222-8222-222222222222', ids[0], new Date(), 42.5, '', + )] + })); + // The cards read their points from the aggregated queries + mockChartQueries([TEST_MEASUREMENT_SEED_1, TEST_MEASUREMENT_SEED_2]); }); afterEach(() => { @@ -40,5 +63,67 @@ describe("Test the MeasurementCategoryOverview component", () => { expect(await screen.findByText('Biceps')).toBeInTheDocument(); expect(screen.getByText('measurements.measurements')).toBeInTheDocument(); expect(screen.getByText('Body fat')).toBeInTheDocument(); + + // The whole card links to its category + expect(screen.getByText('Biceps').closest('a')).toHaveAttribute( + 'href', + expect.stringContaining(`/measurement/category/${TEST_MEASUREMENT_CATEGORY_1.id}`) + ); + + // The header carries the newest value in the category's unit; the + // decimal separator follows the runtime locale + expect(screen.getByText(/42[.,]5 cm/)).toBeInTheDocument(); + expect(screen.getByText(/42[.,]5 %/)).toBeInTheDocument(); + }); + + test('the add button waits while the categories are read again', async () => { + + // Arrange: a new category invalidates the query, and reading the + // histories again takes long enough that the button has to say so + (useMeasurementsCategoryQuery as Mock).mockImplementation(() => ({ + isSuccess: true, + isLoading: false, + isFetching: true, + data: [TEST_MEASUREMENT_CATEGORY_1, TEST_MEASUREMENT_CATEGORY_2] + })); + + // Act + render( + + + + + + ); + + // Assert - the quick-add buttons on the cards carry the same label, + // so the fab is told apart by its class + const fab = screen.getAllByLabelText('add').find(b => b.classList.contains('MuiFab-root'))!; + expect(fab).toBeDefined(); + expect(fab).toBeDisabled(); + expect(fab.querySelector('[data-testid="AddIcon"]')).toBeNull(); + expect(fab.querySelector('.MuiCircularProgress-root')).toBeInTheDocument(); + }); + + test('opens the reorder modal', async () => { + + // Arrange + render( + + + + + + ); + + // Act + await userEvent.click(screen.getByTestId('SortIcon').closest('button')!); + + // Assert - the modal shows the categories as a sortable list + // (the string can also appear in the tooltip of the button itself) + expect(screen.getAllByText('measurements.reorderCategories').length).toBeGreaterThan(0); + // Each category now appears twice, on its card and in the sortable list + expect(screen.getAllByText('Biceps')).toHaveLength(2); + expect(screen.getAllByText('Body fat')).toHaveLength(2); }); }); diff --git a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx index acbb17ba2..d0b9f87df 100644 --- a/src/components/Measurements/screens/MeasurementCategoryOverview.tsx +++ b/src/components/Measurements/screens/MeasurementCategoryOverview.tsx @@ -1,21 +1,37 @@ import React from "react"; -import { Button, Card, CardActions, CardContent, CardHeader, IconButton, Stack, } from "@mui/material"; +import { + Box, + Card, + CardActionArea, + CardActions, + CardContent, + CardHeader, + IconButton, + Stack, + Tooltip, +} from "@mui/material"; import AddIcon from '@mui/icons-material/Add'; +import SortIcon from '@mui/icons-material/Sort'; import { useTranslation } from "react-i18next"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { useMeasurementsCategoryQuery } from "@/components/Measurements/queries"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category"; +import { CategoryLatestValue } from "@/components/Measurements/widgets/CategoryLatestValue"; +import { ChartRange } from "@/components/Measurements/charts/range"; +import { setChartRange, useChartRange } from "@/components/Measurements/state/chartRange"; +import { ChartRangeSelector } from "@/components/Measurements/widgets/ChartRangeSelector"; import { MeasurementChart } from "@/components/Measurements/widgets/MeasurementChart"; import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty"; import { AddMeasurementCategoryFab } from "@/components/Measurements/widgets/fab"; -import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container"; +import { WgerContainerFullWidth } from "@/core/ui/Widgets/Container"; import { makeLink, WgerLink } from "@/core/lib/url"; import { Link } from "react-router-dom"; -import { EntryForm } from "@/components/Measurements/widgets/EntryForm"; +import { CategoryReorderList } from "@/components/Measurements/widgets/CategoryReorderList"; +import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/EntryForm"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; -export const CategoryList = (props: { category: MeasurementCategory }) => { +export const CategoryList = (props: { category: MeasurementCategory, range: ChartRange }) => { const [t, i18n] = useTranslation(); const [openModal, setOpenModal] = React.useState(false); @@ -23,42 +39,81 @@ export const CategoryList = (props: { category: MeasurementCategory }) => { const handleCloseModal = () => setOpenModal(false); return <> - - - - - - - - - + {/* The whole card is the way into the category; only the quick-add + * button below stays a control of its own */} + + + {/* The unit rides on the value; a category still without one + * shows it on its chart axis instead */} + } + /> + + + + + {/* mt: auto pins the action row, so it aligns across a grid row of + * cards with differently sized charts */} + + - + {props.category.isGroup + ? + : } ; }; export const MeasurementCategoryOverview = () => { - const categoryQuery = useMeasurementsCategoryQuery(); const [t] = useTranslation(); + const [openReorderModal, setOpenReorderModal] = React.useState(false); + // One range for all cards, shared with the other measurement screens: + // picking it per card would put a row of buttons on every one of them + const range = useChartRange(); + const categoryQuery = useMeasurementsCategoryQuery(); return categoryQuery.isLoading ? - : - {categoryQuery.data!.length === 0 && } - {categoryQuery.data!.map(c => )} - - } - fab={} - />; + : <> + + setOpenReorderModal(true)}> + + + + } + fab={} + > + + {categoryQuery.data!.length === 0 && } + {categoryQuery.data!.length > 0 + && } + {/* min() keeps the column from forcing a horizontal scroll + * on screens narrower than one card */} + + {categoryQuery.data!.map(c => + )} + + + + setOpenReorderModal(false)}> + + + ; }; diff --git a/src/components/Measurements/state/chartRange.test.ts b/src/components/Measurements/state/chartRange.test.ts new file mode 100644 index 000000000..7fc7b17b6 --- /dev/null +++ b/src/components/Measurements/state/chartRange.test.ts @@ -0,0 +1,42 @@ +import { act, renderHook } from '@testing-library/react'; +import { DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range"; +import { loadChartRange, resetChartRange, setChartRange, useChartRange } from "./chartRange"; + +describe('chartRange store', () => { + + afterEach(() => { + resetChartRange(); + }); + + test('starts at the default the screens used to seed themselves with', () => { + const { result } = renderHook(() => useChartRange()); + + expect(result.current).toBe(DEFAULT_CHART_RANGE); + }); + + test('a pick is what every watcher reads afterwards', () => { + // Two hooks stand in for two screens: the overview and the detail + // reached from it read the same store + const first = renderHook(() => useChartRange()); + const second = renderHook(() => useChartRange()); + + act(() => setChartRange('lastWeek')); + + expect(first.result.current).toBe('lastWeek'); + expect(second.result.current).toBe('lastWeek'); + }); + + test('a pick is persisted, so the next page load starts from it', () => { + // Deliberately not the default, or the test would pass without storing + act(() => setChartRange('lastYear')); + + // What the module reads when a full page load re-imports it + expect(loadChartRange()).toBe('lastYear'); + }); + + test('a stored value this release does not know falls back to the default', () => { + window.localStorage.setItem('wgerChartRange', 'lastDecade'); + + expect(loadChartRange()).toBe(DEFAULT_CHART_RANGE); + }); +}); diff --git a/src/components/Measurements/state/chartRange.ts b/src/components/Measurements/state/chartRange.ts new file mode 100644 index 000000000..25b3a3286 --- /dev/null +++ b/src/components/Measurements/state/chartRange.ts @@ -0,0 +1,65 @@ +import { useSyncExternalStore } from 'react'; + +import { CHART_RANGES, ChartRange, DEFAULT_CHART_RANGE } from "@/components/Measurements/charts/range"; + +/** + * The chart range shared by the measurement screens (category overview, + * category detail, body weight): a pick follows the user through them + * instead of every screen starting over at its own default. The counterpart + * of the flutter app's ChartRangeSetting provider. + * + * Backed by localStorage, not just module state: embedded in Django pages, + * every navigation is a full page load that starts the components over, so + * memory alone would forget the pick right when it matters. + */ +const STORAGE_KEY = 'wgerChartRange'; + +/** + * The stored pick, or the default: a value this release does not know (or a + * blocked storage) must never break the screens over a display preference. + */ +export const loadChartRange = (): ChartRange => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + + return (CHART_RANGES as readonly string[]).includes(stored ?? '') + ? stored as ChartRange + : DEFAULT_CHART_RANGE; + } catch { + return DEFAULT_CHART_RANGE; + } +}; + +let currentRange: ChartRange = loadChartRange(); +const listeners = new Set<() => void>(); + +export const setChartRange = (range: ChartRange) => { + currentRange = range; + try { + window.localStorage.setItem(STORAGE_KEY, range); + } catch { + // Storage full or blocked: the pick still applies for this page load + } + listeners.forEach(listener => listener()); +}; + +/** Back to the default, so one test's pick does not leak into the next */ +export const resetChartRange = () => { + try { + window.localStorage.removeItem(STORAGE_KEY); + } catch { + // See setChartRange + } + currentRange = DEFAULT_CHART_RANGE; + listeners.forEach(listener => listener()); +}; + +const subscribe = (listener: () => void) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; +}; + +export const useChartRange = (): ChartRange => useSyncExternalStore(subscribe, () => currentRange); diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx new file mode 100644 index 000000000..624733a27 --- /dev/null +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.test.tsx @@ -0,0 +1,260 @@ +import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries"; +import { CategoryDetailDataGrid } from "@/components/Measurements/widgets/CategoryDetailDataGrid"; +import { testQueryClient } from "@/tests/queryClient"; +import { makeWeightEntry, testBodyWeightCategory } from "@/tests/weight/testData"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, within } from '@testing-library/react'; +import userEvent from "@testing-library/user-event"; +import React from 'react'; +import type { Mock } from 'vitest'; + +vi.mock("@/components/Measurements/queries"); + +const CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000001'; +const USER_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000001'; +const SYNCED_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000002'; +const NEXT_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000003'; +const OLDEST_ENTRY_UUID = 'dddddddd-dddd-dddd-dddd-000000000004'; + +describe('CategoryDetailDataGrid', () => { + + beforeEach(() => { + (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined) + })); + (useDeleteMeasurementEntryQuery as Mock).mockImplementation(() => ({ + mutate: vi.fn(), + mutateAsync: vi.fn().mockResolvedValue(undefined) + })); + }); + + test('entries synced from a health app offer no edit or delete actions', async () => { + const category = new MeasurementCategory(CATEGORY_UUID, 'Biceps', 'cm'); + const entries = [ + new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 10, '', 'user'), + new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 12, '', 'apple'), + ]; + + render( + + + + ); + await screen.findByText('10'); + + const userRow = document.querySelector(`[data-id="${USER_ENTRY_UUID}"]`) as HTMLElement; + const syncedRow = document.querySelector(`[data-id="${SYNCED_ENTRY_UUID}"]`) as HTMLElement; + + expect(within(userRow).getByRole('menuitem', { name: /edit/i })).toBeInTheDocument(); + expect(within(userRow).getByRole('menuitem', { name: /delete/i })).toBeInTheDocument(); + + expect(within(syncedRow).queryByRole('menuitem', { name: /edit/i })).not.toBeInTheDocument(); + expect(within(syncedRow).queryByRole('menuitem', { name: /delete/i })).not.toBeInTheDocument(); + expect(within(syncedRow).getByRole('menuitem', { name: 'syncedEntryInfo' })).toBeInTheDocument(); + }); + + test('a page measures its difference columns against the entries outside it', async () => { + const category = new MeasurementCategory(CATEGORY_UUID, 'Biceps', 'cm'); + const page = [ + new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 3), 12, ''), + new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 11, '', 'apple'), + ]; + // The entry the page ends before, and the oldest one of the range + const neighbours = [ + new MeasurementEntry(NEXT_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 10, ''), + new MeasurementEntry(OLDEST_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 0, 1), 5, ''), + ]; + + render( + + + + ); + await screen.findByText('12 cm'); + + const cell = (id: string, field: string) => + document.querySelector(`[data-id="${id}"] [data-field="${field}"]`)?.textContent; + + // The last row of the page differs from the entry after it, and both + // rows count from the oldest one there is + expect(cell(SYNCED_ENTRY_UUID, 'change')).toBe('1'); + expect(cell(SYNCED_ENTRY_UUID, 'totalChange')).toBe('6'); + expect(cell(USER_ENTRY_UUID, 'totalChange')).toBe('7'); + + // Neither of them is a row of its own + expect(document.querySelector(`[data-id="${NEXT_ENTRY_UUID}"]`)).toBeNull(); + expect(document.querySelector(`[data-id="${OLDEST_ENTRY_UUID}"]`)).toBeNull(); + }); + + test('a duration reads h:mm, in the value and in the change columns', async () => { + const category = new MeasurementCategory(CATEGORY_UUID, 'Total sleep', 'min'); + const entries = [ + new MeasurementEntry(USER_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 1), 480, '', 'user'), + new MeasurementEntry(SYNCED_ENTRY_UUID, CATEGORY_UUID, new Date(2023, 1, 2), 437, '', 'apple'), + ]; + + render( + + + + ); + await screen.findByText('8:00 h'); + + const laterRow = document.querySelector(`[data-id="${SYNCED_ENTRY_UUID}"]`) as HTMLElement; + const cell = (field: string) => laterRow.querySelector(`[data-field="${field}"]`)!.textContent; + expect(cell('value')).toBe('7:17 h'); + expect(cell('change')).toBe('-0:43'); + expect(cell('totalChange')).toBe('-0:43'); + }); + + /* + * Body weight is the one category whose entries can be stored in a unit + * other than the one they are shown in + */ + describe('with a display unit', () => { + + const ENTRY_UUID_1 = 'dddddddd-dddd-dddd-dddd-000000000011'; + const ENTRY_UUID_2 = 'dddddddd-dddd-dddd-dddd-000000000012'; + + const renderGrid = (entries: MeasurementEntry[]) => render( + + + + ); + + // the grid formats numbers with the runner's locale, normalize the + // decimal separator + const cellText = (row: HTMLElement, field: string) => + row.querySelector(`[data-field="${field}"]`)!.textContent!.replace(',', '.'); + + test('converts mixed units to the display unit, including the aggregations', async () => { + // 90 lb = 40.82 kg, entered a day after the 80 kg entry + renderGrid([ + makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' }), + makeWeightEntry(new Date('2021/12/11'), 90, { id: ENTRY_UUID_2, unit: 'lb' }), + ]); + await screen.findByText('80 kg'); + + const lbRow = document.querySelector(`[data-id="${ENTRY_UUID_2}"]`) as HTMLElement; + expect(cellText(lbRow, 'value')).toBe('40.82 kg'); + // change and totalChange are computed on the converted values + expect(cellText(lbRow, 'totalChange')).toBe('-39.18'); + }); + + test('saving a row without editing the value keeps the stored value and unit', async () => { + const user = userEvent.setup(); + const mutateEditMock = vi.fn(); + (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ + mutate: mutateEditMock, + mutateAsync: mutateEditMock + })); + // stored as 90 lb, displayed as 40.82 kg + renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]); + + await screen.findByText(/40[.,]82/); + await user.click(screen.getByRole('menuitem', { name: /edit/i })); + await user.click(screen.getByRole('menuitem', { name: /save/i })); + + // the displayed conversion must not be written back to the entry + expect(mutateEditMock).toHaveBeenCalled(); + const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry; + expect(Number(submitted.value)).toBe(90); + expect(submitted.extraData.unit).toBe('lb'); + }); + + test('editing the value cell stamps the display unit', async () => { + const user = userEvent.setup(); + const mutateEditMock = vi.fn(); + (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ + mutate: mutateEditMock, + mutateAsync: mutateEditMock + })); + renderGrid([makeWeightEntry(new Date('2021/12/10'), 90, { id: ENTRY_UUID_1, unit: 'lb' })]); + + await screen.findByText(/40[.,]82/); + await user.click(screen.getByRole('menuitem', { name: /edit/i })); + + // the typed value is in the unit the grid shows, not the one the + // entry was stored in + const valueInput = screen.getByRole('spinbutton'); + await user.clear(valueInput); + await user.type(valueInput, '41'); + await user.click(screen.getByRole('menuitem', { name: /save/i })); + + expect(mutateEditMock).toHaveBeenCalled(); + const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry; + expect(Number(submitted.value)).toBe(41); + expect(submitted.extraData.unit).toBe('kg'); + }); + + test('implausible inline edits are rejected and the row stays editable', async () => { + const user = userEvent.setup(); + const mutateEditMock = vi.fn(); + (useEditMeasurementEntryQuery as Mock).mockImplementation(() => ({ + mutate: mutateEditMock, + mutateAsync: mutateEditMock + })); + renderGrid([makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' })]); + + await screen.findByText('80 kg'); + await user.click(screen.getByRole('menuitem', { name: /edit/i })); + + const valueInput = screen.getByRole('spinbutton'); + await user.clear(valueInput); + await user.type(valueInput, '5000'); + await user.click(screen.getByRole('menuitem', { name: /save/i })); + + // nothing is saved, the error shows up and the cell stays editable + expect(mutateEditMock).not.toHaveBeenCalled(); + expect(await screen.findByText('forms.maxValue')).toBeInTheDocument(); + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + + // correcting the value saves normally + await user.clear(valueInput); + await user.type(valueInput, '90'); + await user.click(screen.getByRole('menuitem', { name: /save/i })); + expect(mutateEditMock).toHaveBeenCalled(); + const submitted = mutateEditMock.mock.calls[0][0] as MeasurementEntry; + expect(Number(submitted.value)).toBe(90); + }); + + test('an edit the server refuses is shown instead of being kept', async () => { + const user = userEvent.setup(); + const mutateEditMock = vi.fn().mockRejectedValue({ + response: { data: { value: ['Value must be between 20 and 350'] } }, + }); + (useEditMeasurementEntryQuery as Mock).mockImplementation( + () => ({ mutate: vi.fn(), mutateAsync: mutateEditMock }) + ); + renderGrid([makeWeightEntry(new Date('2021/12/10'), 80, { id: ENTRY_UUID_1, unit: 'kg' })]); + + await screen.findByText('80 kg'); + await user.click(screen.getByRole('menuitem', { name: /edit/i })); + + const valueInput = screen.getByRole('spinbutton'); + await user.clear(valueInput); + await user.type(valueInput, '90'); + await user.click(screen.getByRole('menuitem', { name: /save/i })); + + expect(mutateEditMock).toHaveBeenCalled(); + expect( + await screen.findByText('value: Value must be between 20 and 350') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx index c4ff776ab..a7ae77e3b 100644 --- a/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDataGrid.tsx @@ -1,14 +1,17 @@ import { processTimeSeries } from "@/core/lib/timeSeries"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { valueOnly, valueWithUnit } from "@/components/Measurements/charts/format"; +import { limitsFor, MeasurementCategory } from "@/components/Measurements/models/Category"; +import { collectValidationErrors } from "@/core/lib/forms"; import { MeasurementEntry } from "@/components/Measurements/models/Entry"; -import { useDeleteMeasurementsQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries"; +import { useDeleteMeasurementEntryQuery, useEditMeasurementEntryQuery } from "@/components/Measurements/queries"; import { PAGINATION_OPTIONS } from "@/core/lib/consts"; import { luxonDateTimeToLocale } from "@/core/lib/date"; import CancelIcon from "@mui/icons-material/Close"; +import CloudSyncIcon from "@mui/icons-material/CloudSync"; import DeleteIcon from "@mui/icons-material/DeleteOutlined"; import EditIcon from "@mui/icons-material/Edit"; import SaveIcon from "@mui/icons-material/Save"; -import { Box } from "@mui/material"; +import { Box, Snackbar, Tooltip } from "@mui/material"; import { DataGrid, GridActionsCellItem, @@ -17,6 +20,7 @@ import { GridRowEditStopReasons, GridRowId, GridRowModel, + GridPaginationModel, GridRowModes, GridRowModesModel, GridRowsProp, @@ -25,25 +29,75 @@ import { DateTime } from "luxon"; import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -const convertEntriesToObj = (entries: MeasurementEntry[]): GridRowsProp => - processTimeSeries(entries, e => e.value).map((row) => ({ - id: row.entry.id, - date: row.entry.date, - value: row.entry.value, - notes: row.entry.notes, - change: +row.change.toFixed(2), - totalChange: +row.totalChange.toFixed(2), - days: +row.days.toFixed(1), - })); +// Values are read through the unit helper, never off the raw column: a +// category can hold entries in mixed units, and the change columns are +// computed from the converted values +// +// [neighbours] are entries the shown ones are measured against without being +// rows themselves, see the pagination prop. They are always older, so the +// rows stay at the front of the series and the extra ones cut off again +const buildRows = ( + entries: MeasurementEntry[], + neighbours: MeasurementEntry[], + unit: string, + categoryUnit: string, +): GridRowsProp => + processTimeSeries([...entries, ...neighbours], e => e.valueIn(unit, categoryUnit)) + .slice(0, entries.length) + .map((row) => ({ + id: row.entry.id, + date: row.entry.date, + value: row.entry.valueIn(unit, categoryUnit), + notes: row.entry.notes, + isEditable: row.entry.isEditable, + change: +row.change.toFixed(2), + totalChange: +row.totalChange.toFixed(2), + days: +row.days.toFixed(1), + })); -export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) => { +export const CategoryDetailDataGrid = (props: { + category: MeasurementCategory, + /** Rows to show, read by the caller: a category carries no entries itself */ + entries: MeasurementEntry[], + /** + * Unit the values are shown and edited in, the category's own by default. + * Body weight is shown in the profile unit, since its entries can be + * stored in either; an edited value is then stamped with it. + */ + displayUnit?: string, + /** + * Reading one page at a time, for the histories that are too long to hold: + * the grid then shows what it was handed and asks the caller for the next + * page, rather than paging through a list of its own. + * + * [neighbours] are the entries outside the page the difference columns are + * measured against, i.e. the one after the page and the oldest there is. + * Sorting and filtering are off in this mode: both would only reach the + * page in hand, which is not what a sorted table means. + */ + pagination?: { + rowCount: number, + model: GridPaginationModel, + onModelChange: (model: GridPaginationModel) => void, + neighbours: MeasurementEntry[], + isLoading: boolean, + }, +}) => { - const [t] = useTranslation(); - const data: GridRowsProp = convertEntriesToObj(props.category.entries); + const [t, i18n] = useTranslation(); + const entries = props.entries; + const unit = props.displayUnit ?? props.category.unit; + const data: GridRowsProp = buildRows( + entries, + props.pagination?.neighbours ?? [], + unit, + props.category.unit, + ); const updateEntryQuery = useEditMeasurementEntryQuery(); - const deleteEntryQuery = useDeleteMeasurementsQuery(); + const deleteEntryQuery = useDeleteMeasurementEntryQuery(); const [rowModesModel, setRowModesModel] = useState({}); + const [editError, setEditError] = useState(null); const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { @@ -72,22 +126,61 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) }; - const processRowUpdate = async (newRow: GridRowModel) => { + const processRowUpdate = async (newRow: GridRowModel, oldRow: GridRowModel) => { const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date); - updateEntryQuery.mutate(new MeasurementEntry( - newRow.id, - props.category.id!, - date, - newRow.value, - newRow.notes, - )); + const entry = entries.find(e => e.id === newRow.id); + if (entry === undefined) { + throw new Error(`unknown entry id ${newRow.id}`); + } + + // The grid shows the value converted into the display unit. Re-saving + // that conversion would silently overwrite the entry's stored value and + // unit, so both only change when the value cell was edited + if (Number(newRow.value) === Number(oldRow.value)) { + await updateEntryQuery.mutateAsync(MeasurementEntry.clone(entry, { + date: date, + notes: newRow.notes, + })); + + return { ...newRow, isNew: false }; + } + + // A value outside the bounds of the metric type is refused by the API, + // so the row is not saved with one either; throwing keeps it in edit + // mode so it can be corrected + const value = Number(newRow.value); + const { min, max } = limitsFor(props.category.metricType, unit); + if (isNaN(value) || value < min) { + throw new Error(t('forms.minValue', { value: `${min} ${unit}` })); + } + if (value > max) { + throw new Error(t('forms.maxValue', { value: `${max} ${unit}` })); + } + + await updateEntryQuery.mutateAsync(MeasurementEntry.clone(entry, { + date: date, + value: value, + notes: newRow.notes, + // The typed value is in the unit the grid shows, which for body + // weight is not necessarily the one it was stored in + ...(props.displayUnit ? { extraData: entry.extraDataInUnit(unit) } : {}), + })); return { ...newRow, isNew: false }; }; + // Both the checks above and a write the server refused end up here, and the + // grid puts the row back to what it was const onProcessRowUpdateError = (error: unknown) => { - console.error(error); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const response = (error as any)?.response?.data; + const validationErrors = collectValidationErrors(response); + if (validationErrors.length > 0) { + setEditError(validationErrors.join(', ')); + return; + } + setEditError(error instanceof Error ? error.message : String(error)); }; const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { @@ -98,14 +191,13 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) { field: 'value', headerName: t('value'), - width: 80, + type: 'number', + // wide enough for a grouped number plus its unit + width: 120, editable: true, - valueFormatter: (value?: number) => { - if (value == null) { - return ''; - } - return value + props.category.unit; - }, + valueFormatter: (value?: number) => value == null + ? '' + : valueWithUnit(value, unit, i18n.language), }, { field: 'date', @@ -126,6 +218,10 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) type: 'number', width: 120, editable: false, + // a duration delta reads h:mm like the value it changes + valueFormatter: (value?: number) => value == null + ? '' + : valueOnly(value, unit, i18n.language), }, { field: 'totalChange', @@ -133,6 +229,9 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) type: 'number', width: 140, editable: false, + valueFormatter: (value?: number) => value == null + ? '' + : valueOnly(value, unit, i18n.language), }, { field: 'days', @@ -154,7 +253,24 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) headerName: t('actions'), width: 100, cellClassName: 'actions', - getActions: ({ id }) => { + getActions: ({ id, row }) => { + // synced entries are managed by the source app, offer no actions + if (!row.isEditable) { + return [ + } + label={t('syncedEntryInfo')} + color="inherit" + // a badge, not a button: disabled drops the click + // affordance, the style keeps hover events flowing + // so the tooltip still works + disabled + style={{ pointerEvents: 'auto', cursor: 'default' }} + />, + ]; + } + const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; if (isInEditMode) { @@ -162,13 +278,13 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) } - label="Save" + label={t('save')} onClick={handleSaveClick(id)} />, } - label="Cancel" + label={t('cancel')} className="textPrimary" onClick={handleCancelClick(id)} color="inherit" @@ -180,7 +296,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) } - label="Edit" + label={t('edit')} className="textPrimary" onClick={handleEditClick(id)} color="inherit" @@ -188,7 +304,7 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) } - label="Delete" + label={t('delete')} onClick={handleDeleteClick(id)} color="inherit" />, @@ -198,25 +314,36 @@ export const CategoryDetailDataGrid = (props: { category: MeasurementCategory }) ]; - return + return <> ({ ...column, sortable: false, filterable: false }))} + initialState={props.pagination === undefined + ? { pagination: { paginationModel: { pageSize: PAGINATION_OPTIONS.pageSize } } } + : undefined} + paginationMode={props.pagination === undefined ? 'client' : 'server'} + rowCount={props.pagination?.rowCount} + paginationModel={props.pagination?.model} + onPaginationModelChange={props.pagination?.onModelChange} + loading={props.pagination?.isLoading} pageSizeOptions={PAGINATION_OPTIONS.pageSizeOptions} disableRowSelectionOnClick + isCellEditable={(params) => params.row.isEditable} rowModesModel={rowModesModel} onRowModesModelChange={handleRowModesModelChange} onRowEditStop={handleRowEditStop} processRowUpdate={processRowUpdate} onProcessRowUpdateError={onProcessRowUpdateError} /> - ; + + setEditError(null)} + message={editError} + /> + ; }; \ No newline at end of file diff --git a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx index 7ed1cb5a4..180c2b7d2 100644 --- a/src/components/Measurements/widgets/CategoryDetailDropdown.tsx +++ b/src/components/Measurements/widgets/CategoryDetailDropdown.tsx @@ -1,8 +1,9 @@ import MenuIcon from '@mui/icons-material/Menu'; import { Button, Menu, MenuItem } from "@mui/material"; import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal"; +import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; -import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { categoryDisplayName, MeasurementCategory } from "@/components/Measurements/models/Category"; import { useDeleteMeasurementCategoryQuery } from "@/components/Measurements/queries"; import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm"; import React from "react"; @@ -52,6 +53,7 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory }) return (
+ @@ -74,8 +76,8 @@ export const CategoryDetailDropdown = (props: { category: MeasurementCategory }) { + const group = MeasurementCategory.clone(TEST_GROUP_CATEGORY); + group.children = [new MeasurementCategory( + childId, + 'Systolic', + 'mmHg', + 'blood_pressure', + false, + TEST_GROUP_CATEGORY.id, + )]; + + return group; +}; describe("Test the CategoryForm component", () => { const queryClient = new QueryClient(); @@ -25,6 +55,14 @@ describe("Test the CategoryForm component", () => { (useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({ mutate: mutate })); + // the two measurement categories hold entries, the group is free + (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ + data: [ + { category: TEST_MEASUREMENT_CATEGORY_1, hasEntries: true }, + { category: TEST_MEASUREMENT_CATEGORY_2, hasEntries: true }, + { category: TEST_GROUP_CATEGORY, hasEntries: false }, + ] + })); }); test('Passing an existing entry renders its values in the form', () => { @@ -67,7 +105,7 @@ describe("Test the CategoryForm component", () => { expect(mutate).toHaveBeenCalledWith(MeasurementCategory.clone( TEST_MEASUREMENT_CATEGORY_2, { name: "a better name", unit: 'K/m2' } - )); + ), expect.anything()); }); test('Creating a new category', async () => { @@ -88,7 +126,328 @@ describe("Test the CategoryForm component", () => { // Assert await user.click(submitButton); - expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm')); + expect(mutate).toHaveBeenCalledWith(new MeasurementCategory(null, 'calves', 'cm'), expect.anything()); + }); + + test('The metric type is not offered', () => { + // It is picked when the category is created and immutable from then + // on, the server refuses a change + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByRole('combobox', { name: 'measurements.metricType' })).toBeNull(); + }); + + test('A typed category has neither a name nor a unit field', () => { + // Arrange: both come from the metric type, which is also what is shown + const typed = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1, { + metricType: 'heart_rate', + }); + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByLabelText('name')).toBeNull(); + expect(screen.queryByLabelText('unit')).toBeNull(); + expect(screen.getByRole('combobox', { name: 'measurements.chartType' })).toBeInTheDocument(); + }); + + test('Creating a category inside a group', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.type(await screen.findByLabelText('name'), 'Something'); + await user.type(await screen.findByLabelText('unit'), 'mmHg'); + + await user.click(screen.getByRole('combobox', { name: 'measurements.partOfGroup' })); + await user.click(screen.getByRole('option', { name: 'Blood pressure' })); + + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert + expect(mutate).toHaveBeenCalledWith(new MeasurementCategory( + null, + 'Something', + 'mmHg', + 'custom', + false, + TEST_GROUP_CATEGORY.id, + ), expect.anything()); + }); + + test('A typed category cannot be put into a group', () => { + // Arrange + const typed = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1, { + metricType: 'steps', + }); + + // Act + render( + + + + ); + + // Assert - the group selector is gone, a typed category stays top-level + expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull(); + }); + + test('A group is not offered as a parent, it only holds its own components', async () => { + // Arrange + (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ + data: [{ + category: MeasurementCategory.clone(TEST_GROUP_CATEGORY, { metricType: 'blood_pressure' }), + hasEntries: false, + }] + })); + + // Act + render( + + + + ); + + // Assert - no eligible parent left, so the selector is not rendered + expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull(); + }); + + test('Only entry-free top-level categories are offered as parents', async () => { + // Arrange + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.click(screen.getByRole('combobox', { name: 'measurements.partOfGroup' })); + + // Assert - the categories with entries are not eligible + expect(screen.getByRole('option', { name: 'Blood pressure' })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: 'Biceps' })).toBeNull(); + expect(screen.queryByRole('option', { name: 'Body fat' })).toBeNull(); + }); + + test('The group dropdown is hidden for a category with children', () => { + // Arrange: an eligible parent exists, so the dropdown is only absent + // because the edited category is a group itself + const group = groupWithComponent('cccccccc-cccc-cccc-cccc-000000000043'); + const candidate = new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000045', + 'Waist', + 'cm', + ); + (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ + data: [ + { category: group, hasEntries: false }, + { category: candidate, hasEntries: false }, + ] + })); + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByRole('combobox', { name: 'measurements.partOfGroup' })).toBeNull(); + }); + + test('A rejected write keeps the form open and is shown', async () => { + + // Arrange: the mutation reports the failure, as react-query does + const user = userEvent.setup(); + const closeFn = vi.fn(); + (useAddMeasurementCategoryQuery as Mock).mockImplementation(() => ({ + mutate: mutate, + isError: true, + error: { message: 'Request failed', response: { data: { name: ['Already exists'] } } }, + })); + + // Act + render( + + + + ); + await user.type(await screen.findByLabelText('name'), 'calves'); + await user.type(await screen.findByLabelText('unit'), 'cm'); + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert: the form only closes from the success callback, which a + // failed mutation never runs + expect(closeFn).not.toHaveBeenCalled(); + expect(screen.getByText('name: Already exists')).toBeInTheDocument(); + }); + + test('A category with children gets no chart type picker', () => { + + // Arrange: its chart follows from what its components are to each + // other, which is what groupChart decides; a pick would have no effect + const group = groupWithComponent('cccccccc-cccc-cccc-cccc-000000000044'); + (useCategoryEntryFlagsQuery as Mock).mockImplementation(() => ({ + data: [{ category: group, hasEntries: false }] + })); + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByRole('combobox', { name: 'measurements.chartType' })).toBeNull(); + }); + + test('A leaf category gets the chart type picker', () => { + + // Act + render( + + + + ); + + // Assert + expect(screen.getByRole('combobox', { name: 'measurements.chartType' })) + .toBeInTheDocument(); + }); + + test('A leaf category gets the line chart settings', () => { + + // Act + render( + + + + ); + + // Assert + expect(screen.getByRole('combobox', { name: 'measurements.chartTrend' })) + .toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: 'measurements.chartAverageWindow' })) + .toBeInTheDocument(); + }); + + test('A summed type has no line to configure', () => { + // Its chart is one bar per day, which has neither a trend nor an average + const steps = new MeasurementCategory( + 'cccccccc-cccc-cccc-cccc-000000000045', 'Steps', 'steps', 'steps', + ); + + // Act + render( + + + + ); + + // Assert + expect(screen.queryByRole('combobox', { name: 'measurements.chartTrend' })).toBeNull(); + expect(screen.queryByRole('combobox', { name: 'measurements.chartAverageWindow' })) + .toBeNull(); + }); + + test('The line settings are disabled for a chart without a line', () => { + // Kept rather than hidden: switching the chart type back applies them + // again, and a field that vanishes takes the reason with it + const category = MeasurementCategory.clone( + TEST_MEASUREMENT_CATEGORY_1, { chartType: 'delta' }, + ); + + // Act + render( + + + + ); + + // Assert + expect(screen.getByRole('combobox', { name: 'measurements.chartTrend' })) + .toHaveAttribute('aria-disabled', 'true'); + expect(screen.getByRole('combobox', { name: 'measurements.chartAverageWindow' })) + .toHaveAttribute('aria-disabled', 'true'); + }); + + test('Picking a trend keeps the settings of another client', async () => { + // Arrange + const user = userEvent.setup(); + const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1); + category.chartConfig = { goal_line: 75 }; + + // Act + render( + + + + ); + await user.click(screen.getByRole('combobox', { name: 'measurements.chartTrend' })); + await user.click(screen.getByRole('option', { name: 'measurements.trends.reactive' })); + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert + expect(mutate.mock.calls[0][0].chartConfig) + .toEqual({ goal_line: 75, trend: 'reactive' }); + }); + + test('A rename keeps a setting this release does not know', async () => { + // 'glacial' reads as the default here, and writing that default back + // would drop it. Only a setting the user changed is written. + const user = userEvent.setup(); + const category = MeasurementCategory.clone(TEST_MEASUREMENT_CATEGORY_1); + category.chartConfig = { trend: 'glacial' as TrendCharacter }; + + // Act + render( + + + + ); + const nameInput = await screen.findByLabelText('name'); + await user.clear(nameInput); + await user.type(nameInput, 'a better name'); + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert + expect(mutate.mock.calls[0][0].chartConfig).toEqual({ trend: 'glacial' }); + }); + + test('An untouched category keeps its empty configuration', async () => { + // Renaming a category must not fill its config with the defaults + const user = userEvent.setup(); + + // Act + render( + + + + ); + await user.click(screen.getByRole('button', { name: 'submit' })); + + // Assert + expect(mutate.mock.calls[0][0].chartConfig).toEqual({}); }); test('The name field error state follows validity, not just touched', async () => { diff --git a/src/components/Measurements/widgets/CategoryForm.tsx b/src/components/Measurements/widgets/CategoryForm.tsx index 48d50d38d..b556adb6a 100644 --- a/src/components/Measurements/widgets/CategoryForm.tsx +++ b/src/components/Measurements/widgets/CategoryForm.tsx @@ -1,6 +1,23 @@ -import { MeasurementCategory } from "@/components/Measurements/models/Category"; -import { useAddMeasurementCategoryQuery, useEditMeasurementCategoryQuery } from "@/components/Measurements/queries"; -import { Button, Stack, TextField } from "@mui/material"; +import { + availableChartTypes, + AVERAGE_WINDOWS, + averageWindowOf, + ChartType, + isGroupMetricType, + MeasurementCategory, + MetricType, + resolveChartType, + TREND_CHARACTERS, + TrendCharacter, + trendOf +} from "@/components/Measurements/models/Category"; +import { + useAddMeasurementCategoryQuery, + useCategoryEntryFlagsQuery, + useEditMeasurementCategoryQuery +} from "@/components/Measurements/queries"; +import { Button, MenuItem, Stack, TextField } from "@mui/material"; +import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { Form, Formik } from "formik"; import React from 'react'; import { useTranslation } from "react-i18next"; @@ -11,11 +28,52 @@ interface CategoryFormProps { closeFn?: () => void, } +/** What the chart type picker offers: no override, plus what the type allows */ +const chartTypeChoices = (metricType: MetricType): ChartType[] => + ['auto', ...availableChartTypes(metricType)]; + +/** + * Whether the category can be drawn as a line at all, which is what the trend + * and the average settings belong to. A summed type is drawn as bars whatever + * is picked, and a group by what its components are. + */ +const canDrawLine = (metricType: MetricType, hasChildren: boolean): boolean => + !hasChildren && availableChartTypes(metricType).includes('line'); + +/** Whether it is drawn as one right now, i.e. whether those settings apply */ +const drawsLine = (values: { metricType: MetricType, chartType: ChartType }): boolean => + resolveChartType(values.metricType, values.chartType) === 'line'; + export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { const [t] = useTranslation(); const useAddCategoryQuery = useAddMeasurementCategoryQuery(); const useEditCategoryQuery = useEditMeasurementCategoryQuery(category?.id || ''); + // The categories are read only to offer the groups this one can join, of + // which an entry-free one is one, so that is all that is asked of them + const categoryQuery = useCategoryEntryFlagsQuery(); + + // Name and unit belong to the user only for a free-form category. A typed + // one takes both from its metric type, which is also what is shown for it + const isCustom = (category?.metricType ?? 'custom') === 'custom'; + + // Asked of the category itself, which carries its components: the query + // returns the top-level ones only, so looking for a row whose parent is + // this one never finds anything + const hasChildren = category?.isGroup ?? false; + // Multi-value groups, e.g. blood pressure. Mirrors the server rules: only + // top-level, entry-free categories can be parents, a category that already + // has children cannot be nested, a typed category stays top-level, and a + // group takes only its own components. The current parent always stays + // selectable so editing something else doesn't silently drop it. + const parentCandidates = (categoryQuery.data ?? []) + .filter(({ category: c, hasEntries }) => + c.parentId === null + && c.id !== category?.id + && !isGroupMetricType(c.metricType) + && (!hasEntries || c.id === category?.parentId) + ) + .map(({ category: c }) => c); // Match the backend column limits. We do NOT enforce a minimum length: // many users have legitimate 1-2 char names (e.g. CJK abbreviations // like 体重 / 体脂), and the backend allows them. @@ -31,41 +89,87 @@ export const CategoryForm = ({ category, closeFn }: CategoryFormProps) => { }); + // What the two chart settings were seeded with, which is also what decides + // whether the user changed them + const seededTrend = trendOf(category?.chartConfig ?? {}); + const seededWindow = averageWindowOf(category?.chartConfig ?? {}); + return ( { + const parentId = values.parentId === "" ? null : values.parentId; + + /** + * Applies the chart settings the user actually changed. + * + * Only a changed one is written, so renaming a category leaves + * its configuration exactly as it was: a value another client + * wrote and this one does not know reads as the default here, + * and writing that default back would drop it. + */ + const withSettings = (target: MeasurementCategory): MeasurementCategory => { + let out = target; + if (values.trend !== seededTrend) { + out = out.withChartSetting('trend', values.trend); + } + if (values.averageWindow !== seededWindow) { + out = out.withChartSetting('average_window', values.averageWindow); + } + + return out; + }; + + // The form closes only once the server took the category, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; // Edit existing category if (category) { - useEditCategoryQuery.mutate(MeasurementCategory.clone(category, values)); + useEditCategoryQuery.mutate(withSettings(MeasurementCategory.clone(category, { + name: values.name, + unit: values.unit, + metricType: values.metricType, + chartType: values.chartType, + parentId: parentId, + })), options); } else { - useAddCategoryQuery.mutate(new MeasurementCategory(null, values.name, values.unit)); - } - - // if closeFn is defined, close the modal (this form does not have to - // be displayed in a modal) - if (closeFn) { - closeFn(); + useAddCategoryQuery.mutate(withSettings(new MeasurementCategory( + null, + values.name, + values.unit, + values.metricType, + false, + parentId, + 0, + values.chartType, + )), options); } }} > {formik => (
- - } + {isCustom && { : t('measurements.unitFormHelpText') } {...formik.getFieldProps('unit')} - /> + />} + {/* The metric type is picked when the category is + * created (see NewCategoryPicker) and fixed from then + * on: the key of a typed category is derived from it, + * and the server refuses a change + */} + {/* + * Only the shapes that are a matter of taste are + * offered, and only those the metric type can be drawn + * as. A group gets no picker at all, its chart follows + * from what its components are to each other; a + * category with children is one whatever its metric + * type says, which is also how the charts decide + */} + {!hasChildren && availableChartTypes(formik.values.metricType).length > 0 && + + {chartTypeChoices(formik.values.metricType).map(chartType => + + {t(`measurements.chartTypes.${chartType}`)} + + )} + + } + {/* The trend line and the moving average are parts of + * the line chart: a category that can never be drawn + * as one is not offered them at all, and one that is + * currently drawn as something else keeps its + * settings but cannot change them + */} + {canDrawLine(formik.values.metricType, hasChildren) && <> + + {TREND_CHARACTERS.map((trend: TrendCharacter) => + + {t(`measurements.trends.${trend}`)} + + )} + + + {AVERAGE_WINDOWS.map(days => + + {t('measurements.chartAverageWindowDays', { count: days })} + + )} + + } + {!hasChildren && formik.values.metricType === 'custom' + && parentCandidates.length > 0 && + + {t('measurements.noGroup')} + {parentCandidates.map(candidate => + + {candidate.name} + + )} + + } + + + +
+ )} +
) + ); +}; + +interface GroupEntryFormProps { + group: MeasurementCategory, + closeFn?: () => void, +} + +/** + * Adds one reading for every component of a multi-value group (e.g. systolic + * and diastolic blood pressure): date and time are shared, one value field + * per child category + */ +export const GroupEntryForm = ({ group, closeFn }: GroupEntryFormProps) => { + + const [t, i18n] = useTranslation(); + const addGroupEntriesQuery = useAddGroupEntriesQuery(); + + const [dateValue, setDateValue] = React.useState(DateTime.now()); + + const validationSchema = yup.object({ + date: yup + .date() + .required(t('forms.fieldRequired')), + // Each component is bounded by its own type: systolic and diastolic + // do not share a range + values: yup.object(Object.fromEntries(group.children.map(child => { + const limits = limitsFor(child.metricType, child.unit); + + return [ + child.id!, + yup + .number() + .required(t('forms.fieldRequired')) + .min(limits.min, t('forms.minValue', { value: String(limits.min) })) + .max(limits.max, t('forms.maxValue', { value: String(limits.max) })), + ]; + }))), + }); + + return ( + ( [child.id!, ''])), + }} + validationSchema={validationSchema} + onSubmit={async (values) => { + addGroupEntriesQuery.mutate( + group.children.map(child => new MeasurementEntry( + null, + child.id!, + values.date, + Number(values.values[child.id!]), + '', + )), + { onSuccess: () => closeFn?.() } + ); + }} + > + {formik => ( +
+ + + { + if (newValue) { + formik.setFieldValue('date', newValue.toJSDate()); + } + setDateValue(newValue); + }} + /> + + {group.children.map(child => + + )} + + + +
+ )} +
) + ); +}; diff --git a/src/components/Measurements/widgets/WeightTableDashboard.test.tsx b/src/components/Measurements/widgets/WeightTableDashboard.test.tsx new file mode 100644 index 000000000..92d2c57cd --- /dev/null +++ b/src/components/Measurements/widgets/WeightTableDashboard.test.tsx @@ -0,0 +1,38 @@ +import { MeasurementEntry } from "@/components/Measurements"; +import { makeWeightEntry } from "@/tests/weight/testData"; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { WeightTableDashboard } from '@/components/Measurements/widgets/WeightTableDashboard'; + +describe("Body weight test", () => { + test('renders without crashing', async () => { + + const weightsData: MeasurementEntry[] = [ + makeWeightEntry(new Date('2021/12/10'), 80, { id: 'd-1' }), + makeWeightEntry(new Date('2021/12/20'), 90, { id: 'd-2' }), + ]; + + // since I used context api to provide state, also need it here + render(); + + // Both weights are found in th document + const weightRow = await screen.findByText('80'); + expect(weightRow).toBeInTheDocument(); + + const weightRow2 = await screen.findByText("90"); + expect(weightRow2).toBeInTheDocument(); + }); + + test('converts entries stored in other units to the display unit', async () => { + + const weightsData: MeasurementEntry[] = [ + makeWeightEntry(new Date('2021/12/10'), 80, { id: 'd-1', unit: 'kg' }), + makeWeightEntry(new Date('2021/12/20'), 90, { id: 'd-2', unit: 'lb' }), + ]; + + render(); + + expect(await screen.findByText('80')).toBeInTheDocument(); + expect(await screen.findByText('40.82')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx b/src/components/Measurements/widgets/WeightTableDashboard.tsx similarity index 75% rename from src/components/Weight/widgets/TableDashboard/TableDashboard.tsx rename to src/components/Measurements/widgets/WeightTableDashboard.tsx index c915dcf45..7e3080448 100644 --- a/src/components/Weight/widgets/TableDashboard/TableDashboard.tsx +++ b/src/components/Measurements/widgets/WeightTableDashboard.tsx @@ -1,9 +1,10 @@ import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; import { styled } from '@mui/material/styles'; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; import React from 'react'; import { useTranslation } from "react-i18next"; import { dateTimeToLocale } from "@/core/lib/date"; +import { WeightUnit } from "@/core/lib/weightUnit"; const PREFIX = 'WeightTableDashboard'; @@ -25,10 +26,12 @@ const Root = styled('div')(() => { export interface WeightTableProps { - weights: WeightEntry[]; + weights: MeasurementEntry[]; + unit: WeightUnit; + categoryUnit: string; } -export const WeightTableDashboard = ({ weights }: WeightTableProps) => { +export const WeightTableDashboard = ({ weights, unit, categoryUnit }: WeightTableProps) => { const [t] = useTranslation(); const WEIGHT_ENTRIES_TO_SHOW = 5; @@ -42,14 +45,14 @@ export const WeightTableDashboard = ({ weights }: WeightTableProps) => { {t('date')} - {t('weight')} + {`${t('weight')} (${t(`server.${unit}`)})`} {filteredWeight.map((row) => ( {dateTimeToLocale(row.date)} - {row.weight} + {row.valueIn(unit, categoryUnit)} ))} diff --git a/src/components/Measurements/widgets/chartFrames.tsx b/src/components/Measurements/widgets/chartFrames.tsx new file mode 100644 index 000000000..aa844c0d3 --- /dev/null +++ b/src/components/Measurements/widgets/chartFrames.tsx @@ -0,0 +1,78 @@ +import { Box, Paper, useTheme } from "@mui/material"; +import { + dateTick, + durationAxis, + spansYears, + valueWithUnit +} from "@/components/Measurements/charts/format"; +import { dateToLocale } from "@/core/lib/date"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from "recharts"; + +export interface TooltipProps { + active?: boolean, + /** The hovered entries, read by each tooltip the way its own chart wrote them */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload?: any, + label?: string, +} + +/** What every tooltip here shares: the day, and under it what was measured on it */ +export const TooltipFrame = (props: { label?: string, children: React.ReactNode }) => + +

{dateToLocale(new Date(Number(props.label)))}

+ {props.children} +
; + +/** + * The frame every bar chart here is drawn in: the grid, the date axis and the + * value axis, which only differ in the unit they read. The bars themselves are + * the caller's, they are what each chart is about. + */ +export const BarChartFrame = (props: { + data: { date: number }[], + unit: string, + /** Where the value axis starts for a unit that brings no axis of its own */ + domainStart: 0 | 'auto', + axis: ReturnType, + tooltip: React.ReactElement, + ariaLabel?: string, + children: React.ReactNode, +}) => { + const [, i18n] = useTranslation(); + const theme = useTheme(); + + return + {/* + * Bar width follows from how many bars share the width: recharts + * sizes them to the band, the gap (taken off both sides, so a bar + * keeps 70% of its band) holds neighbours apart, and the maximum + * keeps a handful of bars from becoming blocks + */} + + + + valueWithUnit(value, props.unit, i18n.language)} /> + + {props.children} + + ; +}; diff --git a/src/components/Measurements/widgets/fab.tsx b/src/components/Measurements/widgets/fab.tsx index 4f6f04494..6a4c12b90 100644 --- a/src/components/Measurements/widgets/fab.tsx +++ b/src/components/Measurements/widgets/fab.tsx @@ -1,13 +1,19 @@ import React from "react"; -import { Fab } from "@mui/material"; +import { CircularProgress, Fab } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import { useTranslation } from "react-i18next"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; -import { CategoryForm } from "@/components/Measurements/widgets/CategoryForm"; -import { EntryForm } from "@/components/Measurements/widgets/EntryForm"; -import { useParams } from "react-router-dom"; +import { MeasurementCategory } from "@/components/Measurements/models/Category"; +import { NewCategoryPicker } from "@/components/Measurements/widgets/MetricPicker"; +import { EntryForm, GroupEntryForm } from "@/components/Measurements/widgets/EntryForm"; +import { WeightForm } from "@/components/Measurements/widgets/WeightForm"; -export const AddMeasurementCategoryFab = () => { +/** + * @param isLoading whether the overview is (re)reading its categories. A new + * category invalidates that query, and reading the histories again takes long + * enough that the button has to say so instead of looking idle. + */ +export const AddMeasurementCategoryFab = ({ isLoading = false }: { isLoading?: boolean }) => { const [t] = useTranslation(); const [openModal, setOpenModal] = React.useState(false); const handleOpenModal = () => setOpenModal(true); @@ -19,6 +25,7 @@ export const AddMeasurementCategoryFab = () => { { right: (theme) => `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`, zIndex: 9, }}> - + {isLoading ? : } - +
); }; -export const AddMeasurementEntryFab = () => { +export const AddMeasurementEntryFab = ({ category }: { category: MeasurementCategory }) => { const [t] = useTranslation(); const [openModal, setOpenModal] = React.useState(false); const handleOpenModal = () => setOpenModal(true); const handleCloseModal = () => setOpenModal(false); - const params = useParams<{ categoryId: string }>(); - const categoryId = params.categoryId!; - return (<> { - + {category.isGroup + ? + : } ); -}; \ No newline at end of file +}; + +export const AddBodyWeightEntryFab = () => { + const [t] = useTranslation(); + const [openModal, setOpenModal] = React.useState(false); + const handleOpenModal = () => setOpenModal(true); + const handleCloseModal = () => setOpenModal(false); + + return ( +
+ `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`, + zIndex: 9, + }}> + + + + + +
+ ); +}; diff --git a/src/components/Muscles/MuscleOverview.tsx b/src/components/Muscles/MuscleOverview.tsx index 27b44136c..78f121ff0 100644 --- a/src/components/Muscles/MuscleOverview.tsx +++ b/src/components/Muscles/MuscleOverview.tsx @@ -1,4 +1,4 @@ -import { Muscle } from "@/components/Exercises"; +import type { Muscle } from "@/components/Exercises"; import { PUBLIC_URL } from "@/config"; import React from "react"; diff --git a/src/components/Nutrition/index.ts b/src/components/Nutrition/index.ts index 21a518d05..cc4113a46 100644 --- a/src/components/Nutrition/index.ts +++ b/src/components/Nutrition/index.ts @@ -24,6 +24,7 @@ export { useAddDiaryEntryQuery, useFetchLastNutritionalPlanQuery, useNutritionDiaryQuery, + useNutritionPlanPeriods, } from "./queries"; // Widgets diff --git a/src/components/Nutrition/models/consts.ts b/src/components/Nutrition/models/consts.ts new file mode 100644 index 000000000..8d4043856 --- /dev/null +++ b/src/components/Nutrition/models/consts.ts @@ -0,0 +1,2 @@ +/** Id of the meal that stands in for the logs made outside any meal */ +export const PSEUDO_MEAL_ID = '00000000-0000-0000-0000-000000000000'; diff --git a/src/components/Nutrition/models/meal.ts b/src/components/Nutrition/models/meal.ts index 325401610..ca36f4850 100644 --- a/src/components/Nutrition/models/meal.ts +++ b/src/components/Nutrition/models/meal.ts @@ -1,7 +1,7 @@ import { NutritionalValues } from "@/components/Nutrition/helpers/nutritionalValues"; import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry"; import { MealItem } from "@/components/Nutrition/models/mealItem"; -import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/nutritionalPlan"; +import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts"; import { Adapter } from "@/core/lib/Adapter"; import { dateTimeToHHMM, dateTimeToLocaleHHMM, HHMMToDateTime, isSameDay } from "@/core/lib/date"; diff --git a/src/components/Nutrition/models/nutritionalPlan.test.ts b/src/components/Nutrition/models/nutritionalPlan.test.ts index 89d8e0c2e..c41bc730e 100644 --- a/src/components/Nutrition/models/nutritionalPlan.test.ts +++ b/src/components/Nutrition/models/nutritionalPlan.test.ts @@ -1,4 +1,5 @@ -import { NutritionalPlan, PSEUDO_MEAL_ID } from "@/components/Nutrition/models/nutritionalPlan"; +import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts"; +import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan"; import { TEST_DIARY_ENTRY_13, TEST_DIARY_ENTRY_3, TEST_DIARY_ENTRY_4 } from "@/tests/nutritionDiaryTestdata"; import { TEST_MEAL_1, TEST_NUTRITIONAL_PLAN_1 } from "@/tests/nutritionTestdata"; import { yyyymmddToDate } from "@/core/lib/date"; diff --git a/src/components/Nutrition/models/nutritionalPlan.ts b/src/components/Nutrition/models/nutritionalPlan.ts index c565cbf9e..f9e17c47c 100644 --- a/src/components/Nutrition/models/nutritionalPlan.ts +++ b/src/components/Nutrition/models/nutritionalPlan.ts @@ -1,5 +1,6 @@ import { NutritionalValues } from "@/components/Nutrition/helpers/nutritionalValues"; import { DiaryEntry } from "@/components/Nutrition/models/diaryEntry"; +import { PSEUDO_MEAL_ID } from "@/components/Nutrition/models/consts"; import { Meal } from "@/components/Nutrition/models/meal"; import { ApiNutritionalPlanType } from "@/types"; import { Adapter } from "@/core/lib/Adapter"; @@ -12,9 +13,6 @@ export type GroupedDiaryEntries = { nutritionalValues: NutritionalValues; } -export const PSEUDO_MEAL_ID = '00000000-0000-0000-0000-000000000000'; - - type NutritionalPlanConstructorParams = { id?: string | null, creationDate?: Date, diff --git a/src/components/Nutrition/queries/diary.ts b/src/components/Nutrition/queries/diary.ts index 710fc24b7..bbcd52e33 100644 --- a/src/components/Nutrition/queries/diary.ts +++ b/src/components/Nutrition/queries/diary.ts @@ -11,7 +11,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; export const useNutritionDiaryQuery = (options?: NutritionalDiaryEntriesOptions) => useQuery({ queryFn: () => getNutritionalDiaryEntries(options), - queryKey: [QueryKey.NUTRITIONAL_PLAN_DIARY, JSON.stringify(options || {})], + queryKey: [QueryKey.NUTRITIONAL_PLAN_DIARY, options ?? {}], }); export const useAddDiaryEntryQuery = (planId: string) => { diff --git a/src/components/Nutrition/queries/index.ts b/src/components/Nutrition/queries/index.ts index e3e1ddc82..c464c439d 100644 --- a/src/components/Nutrition/queries/index.ts +++ b/src/components/Nutrition/queries/index.ts @@ -1,5 +1,6 @@ export { useFetchNutritionalPlansQuery, + useNutritionPlanPeriods, useFetchNutritionalPlanDateQuery, useEditNutritionalPlanQuery, useAddNutritionalPlanQuery, diff --git a/src/components/Nutrition/queries/plan.ts b/src/components/Nutrition/queries/plan.ts index 1940d24e9..c210fe7ae 100644 --- a/src/components/Nutrition/queries/plan.ts +++ b/src/components/Nutrition/queries/plan.ts @@ -8,15 +8,36 @@ import { getNutritionalPlanFull, getNutritionalPlansSparse } from "@/components/Nutrition/api/nutritionalPlan"; +import { PlanPeriod } from "@/components/Measurements"; import { QueryKey } from "@/core/lib/consts"; +import { useTranslation } from "react-i18next"; -export function useFetchNutritionalPlansQuery() { +export function useFetchNutritionalPlansQuery(enabled = true) { return useQuery({ queryKey: [QueryKey.NUTRITIONAL_PLANS], - queryFn: () => getNutritionalPlansSparse() + queryFn: () => getNutritionalPlansSparse(), + enabled: enabled, }); } +/** + * The plans as periods a measurement chart can shade, newest first. A plan + * without an end date is still running, so its period reaches up to now. + * + * Pass enabled=false where the metric has nothing to do with nutrition, so + * those charts do not fetch the plans at all. + */ +export function useNutritionPlanPeriods(enabled = true): PlanPeriod[] { + const [t] = useTranslation(); + const query = useFetchNutritionalPlansQuery(enabled); + + return (query.data ?? []).map(plan => ({ + start: plan.start.getTime(), + end: (plan.end ?? new Date()).getTime(), + name: plan.description !== '' ? plan.description : t('nutrition.plan'), + })); +} + export function useFetchLastNutritionalPlanQuery() { return useQuery({ diff --git a/src/components/Nutrition/screens/BmiCalculator.test.tsx b/src/components/Nutrition/screens/BmiCalculator.test.tsx index 34a7f3043..be819ac44 100644 --- a/src/components/Nutrition/screens/BmiCalculator.test.tsx +++ b/src/components/Nutrition/screens/BmiCalculator.test.tsx @@ -1,14 +1,15 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { render, screen } from '@testing-library/react'; +import { useBodyWeightCategoryQuery, useBodyWeightQuery } from "@/components/Measurements"; import { BmiCalculator } from "@/components/Nutrition/screens/BmiCalculator"; -import { useBodyWeightQuery } from "@/components/Weight"; import { useProfileQuery } from "@/components/User"; import i18n from 'i18next'; import { BrowserRouter } from 'react-router-dom'; import { testQueryClient } from "@/tests/queryClient"; +import { makeWeightEntry, testBodyWeightCategory } from "@/tests/weight/testData"; import type { Mock } from 'vitest'; -vi.mock('@/components/Weight/queries'); +vi.mock('@/components/Measurements/queries/bodyWeight'); vi.mock('@/components/User/queries/profile'); const wrapper = ({ children }: { children: React.ReactNode }) => ( @@ -34,7 +35,11 @@ describe('BmiCalculator', () => { beforeEach(() => { (useBodyWeightQuery as Mock).mockReturnValue({ isLoading: false, - data: [{ weight: 55, date: new Date() }], + data: [makeWeightEntry(new Date(), 55)], + }); + (useBodyWeightCategoryQuery as Mock).mockReturnValue({ + isLoading: false, + data: testBodyWeightCategory, }); (useProfileQuery as Mock).mockReturnValue({ isLoading: false, @@ -59,17 +64,18 @@ describe('BmiCalculator', () => { expect(screen.getByLabelText('height')).toHaveValue(180); }); - it('converts the last weight entry to kg for imperial users', async () => { - (useProfileQuery as Mock).mockReturnValue({ + it('converts an entry stored in pounds to kg', async () => { + // The unit travels with the entry, so the profile has no say in this + (useBodyWeightQuery as Mock).mockReturnValue({ isLoading: false, - data: { height: 180, useMetric: false }, + data: [makeWeightEntry(new Date(), 121, { unit: 'lb' })], }); render(, { wrapper }); - // 55 lb are 24.947 kg, which gives a BMI of 7.699... - expect(screen.getByLabelText('weight')).toHaveValue(55 * 0.453592); - expect(screen.getByText('BMI: 7.7')).toBeInTheDocument(); + // 121 lb are 54.88 kg, which gives a BMI of 16.938... + expect(screen.getByLabelText('weight')).toHaveValue(54.88); + expect(screen.getByText('BMI: 16.9')).toBeInTheDocument(); }); it('shows no result when the user has no weight entries', async () => { diff --git a/src/components/Nutrition/screens/BmiCalculator.tsx b/src/components/Nutrition/screens/BmiCalculator.tsx index 7dfab7182..7d93e616a 100644 --- a/src/components/Nutrition/screens/BmiCalculator.tsx +++ b/src/components/Nutrition/screens/BmiCalculator.tsx @@ -1,6 +1,10 @@ import { Box, Stack, TextField, Typography } from "@mui/material"; import Grid from "@mui/material/Grid"; -import { useBodyWeightQuery } from "@/components/Weight"; +import { + entryFilterFor, + useBodyWeightCategoryQuery, + useBodyWeightQuery +} from "@/components/Measurements"; import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container"; import { useProfileQuery } from "@/components/User"; @@ -23,22 +27,23 @@ const getRangeColor = (name: string) => { export const BmiCalculator = () => { const [t] = useTranslation(); - const weightQuery = useBodyWeightQuery(); + // Only the most recent entry is used to prefill the field; a year back is + // generous for that and keeps the query bounded + const weightQuery = useBodyWeightQuery(entryFilterFor('lastYear')); + const categoryQuery = useBodyWeightCategoryQuery(); const profileQuery = useProfileQuery(); + // Entries without their own unit fall back to the one of the category + const categoryUnit = categoryQuery.data?.unit ?? 'kg'; const [height, setHeight] = useState(); const [weight, setWeight] = useState(); - // Set default weight from last weight entry + // Set default weight from last weight entry, the BMI is always computed in kg useEffect(() => { if (weightQuery.data && weightQuery.data.length > 0) { - const lastWeightEntry = weightQuery.data[0]; - const weightInKg = profileQuery.data?.useMetric - ? lastWeightEntry.weight - : lastWeightEntry.weight * 0.453592; // Convert lb to kg - setWeight(weightInKg); + setWeight(weightQuery.data[0].valueIn('kg', categoryUnit)); } - }, [weightQuery.data, profileQuery.data]); + }, [weightQuery.data, categoryUnit]); useEffect(() => { if (profileQuery.data?.height) { diff --git a/src/components/Nutrition/screens/PlanDetail.tsx b/src/components/Nutrition/screens/PlanDetail.tsx index ca298fbc0..2ac553d3b 100644 --- a/src/components/Nutrition/screens/PlanDetail.tsx +++ b/src/components/Nutrition/screens/PlanDetail.tsx @@ -11,6 +11,7 @@ import { AddNutritionDiaryEntryFab } from "@/components/Nutrition/widgets/Fab"; import { MealForm } from "@/components/Nutrition/widgets/forms/MealForm"; import { MealDetail } from "@/components/Nutrition/widgets/MealDetail"; import { NutritionalValuesTable } from "@/components/Nutrition/widgets/NutritionalValuesTable"; +import { PlanWeightChart } from "@/components/Nutrition/widgets/charts/PlanWeightChart"; import { PlanDetailDropdown } from "@/components/Nutrition/widgets/PlanDetailDropdown"; import { PlanSidebar } from "@/components/Nutrition/widgets/PlanSidebar"; import React, { useState } from "react"; @@ -92,6 +93,7 @@ export const PlanDetail = () => { logged={plan.groupDiaryEntries} planned={plan.plannedNutritionalValues} /> + } sideBar={} diff --git a/src/components/Nutrition/widgets/MealDetailDropdown.tsx b/src/components/Nutrition/widgets/MealDetailDropdown.tsx index d239ff0cb..b3ae22dca 100644 --- a/src/components/Nutrition/widgets/MealDetailDropdown.tsx +++ b/src/components/Nutrition/widgets/MealDetailDropdown.tsx @@ -5,6 +5,7 @@ import MoreVertIcon from "@mui/icons-material/MoreVert"; import { Alert, IconButton, Menu, MenuItem, Snackbar } from "@mui/material"; import Tooltip from "@mui/material/Tooltip"; import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal"; +import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; import { Meal } from "@/components/Nutrition/models/meal"; import { useDeleteMealQuery } from "@/components/Nutrition/queries"; @@ -76,6 +77,8 @@ export const MealDetailDropdown = (props: { return <> + + {props.meal.isRealMeal && !props.onlyLogging && diff --git a/src/components/Nutrition/widgets/PlanDetailDropdown.tsx b/src/components/Nutrition/widgets/PlanDetailDropdown.tsx index 4cca96180..0b535216e 100644 --- a/src/components/Nutrition/widgets/PlanDetailDropdown.tsx +++ b/src/components/Nutrition/widgets/PlanDetailDropdown.tsx @@ -1,6 +1,7 @@ import MenuIcon from '@mui/icons-material/Menu'; import { Button, Menu, MenuItem } from "@mui/material"; import { DeleteConfirmationModal } from "@/core/ui/Modals/DeleteConfirmationModal"; +import { FormQueryErrorsSnackbar } from "@/core/ui/Widgets/FormError"; import { WgerModal } from "@/core/ui/Modals/WgerModal"; import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan"; import { useDeleteNutritionalPlanQuery } from "@/components/Nutrition/queries"; @@ -53,6 +54,7 @@ export const PlanDetailDropdown = (props: { plan: NutritionalPlan }) => { const handleCloseDeleteModal = () => setOpenDeleteModal(false); return <> + diff --git a/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx new file mode 100644 index 000000000..a089fa585 --- /dev/null +++ b/src/components/Nutrition/widgets/charts/PlanWeightChart.tsx @@ -0,0 +1,55 @@ +import { Typography } from "@mui/material"; +import { NutritionalPlan } from "@/components/Nutrition/models/nutritionalPlan"; +import { + useBodyWeightCategoryQuery, + useBodyWeightQuery, + useDisplayWeightUnit, + WeightChart +} from "@/components/Measurements"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +/** + * Body weight during the plan's period. + * + * Hidden while the weight data is not loaded and when fewer than two readings + * fall into the period. All series are derived from the readings inside the + * period only, so the trend starts from a real measurement instead of an + * interpolated boundary point. + */ +export const PlanWeightChart = (props: { plan: NutritionalPlan }) => { + const [t] = useTranslation(); + // The chart starts at the plan, so nothing before it has to be fetched. + // The upper end stays a client-side filter, because the plan's last day + // counts in full and a date bound would cut it at midnight + const weightQuery = useBodyWeightQuery({ "date__gte": props.plan.start.toISOString() }); + const categoryQuery = useBodyWeightCategoryQuery(); + const displayUnit = useDisplayWeightUnit(); + + if (!weightQuery.data || !categoryQuery.data) { + return null; + } + + // The end date is inclusive: readings on the plan's last day still count + const endExclusive = props.plan.end === null + ? null + : new Date(props.plan.end.getTime() + 24 * 60 * 60 * 1000); + const entries = weightQuery.data.filter(entry => + entry.date >= props.plan.start && (endExclusive === null || entry.date < endExclusive)); + + // A single reading has no development to show + if (entries.length < 2) { + return null; + } + + return <> + {t('weight')} + + ; +}; diff --git a/src/components/Nutrition/widgets/forms/MealForm.test.tsx b/src/components/Nutrition/widgets/forms/MealForm.test.tsx index 66c28a7e9..720afcc82 100644 --- a/src/components/Nutrition/widgets/forms/MealForm.test.tsx +++ b/src/components/Nutrition/widgets/forms/MealForm.test.tsx @@ -1,23 +1,24 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import { Meal } from "@/components/Nutrition/models/meal"; import { useAddMealQuery, useEditMealQuery } from "@/components/Nutrition/queries"; import { MealForm } from "@/components/Nutrition/widgets/forms/MealForm"; +import { mutateMock } from "@/tests/mutationMock"; import { TEST_MEAL_1 } from "@/tests/nutritionTestdata"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import type { Mock } from 'vitest'; vi.mock('@/components/Nutrition/queries'); describe('Test the MealForm component', () => { const queryClient = new QueryClient(); - let mutateAddMock = vi.fn(); - let mutateEditMock = vi.fn(); + let mutateAddMock = mutateMock(); + let mutateEditMock = mutateMock(); let closeFnMock = vi.fn(); beforeEach(() => { - mutateAddMock = vi.fn(); - mutateEditMock = vi.fn(); + mutateAddMock = mutateMock(); + mutateEditMock = mutateMock(); closeFnMock = vi.fn(); (useEditMealQuery as Mock).mockImplementation(() => ({ mutate: mutateEditMock })); @@ -49,7 +50,7 @@ describe('Test the MealForm component', () => { name: '2nd breakfast', planId: 'aaaaaaaa-0000-0000-0000-000000000987', time: expect.any(Date), - })); + }), expect.anything()); }); test('an existing meal is correctly edited', async () => { @@ -81,6 +82,6 @@ describe('Test the MealForm component', () => { planId: 'aaaaaaaa-0000-0000-0000-000000000123', time: TEST_MEAL_1.time }) - ); + , expect.anything()); }); }); diff --git a/src/components/Nutrition/widgets/forms/MealForm.tsx b/src/components/Nutrition/widgets/forms/MealForm.tsx index f5f83ae1b..9ece9ed55 100644 --- a/src/components/Nutrition/widgets/forms/MealForm.tsx +++ b/src/components/Nutrition/widgets/forms/MealForm.tsx @@ -3,6 +3,7 @@ import { LocalizationProvider, TimePicker } from "@mui/x-date-pickers"; import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon"; import { Meal } from "@/components/Nutrition/models/meal"; import { useAddMealQuery, useEditMealQuery } from "@/components/Nutrition/queries"; +import { FormQueryErrors } from "@/core/ui/Widgets/FormError"; import { Form, Formik } from "formik"; import { DateTime } from "luxon"; import React from 'react'; @@ -45,10 +46,14 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => { values.time = values.time.toJSDate(); } + // The dialog closes only once the server took the meal, so a + // rejected write is shown instead of disappearing with it + const options = { onSuccess: () => closeFn?.() }; + if (meal) { // Edit const newMeal = Meal.clone(meal, { name: values.name, time: values.time }); - editMealQuery.mutate(newMeal); + editMealQuery.mutate(newMeal, options); } else { // Add @@ -56,11 +61,7 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => { planId: planId, name: values.name, time: values.time, - })); - } - - if (closeFn) { - closeFn(); + }), options); } }} > @@ -83,6 +84,7 @@ export const MealForm = ({ meal, planId, closeFn }: MealFormProps) => { onChange={(newValue) => formik.setFieldValue('time', newValue ? newValue.toJSDate() : null)} /> + {closeFn !== undefined && - - - - )} - ) - ); -}; diff --git a/src/components/Weight/index.ts b/src/components/Weight/index.ts deleted file mode 100644 index 205b1617d..000000000 --- a/src/components/Weight/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Public surface of the Weight domain. - * - * Other code may only import from `@/components/Weight`, never from - * internal sub-paths. - */ -export { BodyWeight } from "./screens/BodyWeight"; -export { WeightForm } from "./forms/WeightForm"; -export { WeightTableDashboard } from "./widgets/TableDashboard/TableDashboard"; -export { WeightChart } from "./widgets/WeightChart"; -export { WeightEntry } from "./models/WeightEntry"; -export { useBodyWeightQuery } from "./queries"; -export type { FilterType } from "./widgets/FilterButtons"; diff --git a/src/components/Weight/models/WeightEntry.ts b/src/components/Weight/models/WeightEntry.ts deleted file mode 100644 index a79b27c62..000000000 --- a/src/components/Weight/models/WeightEntry.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Adapter } from "@/core/lib/Adapter"; - -export class WeightEntry { - - constructor( - public date: Date, - public weight: number, - public id?: number, - ) { - } - - static clone(other: WeightEntry, overrides?: Partial>): WeightEntry { - return new WeightEntry( - overrides?.date ?? other.date, - overrides?.weight ?? other.weight, - overrides?.id ?? other.id, - ); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - static fromJson(json: any) { - return adapter.fromJson(json); - } - - toJson() { - return adapter.toJson(this); - } -} - -class WeightAdapter implements Adapter { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fromJson(item: any): WeightEntry { - return new WeightEntry( - new Date(item.date), - parseFloat(item.weight), - item.id, - ); - } - - toJson(item: WeightEntry) { - return { - date: item.date.toISOString(), - weight: item.weight, - }; - } -} - -const adapter = new WeightAdapter(); \ No newline at end of file diff --git a/src/components/Weight/queries/index.ts b/src/components/Weight/queries/index.ts deleted file mode 100644 index 44571b6ab..000000000 --- a/src/components/Weight/queries/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { createWeight, deleteWeight, getWeights, updateWeight } from "@/components/Weight/api/weight"; -import { QueryKey, } from "@/core/lib/consts"; -import { FilterType } from "../widgets/FilterButtons"; - - -export function useBodyWeightQuery(filter: FilterType = 'lastWeek') { - return useQuery({ - queryKey: [QueryKey.BODY_WEIGHT, filter], - queryFn: () => getWeights(filter), - }); -} - -export const useDeleteWeightEntryQuery = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (id: number) => deleteWeight(id), - onSuccess: () => queryClient.invalidateQueries({ - queryKey: [QueryKey.BODY_WEIGHT] - }) - }); -}; - - -export const useAddWeightEntryQuery = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (weightEntry: WeightEntry) => createWeight(weightEntry), - onSuccess: () => queryClient.invalidateQueries({ - queryKey: [QueryKey.BODY_WEIGHT,] - }) - }); -}; - -export const useEditWeightEntryQuery = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (data: WeightEntry) => updateWeight(data), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: [QueryKey.BODY_WEIGHT,] - }); - } - }); -}; \ No newline at end of file diff --git a/src/components/Weight/screens/BodyWeight.test.tsx b/src/components/Weight/screens/BodyWeight.test.tsx deleted file mode 100644 index 8d439d9b2..000000000 --- a/src/components/Weight/screens/BodyWeight.test.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from "@testing-library/user-event"; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { getWeights } from "@/components/Weight/api/weight"; -import { getTestQueryClient } from "@/tests/queryClient"; -import { BodyWeight } from "./BodyWeight"; -import { FilterType } from "../widgets/FilterButtons"; -import type { Mock } from 'vitest'; - -vi.mock("@/components/Weight/api/weight"); - -describe("Test BodyWeight component", () => { - - beforeEach(() => { - vi.clearAllMocks(); - }); - - // Arrange - const weightData = [ - new WeightEntry(new Date('2021-12-10'), 80, 1), - new WeightEntry(new Date('2021-12-20'), 90, 2), - ]; - - /* - * Each test gets its own query client. With a shared one the cached entries of - * the previous test would answer the query here, regardless of the mock. - */ - const renderComponent = () => render( - - - - ); - - test('renders without crashing', async () => { - - (getWeights as Mock).mockImplementation(() => Promise.resolve(weightData)); - - // Act - renderComponent(); - - // Assert - expect(getWeights).toHaveBeenCalledTimes(1); - - // Both weights are found in the document - expect(await screen.findByText("80")).toBeInTheDocument(); - expect(await screen.findByText("90")).toBeInTheDocument(); - }); - - test('changes filter and updates displayed data', async () => { - - // Arrange - const user = userEvent.setup(); - - // Mock the getWeights response based on the filter - (getWeights as Mock).mockImplementation((filter: FilterType) => { - if (filter === 'lastYear') { - return Promise.resolve(weightData); - } - return Promise.resolve([]); - }); - - // Act - renderComponent(); - - // Assert - initially the data for last year is shown - expect(await screen.findByText("80")).toBeInTheDocument(); - expect(await screen.findByText("90")).toBeInTheDocument(); - - // Act - change the filter to 'lastMonth' - await user.click(screen.getByRole('button', { name: /lastMonth/i })); - - // Assert - the empty result of the new filter replaces the old entries - expect(getWeights).toHaveBeenCalledWith('lastMonth'); - await waitFor(() => expect(screen.getByText('nothingHereYet')).toBeInTheDocument()); - expect(screen.queryByText("80")).not.toBeInTheDocument(); - expect(screen.queryByText("90")).not.toBeInTheDocument(); - }); - - test('shows the empty state when there are no entries at all', async () => { - - // Arrange - (getWeights as Mock).mockImplementation(() => Promise.resolve([])); - - // Act - renderComponent(); - - // Assert - expect(await screen.findByText('nothingHereYet')).toBeInTheDocument(); - }); -}); diff --git a/src/components/Weight/screens/BodyWeight.tsx b/src/components/Weight/screens/BodyWeight.tsx deleted file mode 100644 index be653382f..000000000 --- a/src/components/Weight/screens/BodyWeight.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { Box, Stack } from "@mui/material"; -import { useBodyWeightQuery } from "@/components/Weight/queries"; -import { WeightTable } from "@/components/Weight/widgets/Table"; -import { WeightChart } from "@/components/Weight/widgets/WeightChart"; -import { AddBodyWeightEntryFab } from "@/components/Weight/widgets/fab"; -import { FilterButtons, FilterType } from "@/components/Weight/widgets/FilterButtons"; -import { LoadingPlaceholder } from "@/core/ui/LoadingWidget/LoadingWidget"; -import { WgerContainerRightSidebar } from "@/core/ui/Widgets/Container"; -import { OverviewEmpty } from "@/core/ui/Widgets/OverviewEmpty"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - - -export const BodyWeight = () => { - const [t] = useTranslation(); - const [filter, setFilter] = useState('lastYear'); - const weightyQuery = useBodyWeightQuery(filter); - const handleFilterChange = (newFilter: FilterType) => { - setFilter(newFilter); - }; - - if (weightyQuery.isLoading) { - return ; - } - - return - - {weightyQuery.data!.length === 0 && } - {weightyQuery.data!.length !== 0 && <> - - - - } - - } - fab={} - />; -}; \ No newline at end of file diff --git a/src/components/Weight/widgets/FilterButtons.test.tsx b/src/components/Weight/widgets/FilterButtons.test.tsx deleted file mode 100644 index 898827dcc..000000000 --- a/src/components/Weight/widgets/FilterButtons.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; -import React from 'react'; -import { FilterButtons, FilterType } from './FilterButtons'; - -describe('FilterButtons Component', () => { - const onFilterChange = vi.fn(); - - const renderComponent = (currentFilter: FilterType) => { - render( - - ); - }; - - afterEach(() => { - onFilterChange.mockClear(); - }); - - test('renders all filter buttons', () => { - renderComponent(''); - const buttonLabels = ['all', 'lastYear', 'lastHalfYear', 'lastMonth', 'lastWeek']; - buttonLabels.forEach(label => { - expect(screen.getByText(label)).toBeInTheDocument(); - }); - }); - - test('applies primary color and contained variant to the active filter button', () => { - renderComponent('lastMonth'); - const activeButton = screen.getByText('lastMonth'); - expect(activeButton).toHaveClass('MuiButton-contained', 'MuiButton-colorPrimary'); - }); - - test('calls onFilterChange with correct value when a button is clicked', () => { - renderComponent(''); - const lastYearButton = screen.getByText('lastYear'); - - fireEvent.click(lastYearButton); - expect(onFilterChange).toHaveBeenCalledWith('lastYear'); - }); - - test('does not trigger onFilterChange when clicking the currently active filter button', () => { - renderComponent('lastYear'); - const lastYearButton = screen.getByText('lastYear'); - - fireEvent.click(lastYearButton); - expect(onFilterChange).not.toHaveBeenCalled(); - }); - - test('displays correct default style for inactive filter buttons', () => { - renderComponent(''); - const inactiveButton = screen.getByText('lastYear'); - expect(inactiveButton).toHaveClass('MuiButton-outlined'); - }); -}); diff --git a/src/components/Weight/widgets/FilterButtons.tsx b/src/components/Weight/widgets/FilterButtons.tsx deleted file mode 100644 index c78360da7..000000000 --- a/src/components/Weight/widgets/FilterButtons.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Button, ButtonGroup } from "@mui/material"; -import { useTheme } from '@mui/material/styles'; -import { useTranslation } from "react-i18next"; - -export type FilterType = 'lastYear' | 'lastHalfYear' | 'lastMonth' | 'lastWeek' | ''; - -export interface FilterButtonsProps { - currentFilter: FilterType; - onFilterChange: (newFilter: FilterType) => void; -} - -export const FilterButtons = ({ currentFilter, onFilterChange }: FilterButtonsProps) => { - - const [t] = useTranslation(); - - const theme = useTheme(); - - // Won't call onFilterChange if the filter stays the same - const handleFilterChange = (newFilter: FilterType) => { - if (currentFilter !== newFilter) { - onFilterChange(newFilter); - } - }; - - return ( - - - - - - - - ); -}; diff --git a/src/components/Weight/widgets/Table/Fab/Fab.tsx b/src/components/Weight/widgets/Table/Fab/Fab.tsx deleted file mode 100644 index 408bba3fa..000000000 --- a/src/components/Weight/widgets/Table/Fab/Fab.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Fab } from '@mui/material'; -import AddIcon from '@mui/icons-material/Add'; -import { WeightForm } from "@/components/Weight/forms/WeightForm"; -import { WgerModal } from "@/core/ui/Modals/WgerModal"; -import { useTranslation } from "react-i18next"; - - -export const WeightEntryFab = () => { - - const [t] = useTranslation(); - const [openModal, setOpenModal] = React.useState(false); - const handleOpenModal = () => setOpenModal(true); - const handleCloseModal = () => setOpenModal(false); - - return ( -
- `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`, - zIndex: 9, - }}> - - - - - -
- ); -}; \ No newline at end of file diff --git a/src/components/Weight/widgets/Table/index.test.tsx b/src/components/Weight/widgets/Table/index.test.tsx deleted file mode 100644 index 9077f53ce..000000000 --- a/src/components/Weight/widgets/Table/index.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from '@testing-library/react'; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { BrowserRouter } from "react-router-dom"; -import { testQueryClient } from "@/tests/queryClient"; -import { WeightTable } from './index'; - -const renderTable = (weights: WeightEntry[]) => - render( - - - - - - ); - -describe("Body weight table", () => { - test('renders rows for all weight entries', async () => { - const weights: WeightEntry[] = [ - new WeightEntry(new Date('2021/12/10'), 80, 1), - new WeightEntry(new Date('2021/12/20'), 90, 2), - ]; - - renderTable(weights); - - expect(await screen.findByText('80')).toBeInTheDocument(); - expect(await screen.findByText('90')).toBeInTheDocument(); - }); - - test('displays total change column correctly', async () => { - const weights: WeightEntry[] = [ - new WeightEntry(new Date('2021/12/10'), 80, 1), - new WeightEntry(new Date('2021/12/20'), 90, 2), - new WeightEntry(new Date('2021/12/25'), 85, 3), - ]; - - renderTable(weights); - await screen.findByText('80'); - - // DataGrid rows are sorted newest-first: 85 (total +5), 90 (+10), 80 (0) - const expectedTotals: Record = { '85': '5', '90': '10', '80': '0' }; - - for (const [weight, totalChange] of Object.entries(expectedTotals)) { - const row = document.querySelector(`[data-id="${weight === '80' ? 1 : weight === '90' ? 2 : 3}"]`) as HTMLElement; - expect(row).not.toBeNull(); - const cell = row.querySelector('[data-field="totalChange"]') as HTMLElement; - expect(cell.textContent).toBe(totalChange); - } - }); - - test('shows inline edit and delete actions per row', async () => { - const weights: WeightEntry[] = [new WeightEntry(new Date('2021/12/10'), 80, 1)]; - renderTable(weights); - - await screen.findByText('80'); - expect(screen.getByRole('menuitem', { name: /edit/i })).toBeInTheDocument(); - expect(screen.getByRole('menuitem', { name: /delete/i })).toBeInTheDocument(); - }); -}); diff --git a/src/components/Weight/widgets/Table/index.tsx b/src/components/Weight/widgets/Table/index.tsx deleted file mode 100644 index 53bce6263..000000000 --- a/src/components/Weight/widgets/Table/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -import CancelIcon from "@mui/icons-material/Close"; -import DeleteIcon from "@mui/icons-material/DeleteOutlined"; -import EditIcon from "@mui/icons-material/Edit"; -import SaveIcon from "@mui/icons-material/Save"; -import { Box } from "@mui/material"; -import { - DataGrid, - GridActionsCellItem, - GridColDef, - GridEventListener, - GridRowEditStopReasons, - GridRowId, - GridRowModel, - GridRowModes, - GridRowModesModel, - GridRowsProp, -} from "@mui/x-data-grid"; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { WeightEntryFab } from "@/components/Weight/widgets/Table/Fab/Fab"; -import { useDeleteWeightEntryQuery, useEditWeightEntryQuery } from "@/components/Weight/queries"; -import { processTimeSeries } from "@/core/lib/timeSeries"; -import { DateTime } from "luxon"; -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { PAGINATION_OPTIONS } from "@/core/lib/consts"; -import { luxonDateTimeToLocale } from "@/core/lib/date"; - -export interface WeightTableProps { - weights: WeightEntry[]; -} - -const buildRows = (weights: WeightEntry[]): GridRowsProp => - processTimeSeries(weights, e => e.weight).map((row) => ({ - id: row.entry.id, - date: row.entry.date, - weight: row.entry.weight, - change: +row.change.toFixed(2), - totalChange: +row.totalChange.toFixed(2), - days: +row.days.toFixed(1), - })); - -export const WeightTable = ({ weights }: WeightTableProps) => { - const [t] = useTranslation(); - const rows = buildRows(weights); - const editEntryQuery = useEditWeightEntryQuery(); - const deleteEntryQuery = useDeleteWeightEntryQuery(); - const [rowModesModel, setRowModesModel] = useState({}); - - const handleRowEditStop: GridEventListener<'rowEditStop'> = (params, event) => { - if (params.reason === GridRowEditStopReasons.rowFocusOut) { - event.defaultMuiPrevented = true; - } - }; - - const handleEditClick = (id: GridRowId) => () => { - setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.Edit } }); - }; - - const handleSaveClick = (id: GridRowId) => () => { - setRowModesModel({ ...rowModesModel, [id]: { mode: GridRowModes.View } }); - }; - - const handleDeleteClick = (id: GridRowId) => () => { - deleteEntryQuery.mutate(Number(id)); - }; - - const handleCancelClick = (id: GridRowId) => () => { - setRowModesModel({ - ...rowModesModel, - [id]: { mode: GridRowModes.View, ignoreModifications: true }, - }); - }; - - const processRowUpdate = (newRow: GridRowModel) => { - const date = newRow.date instanceof Date ? newRow.date : new Date(newRow.date); - editEntryQuery.mutate(new WeightEntry(date, Number(newRow.weight), Number(newRow.id))); - return newRow; - }; - - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); - }; - - const columns: GridColDef[] = [ - { - field: 'date', - headerName: t('date'), - type: 'dateTime', - width: 160, - editable: true, - valueFormatter: (value?: Date) => { - if (value == null) { - return ''; - } - return luxonDateTimeToLocale(DateTime.fromJSDate(value), undefined, DateTime.DATETIME_SHORT); - }, - }, - { - field: 'weight', - headerName: t('weight'), - type: 'number', - width: 100, - editable: true, - }, - { - field: 'change', - headerName: t('difference'), - type: 'number', - width: 120, - editable: false, - }, - { - field: 'totalChange', - headerName: t('totalChange'), - type: 'number', - width: 140, - editable: false, - }, - { - field: 'days', - headerName: t('days'), - type: 'number', - width: 100, - editable: false, - }, - { - field: 'actions', - type: 'actions', - headerName: t('actions'), - width: 100, - cellClassName: 'actions', - getActions: ({ id }) => { - const isInEditMode = rowModesModel[id]?.mode === GridRowModes.Edit; - - if (isInEditMode) { - return [ - } - label={t('save')} - onClick={handleSaveClick(id)} - />, - } - label={t('cancel')} - className="textPrimary" - onClick={handleCancelClick(id)} - color="inherit" - />, - ]; - } - - return [ - } - label={t('edit')} - className="textPrimary" - onClick={handleEditClick(id)} - color="inherit" - />, - } - label={t('delete')} - onClick={handleDeleteClick(id)} - color="inherit" - />, - ]; - }, - }, - ]; - - return ( - <> - - - - - - ); -}; diff --git a/src/components/Weight/widgets/Table/table.module.css b/src/components/Weight/widgets/Table/table.module.css deleted file mode 100644 index 24a897a5b..000000000 --- a/src/components/Weight/widgets/Table/table.module.css +++ /dev/null @@ -1,3 +0,0 @@ - - -/*# sourceMappingURL=table.module.css.map */ diff --git a/src/components/Weight/widgets/Table/table.module.css.map b/src/components/Weight/widgets/Table/table.module.css.map deleted file mode 100644 index e610b88e8..000000000 --- a/src/components/Weight/widgets/Table/table.module.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.module.css"} \ No newline at end of file diff --git a/src/components/Weight/widgets/Table/table.module.scss b/src/components/Weight/widgets/Table/table.module.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/components/Weight/widgets/Table/table.mosule.css b/src/components/Weight/widgets/Table/table.mosule.css deleted file mode 100644 index 89069e79e..000000000 --- a/src/components/Weight/widgets/Table/table.mosule.css +++ /dev/null @@ -1,2 +0,0 @@ - -/*# sourceMappingURL=table.mosule.css.map */ diff --git a/src/components/Weight/widgets/Table/table.mosule.css.map b/src/components/Weight/widgets/Table/table.mosule.css.map deleted file mode 100644 index 0e61f5b3a..000000000 --- a/src/components/Weight/widgets/Table/table.mosule.css.map +++ /dev/null @@ -1 +0,0 @@ -{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.mosule.css"} diff --git a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx b/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx deleted file mode 100644 index 36b227c58..000000000 --- a/src/components/Weight/widgets/TableDashboard/TableDashboard.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { WeightTableDashboard } from '@/components/Weight/widgets/TableDashboard/TableDashboard'; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; - -describe("Body weight test", () => { - test('renders without crashing', async () => { - - const weightsData: WeightEntry[] = [ - new WeightEntry(new Date('2021/12/10'), 80, 1), - new WeightEntry(new Date('2021/12/20'), 90, 2), - ]; - - // since I used context api to provide state, also need it here - render(); - - // Both weights are found in th document - const weightRow = await screen.findByText('80'); - expect(weightRow).toBeInTheDocument(); - - const weightRow2 = await screen.findByText("90"); - expect(weightRow2).toBeInTheDocument(); - }); -}); diff --git a/src/components/Weight/widgets/WeightChart/ema.ts b/src/components/Weight/widgets/WeightChart/ema.ts deleted file mode 100644 index 34eeb2ae0..000000000 --- a/src/components/Weight/widgets/WeightChart/ema.ts +++ /dev/null @@ -1,31 +0,0 @@ -export interface WeightDataPoint { - date: number; - weight: number; -} - -export interface EMADataPoint extends WeightDataPoint { - ema: number; -} - -/** - * Exponentially weighted moving average over a chronologically ordered series. - * Smoothing factor is 2 / (period + 1) — e.g. period=10 gives ~0.18. - */ -export const calculateEMA = ( - weights: T[], - period: number = 10, -): (T & { ema: number })[] => { - if (weights.length === 0) { - return []; - } - - const smoothing = 2 / (period + 1); - let ema = weights[0].weight; - - return weights.map((point, i) => { - if (i > 0) { - ema = point.weight * smoothing + ema * (1 - smoothing); - } - return { ...point, ema }; - }); -}; diff --git a/src/components/Weight/widgets/WeightChart/index.test.tsx b/src/components/Weight/widgets/WeightChart/index.test.tsx deleted file mode 100644 index 1e1c41a27..000000000 --- a/src/components/Weight/widgets/WeightChart/index.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { QueryClientProvider } from "@tanstack/react-query"; -import { render } from '@testing-library/react'; -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import React from 'react'; -import { describe, test } from 'vitest'; -import { testQueryClient } from "@/tests/queryClient"; -import { WeightChart } from "./index"; - -// See https://github.com/maslianok/react-resize-detector#testing-with-enzyme-and-jest -// Recharts only paints SVG content once a ResizeObserver entry reports real -// dimensions, which neither happy-dom nor jsdom provide. We therefore only -// assert the chart mounts; the EMA logic is covered in ema.test.ts. - -const renderChart = (weights: WeightEntry[], height?: number) => - render( - - - - ); - -describe("WeightChart", () => { - test('mounts with weight data', () => { - renderChart([ - new WeightEntry(new Date('2021-12-10'), 80, 1), - new WeightEntry(new Date('2021-12-20'), 90, 2), - ]); - }); - - test('mounts with empty data', () => { - renderChart([]); - }); - - test('mounts with a single entry', () => { - renderChart([new WeightEntry(new Date('2021-12-10'), 80, 1)]); - }); - - test('mounts with unsorted data', () => { - renderChart([ - new WeightEntry(new Date('2021-12-20'), 90, 2), - new WeightEntry(new Date('2021-12-10'), 80, 1), - new WeightEntry(new Date('2021-12-15'), 85, 3), - ]); - }); - - test('respects the height prop', () => { - renderChart( - [ - new WeightEntry(new Date('2021-12-10'), 80, 1), - new WeightEntry(new Date('2021-12-20'), 85, 2), - ], - 500, - ); - }); -}); diff --git a/src/components/Weight/widgets/WeightChart/index.tsx b/src/components/Weight/widgets/WeightChart/index.tsx deleted file mode 100644 index c8210b559..000000000 --- a/src/components/Weight/widgets/WeightChart/index.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; -import { calculateEMA, EMADataPoint } from "@/components/Weight/widgets/WeightChart/ema"; -import { dateToLocale } from "@/core/lib/date"; -import { Paper, Stack, Typography, useTheme } from "@mui/material"; -import { useTranslation } from "react-i18next"; -import { - CartesianGrid, - Legend, - Line, - LineChart, - ReferenceLine, - Tooltip, - useXAxisScale, - useYAxisScale, - XAxis, - YAxis -} from 'recharts'; - -const NR_OF_WEIGHTS_CHART_DOT = 30; - -export interface WeightChartProps { - weights: WeightEntry[], - height?: number, -} - -export interface TooltipProps { - active?: boolean, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload?: any, - label?: string, -} - -const CustomTooltip = ({ active, payload, label }: TooltipProps) => { - const [t] = useTranslation(); - const theme = useTheme(); - - if (active && payload && payload.length) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actualWeight = payload.find((p: any) => p.dataKey === 'weight'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const trendWeight = payload.find((p: any) => p.dataKey === 'ema'); - const variance = actualWeight && trendWeight ? actualWeight.value - trendWeight.value : 0; - - return ( - -

{dateToLocale(new Date(label!))}

- {actualWeight &&

{t('weight')}: {actualWeight.value.toFixed(1)}

} - {trendWeight &&

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

} - {actualWeight && trendWeight && ( -

0 ? theme.palette.error.main : theme.palette.success.main }}> - {t('variance')}: {variance > 0 ? '+' : ''}{variance.toFixed(1)} -

- )} -
- ); - } - - return null; -}; - -const VarianceLines = ({ emaData }: { emaData: EMADataPoint[] }) => { - const xScale = useXAxisScale(); - const yScale = useYAxisScale(); - const theme = useTheme(); - - if (!xScale || !yScale || emaData.length > NR_OF_WEIGHTS_CHART_DOT) { - return null; - } - - return ( - - {emaData.map(point => { - const x = xScale(point.date) as number; - return ( - point.ema ? theme.palette.error.main : theme.palette.success.main} - strokeWidth={1} - strokeDasharray="2,2" - opacity={0.5} - /> - ); - })} - - ); -}; - -export const WeightChart = ({ weights, height = 300 }: WeightChartProps) => { - const theme = useTheme(); - const [t] = useTranslation(); - - const sortedWeights = [...weights].sort((a, b) => a.date.getTime() - b.date.getTime()); - const weightData = sortedWeights.map(weight => ({ - date: weight.date.getTime(), - weight: weight.weight, - })); - - const emaData = calculateEMA(weightData, 10); - - const meanWeight = weightData.length > 0 - ? weightData.reduce((sum, w) => sum + w.weight, 0) / weightData.length - : 0; - const currentTrend = emaData.length > 0 ? emaData[emaData.length - 1].ema : 0; - - const allWeights = emaData.flatMap(d => [d.weight, d.ema]); - const minWeight = allWeights.length > 0 ? Math.min(...allWeights) : 0; - const maxWeight = allWeights.length > 0 ? Math.max(...allWeights) : 0; - const padding = (maxWeight - minWeight) * 0.1; - const yAxisDomain: [number, number] = [minWeight - padding, maxWeight + padding]; - - return ( -
- {weightData.length > 0 && ( - - - {t('mean')}: {meanWeight.toFixed(1)} - - - {t('currentTrend')}: {currentTrend.toFixed(1)} - - - )} - - - dateToLocale(new Date(timeStr))} - /> - Math.round(value).toString()} /> - - - - - - - - - - NR_OF_WEIGHTS_CHART_DOT - ? false - : { - fill: theme.palette.secondary.main, - stroke: theme.palette.secondary.dark, - strokeWidth: 1, - r: 4 - }} - activeDot={{ - fill: theme.palette.secondary.main, - stroke: theme.palette.secondary.dark, - strokeWidth: 2, - r: 6 - }} - name={t('weight')} - legendType="circle" - /> - - } /> - - -
- ); -}; diff --git a/src/components/Weight/widgets/fab.tsx b/src/components/Weight/widgets/fab.tsx deleted file mode 100644 index a70f33a12..000000000 --- a/src/components/Weight/widgets/fab.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import AddIcon from "@mui/icons-material/Add"; -import { Fab } from "@mui/material"; -import { WeightForm } from "@/components/Weight/forms/WeightForm"; -import { WgerModal } from "@/core/ui/Modals/WgerModal"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; - -export const AddBodyWeightEntryFab = () => { - const [t] = useTranslation(); - const [openModal, setOpenModal] = useState(false); - const handleOpenModal = () => setOpenModal(true); - const handleCloseModal = () => setOpenModal(false); - - return ( -
- `max(${theme.spacing(2)}, calc((100vw - ${theme.breakpoints.values.lg}px) / 2 + ${theme.spacing(2)}))`, - zIndex: 9, - }}> - - - - - -
- ); -}; diff --git a/src/core/lib/consts.ts b/src/core/lib/consts.ts index f427982a0..4a92a98de 100644 --- a/src/core/lib/consts.ts +++ b/src/core/lib/consts.ts @@ -44,9 +44,6 @@ export enum QueryKey { NUTRITIONAL_PLAN_LAST = 'nutritional-plan-last', INGREDIENT = 'ingredient', - // Body weight - BODY_WEIGHT = 'body-weight', - // Profile PROFILE = 'profile', PERMISSION = 'permission', @@ -61,9 +58,13 @@ export enum QueryKey { EQUIPMENT = 'equipment', MUSCLES = 'muscles', - // Measurements + // Measurements (body weight is measurement data and shares these, see + // the body weight queries) MEASUREMENTS = 'measurements', MEASUREMENTS_CATEGORIES = 'measurements-categories', + MEASUREMENT_ENTRIES = 'measurement-entries', + MEASUREMENT_BUCKETS = 'measurement-buckets', + MEASUREMENT_VALUE_COUNTS = 'measurement-value-counts', // Nutrition (search) INGREDIENT_SEARCH = 'ingredient-search', diff --git a/src/core/lib/date.test.ts b/src/core/lib/date.test.ts index 68ee39cfb..ad0ae8db4 100644 --- a/src/core/lib/date.test.ts +++ b/src/core/lib/date.test.ts @@ -1,4 +1,4 @@ -import { calculatePastDate, dateTimeToHHMM, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date"; +import { dateTimeToHHMM, dateToRelative, dateToYYYYMMDD, yyyymmddToDate } from "@/core/lib/date"; /* * All date helpers must behave the same in every timezone, so the whole suite @@ -85,47 +85,23 @@ describe.each([ }); + describe('dateToRelative', () => { + const now = new Date(2026, 7, 7, 9, 0); - describe('calculatePastDate', () => { - - it('should return undefined for empty string filter', () => { - expect(calculatePastDate('', yyyymmddToDate('2023-08-14'))).toBeUndefined(); - }); - - it('should return the correct date for lastWeek filter', () => { - const result = calculatePastDate('lastWeek', yyyymmddToDate('2023-02-14')); - expect(result).toStrictEqual('2023-02-07'); + test('today and yesterday are named, not counted', () => { + expect(dateToRelative(new Date(2026, 7, 7, 0, 30), 'de', now)).toBe('heute'); + // Calendar days, not elapsed hours: late yesterday is yesterday + expect(dateToRelative(new Date(2026, 7, 6, 23, 50), 'de', now)).toBe('gestern'); }); - it('should return the correct date for lastMonth filter', () => { - const result = calculatePastDate('lastMonth', yyyymmddToDate('2023-02-14')); - expect(result).toStrictEqual('2023-01-14'); + test('recent dates count in days', () => { + expect(dateToRelative(new Date(2026, 7, 2), 'de', now)).toBe('vor 5 Tagen'); }); - it('should return the correct date for lastHalfYear filter', () => { - const result = calculatePastDate('lastHalfYear', yyyymmddToDate('2023-08-14')); - expect(result).toStrictEqual('2023-02-14'); - }); - - it('should return the correct date for lastYear filter', () => { - const result = calculatePastDate('lastYear', yyyymmddToDate('2023-02-14')); - expect(result).toStrictEqual('2022-02-14'); - }); - - it('clamps to the last day of the month instead of overflowing', () => { - // Naively subtracting a month from March 31st lands on "February 31st", - // which rolls over into March again - expect(calculatePastDate('lastMonth', yyyymmddToDate('2023-03-31'))).toStrictEqual('2023-02-28'); - expect(calculatePastDate('lastMonth', yyyymmddToDate('2024-03-31'))).toStrictEqual('2024-02-29'); - expect(calculatePastDate('lastHalfYear', yyyymmddToDate('2023-08-31'))).toStrictEqual('2023-02-28'); - }); - - it('does not modify the date it was given', () => { - const date = yyyymmddToDate('2023-02-14'); - - calculatePastDate('lastYear', date); - - expect(dateToYYYYMMDD(date)).toStrictEqual('2023-02-14'); + test('older dates grow to weeks, months and years', () => { + expect(dateToRelative(new Date(2026, 6, 17), 'de', now)).toBe('vor 3 Wochen'); + expect(dateToRelative(new Date(2026, 5, 1), 'de', now)).toBe('vor 2 Monaten'); + expect(dateToRelative(new Date(2024, 7, 1), 'de', now)).toBe('vor 2 Jahren'); }); }); }); diff --git a/src/core/lib/date.ts b/src/core/lib/date.ts index a69cbeec5..17f16058a 100644 --- a/src/core/lib/date.ts +++ b/src/core/lib/date.ts @@ -1,4 +1,3 @@ -import { FilterType } from "@/components/Weight/widgets/FilterButtons"; import i18n from 'i18next'; import { DateTime, DateTimeFormatOptions } from "luxon"; @@ -11,6 +10,35 @@ export function isSameDay(date1: Date, date2: Date): boolean { ); } +/* + * A date as a relative phrase ("today", "3 weeks ago"), in the locale's own + * words via Intl. + * + * Counts calendar days rather than elapsed hours, so an entry from late + * yesterday still reads as yesterday this morning. The unit grows with the + * distance: days within a week, then weeks, months, years. + */ +export function dateToRelative(date: Date, locale?: string, now: Date = new Date()): string { + const dayMs = 24 * 60 * 60 * 1000; + // Rounded because a DST day is 23 or 25 hours long + const days = Math.round(( + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() + - new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + ) / dayMs); + + const format = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + if (Math.abs(days) < 7) { + return format.format(-days, 'day'); + } + if (Math.abs(days) < 31) { + return format.format(-Math.round(days / 7), 'week'); + } + if (Math.abs(days) < 365) { + return format.format(-Math.round(days / 30), 'month'); + } + return format.format(-Math.round(days / 365), 'year'); +} + /* * Util function that converts a date to a YYYY-MM-DD string * @@ -134,32 +162,3 @@ export function HHMMToDateTime(time: string | null) { return dateTime; } - -/* - * Util function that calculates a date in the past based on a string filter - * and returns it as a YYYY-MM-DD string for API queries. - * - * @param filter - A string representing the desired time period (e.g., 'lastWeek', 'lastMonth') - * @param currentDate - (Optional) The current date to base calculations on. Defaults to `new Date()`. - * This parameter allows for testing or custom date bases. - * @returns - Date string in the format YYYY-MM-DD or undefined for no filtering - */ -export function calculatePastDate(filter: FilterType, currentDate: Date = new Date()): string | undefined { - - // Luxon clamps to the last day of the target month (March 31st minus one - // month is February 28th) and leaves the passed in date untouched, both of - // which the native setMonth/setDate can't do. - const base = DateTime.fromJSDate(currentDate); - - const filterMap: Record = { - lastWeek: base.minus({ weeks: 1 }), - lastMonth: base.minus({ months: 1 }), - lastHalfYear: base.minus({ months: 6 }), - lastYear: base.minus({ years: 1 }), - '': undefined - }; - - const pastDate = filterMap[filter]; - - return pastDate ? dateToYYYYMMDD(pastDate.toJSDate()) : undefined; -} \ No newline at end of file diff --git a/src/components/Weight/widgets/WeightChart/ema.test.ts b/src/core/lib/ema.test.ts similarity index 82% rename from src/components/Weight/widgets/WeightChart/ema.test.ts rename to src/core/lib/ema.test.ts index 831b8f361..91d3050f2 100644 --- a/src/components/Weight/widgets/WeightChart/ema.test.ts +++ b/src/core/lib/ema.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from 'vitest'; import { calculateEMA } from './ema'; +const byWeight = (p: { weight: number }) => p.weight; + describe('calculateEMA', () => { test('returns an empty array for empty input', () => { - expect(calculateEMA([])).toEqual([]); + expect(calculateEMA([], byWeight)).toEqual([]); }); test('first point ema equals the first weight', () => { - const result = calculateEMA([{ date: 1, weight: 80 }]); + const result = calculateEMA([{ date: 1, weight: 80 }], byWeight); expect(result).toEqual([{ date: 1, weight: 80, ema: 80 }]); }); @@ -16,7 +18,7 @@ describe('calculateEMA', () => { { date: 1, weight: 100 }, { date: 2, weight: 100 }, { date: 3, weight: 100 }, - ]); + ], byWeight); expect(result.map(p => p.ema)).toEqual([100, 100, 100]); }); @@ -25,7 +27,7 @@ describe('calculateEMA', () => { const s = 2 / (period + 1); const weights = [{ date: 1, weight: 80 }, { date: 2, weight: 90 }]; - const result = calculateEMA(weights, period); + const result = calculateEMA(weights, byWeight, period); expect(result[1].ema).toBeCloseTo(90 * s + 80 * (1 - s), 10); }); @@ -33,6 +35,7 @@ describe('calculateEMA', () => { test('honors a custom period', () => { const result = calculateEMA( [{ date: 1, weight: 80 }, { date: 2, weight: 90 }], + byWeight, 2, ); // s = 2/3, ema[1] = 90 * 2/3 + 80 * 1/3 = 86.666... @@ -43,7 +46,7 @@ describe('calculateEMA', () => { const result = calculateEMA([ { date: 1, weight: 80, label: 'a' }, { date: 2, weight: 82, label: 'b' }, - ]); + ], byWeight); expect(result[0].label).toBe('a'); expect(result[1].label).toBe('b'); }); diff --git a/src/core/lib/ema.ts b/src/core/lib/ema.ts new file mode 100644 index 000000000..2bc4d31cc --- /dev/null +++ b/src/core/lib/ema.ts @@ -0,0 +1,27 @@ +export interface TimeSeriesPoint { + date: number; +} + +/** + * Exponentially weighted moving average over a chronologically ordered series. + * Smoothing factor is 2 / (period + 1), e.g. period=10 gives ~0.18. + */ +export const calculateEMA = ( + points: T[], + getValue: (point: T) => number, + period: number = 10, +): (T & { ema: number })[] => { + if (points.length === 0) { + return []; + } + + const smoothing = 2 / (period + 1); + let ema = getValue(points[0]); + + return points.map((point, i) => { + if (i > 0) { + ema = getValue(point) * smoothing + ema * (1 - smoothing); + } + return { ...point, ema }; + }); +}; diff --git a/src/core/lib/numbers.ts b/src/core/lib/numbers.ts index 18be4d508..b1b926111 100644 --- a/src/core/lib/numbers.ts +++ b/src/core/lib/numbers.ts @@ -12,6 +12,15 @@ export function numberLocale(num: number, locale: string) { return num.toLocaleString(locale, { maximumFractionDigits: 0 }); } +/* + * Formats a number, localised, with up to [maxDecimals] fraction digits. The + * default keeps as many as the server stores, and few enough to hide the + * artefacts of summing floats + */ +export function numberDecimalLocale(num: number, locale: string, maxDecimals: number = 2) { + return num.toLocaleString(locale, { maximumFractionDigits: maxDecimals }); +} + /* * Formats a number with a unit, localised, no fraction digits * diff --git a/src/core/lib/weightUnit.test.ts b/src/core/lib/weightUnit.test.ts new file mode 100644 index 000000000..a31f03094 --- /dev/null +++ b/src/core/lib/weightUnit.test.ts @@ -0,0 +1,19 @@ +import { convertWeight } from "./weightUnit"; + +describe('convertWeight', () => { + + test('returns the value unchanged for the same unit', () => { + expect(convertWeight(81.234, 'kg', 'kg')).toBe(81.234); + expect(convertWeight(180.5, 'lb', 'lb')).toBe(180.5); + }); + + test('converts lb to kg, quantized to 2 decimals', () => { + expect(convertWeight(90, 'lb', 'kg')).toBe(40.82); + expect(convertWeight(1, 'lb', 'kg')).toBe(0.45); + }); + + test('converts kg to lb, quantized to 2 decimals', () => { + expect(convertWeight(80, 'kg', 'lb')).toBe(176.37); + expect(convertWeight(1, 'kg', 'lb')).toBe(2.2); + }); +}); diff --git a/src/core/lib/weightUnit.ts b/src/core/lib/weightUnit.ts new file mode 100644 index 000000000..e7f5cac00 --- /dev/null +++ b/src/core/lib/weightUnit.ts @@ -0,0 +1,49 @@ +export type WeightUnit = 'kg' | 'lb'; + +// Mirror the server's constants (wger/utils/units.py), both of them: a +// division by the other factor is a hair off and could round differently +export const KG_PER_LB = 0.45359237; +export const LB_PER_KG = 2.20462262; + +/* + * Narrows a stored or server-provided unit. Everything else is a free-text + * category label, which is never converted. + */ +export function isWeightUnit(value: unknown): value is WeightUnit { + return value === 'kg' || value === 'lb'; +} + +/* + * Converts a body weight value between kg and lb, quantized to 2 decimal + * places like the server. Free-text units of custom measurement categories + * are never converted, they are plain labels. + */ +export function convertWeight(value: number, from: WeightUnit, to: WeightUnit): number { + if (from === to) { + return value; + } + const converted = from === 'lb' ? value * KG_PER_LB : value * LB_PER_KG; + + return Math.round(converted * 100) / 100; +} + +/* + * Reads a stored value in the target unit: the unit it was entered in wins, + * the category unit fills in, and anything that is not a weight is a plain + * label and passes through. + * + * The one place that decides what a stored number means. A category can hold + * mixed units, so the raw value on its own is meaningless. + */ +export function convertStoredValue( + value: number, + storedUnit: string | null | undefined, + categoryUnit: string, + targetUnit: string, +): number { + const from = storedUnit || categoryUnit; + + return isWeightUnit(from) && isWeightUnit(targetUnit) + ? convertWeight(value, from, targetUnit) + : value; +} diff --git a/src/core/ui/Widgets/Container.tsx b/src/core/ui/Widgets/Container.tsx index de6db4d33..cb35559df 100644 --- a/src/core/ui/Widgets/Container.tsx +++ b/src/core/ui/Widgets/Container.tsx @@ -67,6 +67,7 @@ type WgerTemplateContainerFullWidthProps = { backToUrl?: string; optionsMenu?: ReactElement; maxWidth?: false | Breakpoint | undefined + fab?: ReactElement; }; export const WgerContainerFullWidth = (props: WgerTemplateContainerFullWidthProps) => { @@ -93,6 +94,7 @@ export const WgerContainerFullWidth = (props: WgerTemplateContainerFullWidthProp {props.children} + {props.fab} ); }; \ No newline at end of file diff --git a/src/pages/MeasurementDetail/index.tsx b/src/pages/MeasurementDetail/index.tsx new file mode 100644 index 000000000..403e8d905 --- /dev/null +++ b/src/pages/MeasurementDetail/index.tsx @@ -0,0 +1,19 @@ +import { correlatesWithNutrition, MeasurementCategoryDetail, useMeasurementsQuery } from "@/components/Measurements"; +import { useNutritionPlanPeriods } from "@/components/Nutrition"; +import React from 'react'; +import { useParams } from "react-router-dom"; + +/** + * Reads the plans the detail chart shades. Whether they are worth reading + * depends on the category, asked for here as well and answered from the cache. + */ +export const MeasurementDetail = () => { + const params = useParams<{ categoryId: string }>(); + const categoryId = params.categoryId ?? ''; + const categoryQuery = useMeasurementsQuery(categoryId, categoryId !== ''); + const planPeriods = useNutritionPlanPeriods( + correlatesWithNutrition(categoryQuery.data?.metricType ?? 'custom'), + ); + + return ; +}; diff --git a/src/pages/WeightOverview/index.tsx b/src/pages/WeightOverview/index.tsx index 48b6d0eb7..a98c27bbc 100644 --- a/src/pages/WeightOverview/index.tsx +++ b/src/pages/WeightOverview/index.tsx @@ -1,6 +1,10 @@ -import { BodyWeight } from "@/components/Weight"; +import { BodyWeight } from "@/components/Measurements"; +import { useNutritionPlanPeriods } from "@/components/Nutrition"; import React from 'react'; +/** Reads the plans the weight chart shades */ export const WeightOverview = () => { - return ; -}; \ No newline at end of file + const planPeriods = useNutritionPlanPeriods(); + + return ; +}; diff --git a/src/pages/index.ts b/src/pages/index.ts index b21e19db3..bd12806e8 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -6,6 +6,7 @@ export { CaloriesCalculator } from './CaloriesCalculator'; export { Equipments } from './Equipments'; export { Ingredients } from './Ingredients'; export { Login } from './Login'; +export { MeasurementDetail } from './MeasurementDetail'; export { Preferences } from './Preferences'; export { ApiPage } from './ApiPage'; export { WeightOverview } from './WeightOverview'; diff --git a/src/routes.tsx b/src/routes.tsx index f960002e6..693092036 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,6 +1,6 @@ import { ConfigurableDashboard } from "@/components/Dashboard/ConfigurableDashboard"; import { ExerciseOverview } from "@/components/Exercises"; -import { MeasurementCategoryDetail, MeasurementCategoryOverview } from "@/components/Measurements"; +import { MeasurementCategoryOverview } from "@/components/Measurements"; import { BmiCalculator, NutritionDiaryOverview, PlanDetail, PlansOverview } from "@/components/Nutrition"; import { PrivateTemplateOverview, @@ -27,6 +27,7 @@ import { Equipments, Ingredients, Login, + MeasurementDetail, Preferences, WeightOverview, } from "@/pages"; @@ -78,7 +79,7 @@ export const WgerRoutes = () => { } /> } /> - } /> + } /> } /> diff --git a/src/tests/chartQueries.ts b/src/tests/chartQueries.ts new file mode 100644 index 000000000..b6b989aa7 --- /dev/null +++ b/src/tests/chartQueries.ts @@ -0,0 +1,104 @@ +import { MeasurementBucket, MeasurementValueCount } from "@/components/Measurements/models/Bucket"; +import { isSummedPerDay, MeasurementCategory } from "@/components/Measurements/models/Category"; +import { MeasurementEntry } from "@/components/Measurements/models/Entry"; +import { + useMeasurementBucketsQuery, + useMeasurementValueCountsQuery +} from "@/components/Measurements/queries"; +import type { Mock } from 'vitest'; + +/** A category and the entries the server holds for it */ +export type CategorySeed = { + category: MeasurementCategory, + entries?: MeasurementEntry[], +} + +/** + * Answers the aggregated chart reads from the entries of the seeded categories. + * + * The charts read those condensed rather than as entries, and a test that + * seeds a history would otherwise have to build the condensed shapes by hand. + * One bucket per entry, which is what the server returns for a series short + * enough not to be condensed, and daily totals for the summed metrics, which + * it condenses whatever the count. + * + * The components of a group are seeded like any other category: they are ones, + * and the chart asks for them by id. + */ +export const mockChartQueries = (seeds: CategorySeed[]) => { + const byId = new Map(seeds.map(seed => [seed.category.id, seed])); + + (useMeasurementBucketsQuery as Mock).mockImplementation((ids: string[]) => ({ + data: ids.flatMap(id => bucketsFor(byId.get(id))), + })); + + (useMeasurementValueCountsQuery as Mock).mockImplementation((id: string) => ({ + data: valueCountsFor(byId.get(id)), + })); +}; + +const startOf = (entry: MeasurementEntry, summed: boolean): number => { + const date = entry.date; + + return summed + ? new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() + : date.getTime(); +}; + +const groupBy = (items: T[], key: (item: T) => number): Map => { + const out = new Map(); + for (const item of items) { + out.set(key(item), [...(out.get(key(item)) ?? []), item]); + } + + return out; +}; + +/** + * The buckets the server returns for a category: one per entry, or daily + * totals for the summed metrics, which it condenses whatever the count. + */ +export const bucketsFor = (seed: CategorySeed | undefined): MeasurementBucket[] => { + if (seed === undefined) { + return []; + } + const summed = isSummedPerDay(seed.category.metricType); + + return [...groupBy(seed.entries ?? [], entry => startOf(entry, summed)).entries()] + .map(([start, entries]) => new MeasurementBucket( + seed.category.id!, + new Date(start), + (entries[0].extraData.unit as string) ?? null, + entries.length, + entries.reduce((sum, entry) => sum + entry.value, 0), + Math.min(...entries.map(entry => (entry.extraData.min as number) ?? entry.value)), + Math.max(...entries.map(entry => (entry.extraData.max as number) ?? entry.value)), + )) + .sort((a, b) => a.start.getTime() - b.start.getTime()); +}; + +const valueCountsFor = (seed: CategorySeed | undefined): MeasurementValueCount[] => { + if (seed === undefined) { + return []; + } + const entries = seed.entries ?? []; + const summed = isSummedPerDay(seed.category.metricType); + + // A summed metric distributes its daily totals, the sample types every + // reading, which is the split the server makes + const values = summed + ? [...groupBy(entries, entry => startOf(entry, true)).values()].map(entries => ({ + value: entries.reduce((sum, entry) => sum + entry.value, 0), + newest: new Date(Math.max(...entries.map(entry => entry.date.getTime()))), + })) + : entries.map(entry => ({ value: entry.value, newest: entry.date })); + + return [...groupBy(values, item => item.value).entries()].map(([value, items]) => + new MeasurementValueCount( + seed.category.id!, + value, + null, + items.length, + new Date(Math.max(...items.map(item => item.newest.getTime()))), + )); +}; diff --git a/src/tests/measurementsTestData.ts b/src/tests/measurementsTestData.ts index 40df955ef..8c161efe1 100644 --- a/src/tests/measurementsTestData.ts +++ b/src/tests/measurementsTestData.ts @@ -33,7 +33,6 @@ export const TEST_MEASUREMENT_CATEGORY_1 = new MeasurementCategory( CATEGORY_1, "Biceps", "cm", - TEST_MEASUREMENT_ENTRIES_1, ); @@ -41,5 +40,15 @@ export const TEST_MEASUREMENT_CATEGORY_2 = new MeasurementCategory( CATEGORY_2, "Body fat", "%", - TEST_MEASUREMENT_ENTRIES_2 ); + +/** A category with its history, for the mocked chart reads */ +export const TEST_MEASUREMENT_SEED_1 = { + category: TEST_MEASUREMENT_CATEGORY_1, + entries: TEST_MEASUREMENT_ENTRIES_1, +}; + +export const TEST_MEASUREMENT_SEED_2 = { + category: TEST_MEASUREMENT_CATEGORY_2, + entries: TEST_MEASUREMENT_ENTRIES_2, +}; diff --git a/src/tests/mutationMock.ts b/src/tests/mutationMock.ts new file mode 100644 index 000000000..1c9e6ab97 --- /dev/null +++ b/src/tests/mutationMock.ts @@ -0,0 +1,19 @@ +import { vi } from "vitest"; + +interface MutateOptions { + onSuccess?: () => void; + onError?: (error: unknown) => void; +} + +/** + * A mutate() mock that runs the success callback the caller passed. + * + * Forms close from that callback rather than right after firing, so a mock that + * ignores it never closes and the test sees a form that stayed open. + */ +export const mutateMock = () => + vi.fn((_payload: unknown, options?: MutateOptions) => options?.onSuccess?.()); + +/** The same, for a mutation that is expected to fail */ +export const failingMutateMock = (error: unknown = new Error('rejected')) => + vi.fn((_payload: unknown, options?: MutateOptions) => options?.onError?.(error)); diff --git a/src/tests/unitsTestData.ts b/src/tests/unitsTestData.ts new file mode 100644 index 000000000..3e2838f6f --- /dev/null +++ b/src/tests/unitsTestData.ts @@ -0,0 +1,20 @@ +import { RepetitionUnit } from "@/components/Routines/models/RepetitionUnit"; +import { WeightUnit } from "@/components/Routines/models/WeightUnit"; + +/* + * The units logs and slot entries are measured in. Their own module because + * both the routine and the log fixtures need them, and reading them out of + * each other left whichever loaded second with undefined units. + */ + +export const testWeightUnitKg = new WeightUnit(1, "kg"); +export const testWeightUnitLb = new WeightUnit(2, "lb"); +export const testWeightUnitPlates = new WeightUnit(3, "Plates"); + +export const testWeightUnits = [testWeightUnitKg, testWeightUnitLb, testWeightUnitPlates]; + +export const testRepUnitRepetitions = new RepetitionUnit(1, "Repetitions"); +export const testRepUnitUnitFailure = new RepetitionUnit(2, "Unit failure"); +export const testRepUnitUnitMinutes = new RepetitionUnit(3, "Minutes"); + +export const testRepetitionUnits = [testRepUnitRepetitions, testRepUnitUnitFailure, testRepUnitUnitMinutes]; diff --git a/src/tests/weight/testData.ts b/src/tests/weight/testData.ts index 27dfdcdf4..9d4e1eac4 100644 --- a/src/tests/weight/testData.ts +++ b/src/tests/weight/testData.ts @@ -1,7 +1,39 @@ -import { WeightEntry } from "@/components/Weight/models/WeightEntry"; +import { MeasurementCategory, MeasurementEntry, METRIC_TYPE_BODY_WEIGHT } from "@/components/Measurements"; +import { WeightUnit } from "@/core/lib/weightUnit"; -export const testWeightEntry1 = new WeightEntry(new Date('2023-11-01'), 100, 1); -export const testWeightEntry2 = new WeightEntry(new Date('2023-10-01'), 90, 2); -export const testWeightEntry3 = new WeightEntry(new Date('2023-09-01'), 110, 3); +export const TEST_BODY_WEIGHT_CATEGORY_UUID = 'cccccccc-cccc-cccc-cccc-000000000042'; -export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3]; \ No newline at end of file +export const testBodyWeightCategory = new MeasurementCategory( + TEST_BODY_WEIGHT_CATEGORY_UUID, + 'Body weight', + 'kg', + METRIC_TYPE_BODY_WEIGHT, + true, +); + +/** + * A body weight entry as the API delivers it. The unit an entry is stored in + * travels in extra_data; without it the category unit applies. + */ +export const makeWeightEntry = ( + date: Date, + value: number, + options: { id?: string, unit?: WeightUnit, source?: string, extraData?: Record } = {}, +) => new MeasurementEntry( + options.id ?? null, + TEST_BODY_WEIGHT_CATEGORY_UUID, + date, + value, + '', + options.source ?? 'user', + { ...options.extraData, ...(options.unit ? { unit: options.unit } : {}) }, +); + +const weightEntry = (id: string, date: string, value: number) => + makeWeightEntry(new Date(date), value, { id: id }); + +export const testWeightEntry1 = weightEntry('dddddddd-dddd-dddd-dddd-000000000001', '2023-11-01', 100); +export const testWeightEntry2 = weightEntry('dddddddd-dddd-dddd-dddd-000000000002', '2023-10-01', 90); +export const testWeightEntry3 = weightEntry('dddddddd-dddd-dddd-dddd-000000000003', '2023-09-01', 110); + +export const testWeightEntries = [testWeightEntry1, testWeightEntry2, testWeightEntry3]; diff --git a/src/tests/workoutLogsRoutinesTestData.ts b/src/tests/workoutLogsRoutinesTestData.ts index 796056380..628c4eb11 100644 --- a/src/tests/workoutLogsRoutinesTestData.ts +++ b/src/tests/workoutLogsRoutinesTestData.ts @@ -1,7 +1,7 @@ import { WorkoutLog } from "@/components/Routines/models/WorkoutLog"; import { WorkoutSession } from "@/components/Routines/models/WorkoutSession"; import { testExerciseSquats } from "@/tests/exerciseTestdata"; -import { testRepUnitRepetitions, testWeightUnitKg } from "@/tests/workoutRoutinesTestData"; +import { testRepUnitRepetitions, testWeightUnitKg } from "@/tests/unitsTestData"; const testWorkoutLog1 = new WorkoutLog({ id: 'aaaaaaaa-aaaa-aaaa-aaaa-000000000005', diff --git a/src/tests/workoutRoutinesTestData.ts b/src/tests/workoutRoutinesTestData.ts index eeec2b12b..49059b9bb 100644 --- a/src/tests/workoutRoutinesTestData.ts +++ b/src/tests/workoutRoutinesTestData.ts @@ -14,18 +14,6 @@ import { yyyymmddToDate } from "@/core/lib/date"; import { testExerciseBenchPress, testExerciseSquats } from "@/tests/exerciseTestdata"; import { testWorkoutLogs } from "@/tests/workoutLogsRoutinesTestData"; -export const testWeightUnitKg = new WeightUnit(1, "kg"); -export const testWeightUnitLb = new WeightUnit(2, "lb"); -export const testWeightUnitPlates = new WeightUnit(3, "Plates"); - -export const testWeightUnits = [testWeightUnitKg, testWeightUnitLb, testWeightUnitPlates]; - -export const testRepUnitRepetitions = new RepetitionUnit(1, "Repetitions"); -export const testRepUnitUnitFailure = new RepetitionUnit(2, "Unit failure"); -export const testRepUnitUnitMinutes = new RepetitionUnit(3, "Minutes"); - -export const testRepetitionUnits = [testRepUnitRepetitions, testRepUnitUnitFailure, testRepUnitUnitMinutes]; - export const testDayLegs = new Day({ id: 5, routineId: 1, diff --git a/src/types.ts b/src/types.ts index a78dad30e..9993787d8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,5 @@ import { ApiIngredientThumbnailType } from "@/components/Nutrition/models/IngredientImageThumbnails"; -export interface ApiBodyWeightType { - id: number, - date: string, - weight: string, -} - export interface ApiMuscleType { id: number, name: string, @@ -50,7 +44,11 @@ export interface ApiAliasType { export interface ApiMeasurementCategoryType { id: string, name: string, - unit: string + unit: string, + metric_type: string, + is_official: boolean, + parent: string | null, + order: number, } export const NUTRI_SCORES = ['a', 'b', 'c', 'd', 'e'] as const; @@ -129,9 +127,12 @@ export interface ApiNutritionalPlanType { export interface ApiMeasurementEntryType { id: string, category: string, - date: Date, + date: string, value: number, - notes: string + notes: string, + source: string, + external_id: string | null, + extra_data: { unit?: string, [key: string]: unknown }, } export interface ApiEquipmentType {