@wger-project/react-components 25.11.22 → 26.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/build/assets/ajax-loader.gif +0 -0
  2. package/build/assets/index.css +1 -1
  3. package/build/assets/slick.svg +14 -0
  4. package/build/locales/de/translation.json +25 -6
  5. package/build/locales/en/translation.json +12 -0
  6. package/build/locales/es/translation.json +22 -3
  7. package/build/locales/fr/translation.json +10 -1
  8. package/build/locales/hi/translation.json +8 -0
  9. package/build/locales/nl/translation.json +350 -239
  10. package/build/locales/pt_BR/translation.json +349 -255
  11. package/build/locales/ru/translation.json +39 -3
  12. package/build/locales/uk/translation.json +10 -1
  13. package/build/main.js +170 -166
  14. package/build/main.js.map +1 -1
  15. package/package.json +8 -2
  16. package/src/components/BodyWeight/TableDashboard/TableDashboard.tsx +4 -6
  17. package/src/components/Calendar/Components/CalendarComponent.test.tsx +18 -22
  18. package/src/components/Calendar/Components/CalendarComponent.tsx +11 -8
  19. package/src/components/Calendar/Components/CalendarHeader.tsx +3 -3
  20. package/src/components/Calendar/Components/Entries.tsx +8 -3
  21. package/src/components/Dashboard/CalendarCard.tsx +16 -0
  22. package/src/components/Dashboard/ConfigurableDashboard.test.ts +128 -0
  23. package/src/components/Dashboard/ConfigurableDashboard.tsx +479 -0
  24. package/src/components/Dashboard/DashboardCard.tsx +122 -0
  25. package/src/components/Dashboard/EmptyCard.tsx +3 -3
  26. package/src/components/Dashboard/MeasurementCard.test.tsx +75 -0
  27. package/src/components/Dashboard/MeasurementCard.tsx +101 -0
  28. package/src/components/Dashboard/NutritionCard.tsx +88 -96
  29. package/src/components/Dashboard/RoutineCard.tsx +54 -69
  30. package/src/components/Dashboard/TrophiesCard.test.tsx +63 -0
  31. package/src/components/Dashboard/TrophiesCard.tsx +84 -0
  32. package/src/components/Dashboard/WeightCard.test.tsx +0 -10
  33. package/src/components/Dashboard/WeightCard.tsx +36 -42
  34. package/src/components/Exercises/Detail/Head/ExerciseDeleteDialog.tsx +1 -1
  35. package/src/components/Measurements/Screens/MeasurementCategoryOverview.tsx +1 -1
  36. package/src/components/Measurements/models/Category.ts +13 -2
  37. package/src/components/Measurements/models/Entry.ts +13 -2
  38. package/src/components/Trophies/components/TrophiesDetail.test.tsx +34 -0
  39. package/src/components/Trophies/components/TrophiesDetail.tsx +88 -0
  40. package/src/components/Trophies/models/trophy.test.ts +33 -0
  41. package/src/components/Trophies/models/trophy.ts +75 -0
  42. package/src/components/Trophies/models/userTrophy.test.ts +38 -0
  43. package/src/components/Trophies/models/userTrophy.ts +67 -0
  44. package/src/components/Trophies/models/userTrophyProgression.test.ts +43 -0
  45. package/src/components/Trophies/models/userTrophyProgression.ts +68 -0
  46. package/src/components/Trophies/queries/trophies.ts +31 -0
  47. package/src/components/Trophies/services/trophies.ts +22 -0
  48. package/src/components/Trophies/services/userTrophies.ts +33 -0
  49. package/src/components/Trophies/services/userTrophyProgression.ts +16 -0
  50. package/src/components/WorkoutRoutines/Detail/WorkoutStats.tsx +1 -1
  51. package/src/components/WorkoutRoutines/widgets/forms/DayTypeSelect.tsx +1 -2
  52. package/src/components/WorkoutRoutines/widgets/forms/SlotForm.tsx +0 -4
  53. package/src/components/index.ts +0 -2
  54. package/src/index.tsx +0 -46
  55. package/src/pages/Calendar/index.tsx +2 -2
  56. package/src/pages/WeightOverview/index.tsx +1 -1
  57. package/src/routes.tsx +87 -79
  58. package/src/services/exerciseTranslation.ts +5 -6
  59. package/src/services/measurements.ts +10 -17
  60. package/src/services/video.test.ts +4 -4
  61. package/src/tests/trophies/trophiesTestData.ts +80 -0
  62. package/src/utils/consts.ts +18 -3
  63. package/src/utils/url.test.ts +32 -1
  64. package/src/utils/url.ts +24 -3
  65. package/src/components/Carousel/carousel.module.css +0 -43
  66. package/src/components/Carousel/carousel.module.css.map +0 -1
  67. package/src/components/Carousel/carousel.module.scss +0 -46
  68. package/src/components/Carousel/index.tsx +0 -66
  69. package/src/components/Dashboard/Dashboard.tsx +0 -22
@@ -6,7 +6,6 @@ import { testQueryClient } from "tests/queryClient";
6
6
  import { testWeightEntries } from "tests/weight/testData";
7
7
 
8
8
  jest.mock("components/BodyWeight/queries");
9
- const { ResizeObserver } = window;
10
9
 
11
10
  describe("test the WeightCard component", () => {
12
11
 
@@ -17,18 +16,9 @@ describe("test the WeightCard component", () => {
17
16
  isLoading: false,
18
17
  data: testWeightEntries
19
18
  }));
20
-
21
- // @ts-ignore
22
- delete window.ResizeObserver;
23
- window.ResizeObserver = jest.fn().mockImplementation(() => ({
24
- observe: jest.fn(),
25
- unobserve: jest.fn(),
26
- disconnect: jest.fn()
27
- }));
28
19
  });
29
20
 
30
21
  afterEach(() => {
31
- window.ResizeObserver = ResizeObserver;
32
22
  jest.restoreAllMocks();
33
23
  jest.useRealTimers();
34
24
  });
@@ -1,5 +1,5 @@
1
1
  import AddIcon from "@mui/icons-material/Add";
2
- import { Box, Button, Card, CardActions, CardContent, CardHeader, IconButton, } from '@mui/material';
2
+ import { Box, Button, IconButton } from "@mui/material";
3
3
  import Tooltip from "@mui/material/Tooltip";
4
4
  import { WeightForm } from "components/BodyWeight/Form/WeightForm";
5
5
  import { WeightEntry } from "components/BodyWeight/model";
@@ -9,64 +9,58 @@ import { WeightChart } from "components/BodyWeight/WeightChart";
9
9
  import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget";
10
10
  import { WgerModal } from "components/Core/Modals/WgerModal";
11
11
  import { EmptyCard } from "components/Dashboard/EmptyCard";
12
- import React from 'react';
12
+ import React from "react";
13
13
  import { useTranslation } from "react-i18next";
14
14
  import { makeLink, WgerLink } from "utils/url";
15
+ import { DashboardCard } from "./DashboardCard";
15
16
 
16
17
  export const WeightCard = () => {
17
-
18
18
  const [t] = useTranslation();
19
- const weightyQuery = useBodyWeightQuery('lastYear');
19
+ const weightyQuery = useBodyWeightQuery("lastYear");
20
20
 
21
21
  if (weightyQuery.isLoading) {
22
22
  return <LoadingPlaceholder />;
23
23
  }
24
24
 
25
- return weightyQuery.data?.length !== undefined && weightyQuery.data?.length > 0
26
- ? <WeightCardContent entries={weightyQuery.data} />
27
- : <EmptyCard
28
- title={t('weight')}
29
- modalContent={<WeightForm />}
30
- />;
25
+ return weightyQuery.data?.length !== undefined && weightyQuery.data?.length > 0 ? (
26
+ <WeightCardContent entries={weightyQuery.data} />
27
+ ) : (
28
+ <EmptyCard title={t("weight")} modalContent={<WeightForm />} />
29
+ );
31
30
  };
32
31
  export const WeightCardContent = (props: { entries: WeightEntry[] }) => {
33
-
34
32
  const [openModal, setOpenModal] = React.useState(false);
35
33
  const handleOpenModal = () => setOpenModal(true);
36
34
  const handleCloseModal = () => setOpenModal(false);
37
35
  const [t, i18n] = useTranslation();
38
36
 
39
- return (<>
40
- <Card>
41
- <CardHeader
42
- title={t('weight')}
43
- subheader={'.'}
44
- />
45
- <CardContent sx={{ height: '500px', overflow: 'auto' }}>
37
+ return (
38
+ <>
39
+ <DashboardCard
40
+ title={t("weight")}
41
+ subheader={"."}
42
+ actions={
43
+ <>
44
+ <Button size="small" href={makeLink(WgerLink.WEIGHT_OVERVIEW, i18n.language)}>
45
+ {t("seeDetails")}
46
+ </Button>
47
+ <Tooltip title={t("addEntry")}>
48
+ <IconButton onClick={handleOpenModal}>
49
+ <AddIcon />
50
+ </IconButton>
51
+ </Tooltip>
52
+ </>
53
+ }
54
+ >
46
55
  <WeightChart weights={props.entries} height={200} />
47
- <Box sx={{ mt: 2, }}>
56
+ <Box sx={{ mt: 2 }}>
48
57
  <WeightTableDashboard weights={props.entries} />
49
58
  </Box>
50
- </CardContent>
51
- <CardActions sx={{
52
- justifyContent: "space-between",
53
- alignItems: "flex-start",
54
- }}>
55
- <Button
56
- size="small"
57
- href={makeLink(WgerLink.WEIGHT_OVERVIEW, i18n.language)}
58
- >
59
- {t('seeDetails')}
60
- </Button>
61
- <Tooltip title={t('addEntry')}>
62
- <IconButton onClick={handleOpenModal}>
63
- <AddIcon />
64
- </IconButton>
65
- </Tooltip>
66
- </CardActions>
67
- </Card>
68
- <WgerModal title={t('add')} isOpen={openModal} closeFn={handleCloseModal}>
69
- <WeightForm closeFn={handleCloseModal} />
70
- </WgerModal>
71
- </>);
72
- };
59
+ </DashboardCard>
60
+
61
+ <WgerModal title={t("add")} isOpen={openModal} closeFn={handleCloseModal}>
62
+ <WeightForm closeFn={handleCloseModal} />
63
+ </WgerModal>
64
+ </>
65
+ );
66
+ };
@@ -65,7 +65,7 @@ export function ExerciseDeleteDialog(props: {
65
65
  try {
66
66
  const exercise = await getExercise(id);
67
67
  setReplacementExercise(exercise);
68
- } catch (e) {
68
+ } catch {
69
69
  setReplacementExercise(null);
70
70
  }
71
71
  }
@@ -15,7 +15,7 @@ import { EntryForm } from "components/Measurements/widgets/EntryForm";
15
15
  import { WgerModal } from "components/Core/Modals/WgerModal";
16
16
 
17
17
 
18
- const CategoryList = (props: { category: MeasurementCategory }) => {
18
+ export const CategoryList = (props: { category: MeasurementCategory }) => {
19
19
 
20
20
  const [t, i18n] = useTranslation();
21
21
  const [openModal, setOpenModal] = React.useState(false);
@@ -15,10 +15,19 @@ export class MeasurementCategory {
15
15
  this.entries = entries;
16
16
  }
17
17
  }
18
+
19
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
+ static fromJson(json: any): MeasurementCategory {
21
+ return adapter.fromJson(json);
22
+ }
23
+
24
+ toJson() {
25
+ return adapter.toJson(this);
26
+ }
18
27
  }
19
28
 
20
29
 
21
- export class MeasurementCategoryAdapter implements Adapter<MeasurementCategory> {
30
+ class MeasurementCategoryAdapter implements Adapter<MeasurementCategory> {
22
31
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
32
  fromJson(item: any) {
24
33
  return new MeasurementCategory(
@@ -35,4 +44,6 @@ export class MeasurementCategoryAdapter implements Adapter<MeasurementCategory>
35
44
  unit: item.unit,
36
45
  };
37
46
  }
38
- }
47
+ }
48
+
49
+ const adapter = new MeasurementCategoryAdapter();
@@ -10,10 +10,19 @@ export class MeasurementEntry {
10
10
  public notes: string
11
11
  ) {
12
12
  }
13
+
14
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
15
+ static fromJson(json: any): MeasurementEntry {
16
+ return adapter.fromJson(json);
17
+ }
18
+
19
+ toJson() {
20
+ return adapter.toJson(this);
21
+ }
13
22
  }
14
23
 
15
24
 
16
- export class MeasurementEntryAdapter implements Adapter<MeasurementEntry> {
25
+ class MeasurementEntryAdapter implements Adapter<MeasurementEntry> {
17
26
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
27
  fromJson(item: any) {
19
28
  return new MeasurementEntry(
@@ -34,4 +43,6 @@ export class MeasurementEntryAdapter implements Adapter<MeasurementEntry> {
34
43
  notes: item.notes
35
44
  };
36
45
  }
37
- }
46
+ }
47
+
48
+ const adapter = new MeasurementEntryAdapter();
@@ -0,0 +1,34 @@
1
+ import { render, screen } from '@testing-library/react';
2
+ import '@testing-library/jest-dom';
3
+ import React from 'react';
4
+ import { testUserProgressionTrophies } from "tests/trophies/trophiesTestData";
5
+ import { TrophiesDetail } from './TrophiesDetail';
6
+
7
+
8
+ // Mock the trophies query hook
9
+ jest.mock('components/Trophies/queries/trophies', () => ({
10
+ useUserTrophyProgressionQuery: () => ({
11
+ isLoading: false,
12
+ data: testUserProgressionTrophies(),
13
+ }),
14
+ }));
15
+
16
+ describe('TrophiesDetail', () => {
17
+ test('renders trophy names and progression values', () => {
18
+
19
+ // Act
20
+ render(<TrophiesDetail />);
21
+
22
+ // Assert
23
+ expect(screen.getByText('Beginner')).toBeInTheDocument();
24
+ expect(screen.getByText('Unstoppable')).toBeInTheDocument();
25
+ expect(screen.getByText('Complete your first workout')).toBeInTheDocument();
26
+ expect(screen.getByText('Maintain a 30-day workout streak')).toBeInTheDocument();
27
+
28
+ // Progression value for the progressive trophy should be shown
29
+ expect(screen.getByText('4/30')).toBeInTheDocument();
30
+
31
+ // There should be at least one progressbar in the document
32
+ expect(screen.getAllByRole('progressbar').length).toBeGreaterThanOrEqual(1);
33
+ });
34
+ });
@@ -0,0 +1,88 @@
1
+ import { Card, CardContent, CardMedia, LinearProgress, LinearProgressProps, Typography } from "@mui/material";
2
+ import Box from "@mui/system/Box";
3
+ import Grid from "@mui/system/Grid";
4
+ import { LoadingPlaceholder } from "components/Core/LoadingWidget/LoadingWidget";
5
+ import { WgerContainerFullWidth } from "components/Core/Widgets/Container";
6
+ import { UserTrophyProgression } from "components/Trophies/models/userTrophyProgression";
7
+ import { useUserTrophyProgressionQuery } from "components/Trophies/queries/trophies";
8
+ import React from "react";
9
+ import { useTranslation } from "react-i18next";
10
+
11
+
12
+ export const TrophiesDetail = () => {
13
+ const [t] = useTranslation();
14
+
15
+ const planQuery = useUserTrophyProgressionQuery();
16
+
17
+ if (planQuery.isLoading) {
18
+ return <LoadingPlaceholder />;
19
+ }
20
+
21
+ return <WgerContainerFullWidth title={t('trophies.trophies')}>
22
+ <Grid container spacing={2}>
23
+ {planQuery.data!.map((trophyProgression) => (
24
+ <Grid size={{ xs: 12, sm: 4, md: 3, lg: 3, xl: 2 }} key={trophyProgression.trophy.uuid}>
25
+ <TrophyProgressCard key={trophyProgression.trophy.uuid} trophyProgression={trophyProgression} />
26
+ </Grid>
27
+ ))}
28
+ </Grid>
29
+ </WgerContainerFullWidth>;
30
+ };
31
+
32
+
33
+ function TrophyProgressCard(props: { trophyProgression: UserTrophyProgression }) {
34
+ return <Card sx={{ height: "100%" }}>
35
+ <CardMedia
36
+ sx={{
37
+ opacity: props.trophyProgression.isEarned ? 1 : 0.3,
38
+ p: 1,
39
+ width: 'auto',
40
+ mx: 'auto',
41
+ maxHeight: 130,
42
+ }}
43
+ component="img"
44
+ image={props.trophyProgression.trophy.image}
45
+ title={props.trophyProgression.trophy.name}
46
+ />
47
+ <CardContent sx={{ opacity: props.trophyProgression.isEarned ? 1 : 0.6 }}>
48
+
49
+ <Typography gutterBottom variant="h6" component="div" textAlign="center">
50
+ {props.trophyProgression.trophy.name}
51
+ </Typography>
52
+
53
+ <Box sx={{ mb: 2, mt: 0 }}>
54
+ {(props.trophyProgression.trophy.isProgressive && !props.trophyProgression.isEarned) &&
55
+ <LinearProgressWithLabel value={props.trophyProgression.progress} />
56
+ }
57
+ <Typography variant="body2" sx={{ color: "text.secondary", textAlign: 'center' }}>
58
+ {props.trophyProgression.progressDisplay}
59
+ </Typography>
60
+
61
+ </Box>
62
+
63
+ <Typography variant="body2" sx={{ color: "text.secondary" }}>
64
+ {props.trophyProgression.trophy.description}
65
+ </Typography>
66
+
67
+ </CardContent>
68
+ </Card>;
69
+ }
70
+
71
+
72
+ const LinearProgressWithLabel = (props: LinearProgressProps & { value: number, progressLabel?: string | null }) => {
73
+ // Extract custom prop so it doesn't get passed to the progress bar via the spread operator
74
+ const { progressLabel, ...linearProps } = props;
75
+
76
+ return (
77
+ <Box sx={{ display: 'flex', alignItems: 'center' }}>
78
+ <Box sx={{ width: '100%', mr: 1 }}>
79
+ <LinearProgress variant="determinate" {...linearProps} />
80
+ </Box>
81
+ <Box sx={{ minWidth: 35 }}>
82
+ <Typography variant="body2" sx={{ color: 'text.secondary' }}>
83
+ {progressLabel ?? `${Math.round(props.value)}%`}
84
+ </Typography>
85
+ </Box>
86
+ </Box>
87
+ );
88
+ };
@@ -0,0 +1,33 @@
1
+ import { Trophy } from "components/Trophies/models/trophy";
2
+
3
+
4
+ describe('Test the trophy model', () => {
5
+
6
+ test('correctly creates an object from the API response', () => {
7
+ // Arrange
8
+ const apiResponse = {
9
+ id: 1,
10
+ uuid: "5362e55b-eaf1-4e34-9ef8-661538a3bdd9",
11
+ name: "Beginner",
12
+ description: "Complete your first workout",
13
+ image: "http://localhost:8000/static/trophies/count/5362e55b-eaf1-4e34-9ef8-661538a3bdd9.png",
14
+ "trophy_type": "count",
15
+ "is_hidden": false,
16
+ "is_progressive": false,
17
+ order: 1
18
+ };
19
+
20
+ // Act
21
+ const trophy = Trophy.fromJson(apiResponse);
22
+
23
+ // Assert
24
+ expect(trophy.id).toBe(1);
25
+ expect(trophy.uuid).toBe("5362e55b-eaf1-4e34-9ef8-661538a3bdd9");
26
+ expect(trophy.name).toBe("Beginner");
27
+ expect(trophy.description).toBe("Complete your first workout");
28
+ expect(trophy.image).toBe("http://localhost:8000/static/trophies/count/5362e55b-eaf1-4e34-9ef8-661538a3bdd9.png");
29
+ expect(trophy.type).toBe("count");
30
+ expect(trophy.isHidden).toBe(false);
31
+ expect(trophy.isProgressive).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,75 @@
1
+ import { Adapter } from "utils/Adapter";
2
+
3
+ export type trophyType = 'time' | 'volume' | 'count' | 'sequence' | 'date' | 'pr' | 'other';
4
+
5
+ export interface ApiTrophyType {
6
+ id: number,
7
+ uuid: string,
8
+ name: string,
9
+ description: string,
10
+ image: string,
11
+ "trophy_type": trophyType,
12
+ "is_hidden": boolean,
13
+ "is_progressive": boolean,
14
+ }
15
+
16
+ export type TrophyConstructorParams = {
17
+ id: number;
18
+ uuid: string;
19
+ name: string;
20
+ description: string;
21
+ image: string;
22
+ type: trophyType;
23
+ isHidden: boolean;
24
+ isProgressive: boolean;
25
+ };
26
+
27
+ export class Trophy {
28
+
29
+ public id: number;
30
+ public uuid: string;
31
+ public name: string;
32
+ public description: string;
33
+ public image: string;
34
+ public type: string;
35
+ public isHidden: boolean;
36
+ public isProgressive: boolean;
37
+
38
+
39
+ constructor(params: TrophyConstructorParams) {
40
+ this.id = params.id;
41
+ this.uuid = params.uuid;
42
+ this.name = params.name;
43
+ this.description = params.description;
44
+ this.image = params.image;
45
+ this.type = params.type;
46
+ this.isHidden = params.isHidden;
47
+ this.isProgressive = params.isProgressive;
48
+ }
49
+
50
+ static fromJson(json: ApiTrophyType): Trophy {
51
+ return adapter.fromJson(json);
52
+ }
53
+ }
54
+
55
+ class TrophyAdapter implements Adapter<Trophy> {
56
+ fromJson(item: ApiTrophyType) {
57
+ return new Trophy({
58
+ id: item.id,
59
+ uuid: item.uuid,
60
+ name: item.name,
61
+ description: item.description,
62
+ image: item.image,
63
+ type: item.trophy_type,
64
+ isHidden: item.is_hidden,
65
+ isProgressive: item.is_progressive,
66
+ });
67
+ }
68
+
69
+ toJson(_: Trophy) {
70
+ return {};
71
+ }
72
+ }
73
+
74
+
75
+ const adapter = new TrophyAdapter();
@@ -0,0 +1,38 @@
1
+ import { UserTrophy } from "components/Trophies/models/userTrophy";
2
+
3
+
4
+ describe('Test the user UserTrophy model', () => {
5
+
6
+ test('correctly creates an object from the API response', () => {
7
+ // Arrange
8
+ const apiResponse = {
9
+ id: 4,
10
+ trophy: {
11
+ id: 9,
12
+ uuid: "32bb12da-b25f-4e18-81e4-b695eb65283e",
13
+ name: "Phoenix",
14
+ description: "Return to training after being inactive for 30 days",
15
+ image: "http://localhost:8000/static/trophies/other/32bb12da-b25f-4e18-81e4-b695eb65283e.png",
16
+ "trophy_type": "other",
17
+ "is_hidden": true,
18
+ "is_progressive": false,
19
+ order: 9
20
+ },
21
+ "earned_at": "2025-12-19T13:48:07.519497+01:00",
22
+ progress: 100.0,
23
+ "is_notified": false
24
+ };
25
+
26
+ // Act
27
+ const userTrophy = UserTrophy.fromJson(apiResponse);
28
+
29
+ // Assert
30
+ expect(userTrophy.id).toBe(4);
31
+ expect(userTrophy.earnedAt).toStrictEqual(new Date("2025-12-19T13:48:07.519497+01:00"));
32
+ expect(userTrophy.progress).toBe(100.0);
33
+ expect(userTrophy.isNotified).toBe(false);
34
+
35
+ expect(userTrophy.trophy.uuid).toBe("32bb12da-b25f-4e18-81e4-b695eb65283e");
36
+
37
+ });
38
+ });
@@ -0,0 +1,67 @@
1
+ import { ApiTrophyType, Trophy } from "components/Trophies/models/trophy";
2
+ import { Adapter } from "utils/Adapter";
3
+
4
+ export interface ApiUserTrophyType {
5
+ id: number,
6
+ trophy: ApiTrophyType,
7
+ earned_at: string,
8
+ progress: number,
9
+ "is_notified": boolean,
10
+ }
11
+
12
+ export type UserTrophyConstructorParams = {
13
+ id: number;
14
+ trophy: Trophy;
15
+ earnedAt: Date;
16
+ progress: number;
17
+ isNotified: boolean;
18
+ };
19
+
20
+ /*
21
+ * A list of trophies earned by a user, along with their progress.
22
+ */
23
+ export class UserTrophy {
24
+
25
+ public id: number;
26
+ public trophy: Trophy;
27
+ public earnedAt: Date;
28
+ public progress: number;
29
+ public isNotified: boolean;
30
+
31
+ constructor(params: UserTrophyConstructorParams) {
32
+ this.id = params.id;
33
+ this.trophy = params.trophy;
34
+ this.earnedAt = params.earnedAt;
35
+ this.progress = params.progress;
36
+ this.isNotified = params.isNotified;
37
+ }
38
+
39
+ static fromJson(json: ApiUserTrophyType): UserTrophy {
40
+ return adapter.fromJson(json);
41
+ }
42
+
43
+ toJson() {
44
+ return adapter.toJson(this);
45
+ }
46
+ }
47
+
48
+ class UserTrophyAdapter implements Adapter<UserTrophy> {
49
+ fromJson(item: ApiUserTrophyType) {
50
+ return new UserTrophy({
51
+ id: item.id,
52
+ trophy: Trophy.fromJson(item.trophy),
53
+ earnedAt: new Date(item.earned_at),
54
+ progress: item.progress,
55
+ isNotified: item.is_notified,
56
+ });
57
+ }
58
+
59
+ toJson(trophy: UserTrophy) {
60
+ return {
61
+ id: trophy.id,
62
+ "is_notified": trophy.isNotified,
63
+ };
64
+ }
65
+ }
66
+
67
+ const adapter = new UserTrophyAdapter();
@@ -0,0 +1,43 @@
1
+ import { trophyType } from "components/Trophies/models/trophy";
2
+ import { UserTrophyProgression } from "components/Trophies/models/userTrophyProgression";
3
+
4
+
5
+ describe('Test the UserTrophyProgression model', () => {
6
+
7
+ test('correctly creates an object from the API response', () => {
8
+ // Arrange
9
+ const apiResponse = {
10
+ trophy: {
11
+ id: 1,
12
+ uuid: "5362e55b-eaf1-4e34-9ef8-661538a3bdd9",
13
+ name: "Beginner",
14
+ description: "Complete your first workout",
15
+ image: "http://localhost:8000/static/trophies/count/5362e55b-eaf1-4e34-9ef8-661538a3bdd9.png",
16
+ "trophy_type": "count" as trophyType,
17
+ "is_hidden": false,
18
+ "is_progressive": false,
19
+ order: 1
20
+ },
21
+ "is_earned": true,
22
+ "earned_at": "2025-12-19T13:48:07.513138+01:00",
23
+ progress: 100.0,
24
+ "current_value": null,
25
+ "target_value": null,
26
+ "progress_display": null
27
+ };
28
+
29
+ // Act
30
+ const userTrophyProgression = UserTrophyProgression.fromJson(apiResponse);
31
+
32
+ // Assert
33
+ expect(userTrophyProgression.isEarned).toBe(true);
34
+ expect(userTrophyProgression.earnedAt).toStrictEqual(new Date("2025-12-19T13:48:07.513138+01:00"));
35
+ expect(userTrophyProgression.progress).toBe(100.0);
36
+ expect(userTrophyProgression.currentValue).toBe(null);
37
+ expect(userTrophyProgression.targetValue).toBe(null);
38
+ expect(userTrophyProgression.progressDisplay).toBe(null);
39
+
40
+ expect(userTrophyProgression.trophy.uuid).toBe("5362e55b-eaf1-4e34-9ef8-661538a3bdd9");
41
+
42
+ });
43
+ });
@@ -0,0 +1,68 @@
1
+ import { ApiTrophyType, Trophy } from "components/Trophies/models/trophy";
2
+ import { Adapter } from "utils/Adapter";
3
+
4
+ export interface ApiUserTrophyType {
5
+ trophy: ApiTrophyType,
6
+ is_earned: boolean
7
+ earned_at: string | null,
8
+ progress: number,
9
+ current_value: number | null,
10
+ target_value: number | null,
11
+ progress_display: string | null,
12
+ }
13
+
14
+ export type UserTrophyConstructorParams = {
15
+ trophy: Trophy;
16
+ isEarned: boolean;
17
+ earnedAt: Date | null;
18
+ progress: number;
19
+ currentValue: number | null;
20
+ targetValue: number | null;
21
+ progressDisplay: string | null;
22
+ };
23
+
24
+ export class UserTrophyProgression {
25
+
26
+ public trophy: Trophy;
27
+ public earnedAt: Date | null;
28
+ public isEarned: boolean;
29
+ public progress: number;
30
+ public currentValue: number | null;
31
+ public targetValue: number | null;
32
+ public progressDisplay: string | null;
33
+
34
+ constructor(params: UserTrophyConstructorParams) {
35
+ this.trophy = params.trophy;
36
+ this.earnedAt = params.earnedAt;
37
+ this.isEarned = params.isEarned;
38
+ this.progress = params.progress;
39
+ this.currentValue = params.currentValue;
40
+ this.targetValue = params.targetValue;
41
+ this.progressDisplay = params.progressDisplay;
42
+ }
43
+
44
+ static fromJson(json: ApiUserTrophyType): UserTrophyProgression {
45
+ return adapter.fromJson(json);
46
+ }
47
+ }
48
+
49
+ class UserTrophyAdapter implements Adapter<UserTrophyProgression> {
50
+ fromJson(item: ApiUserTrophyType) {
51
+ return new UserTrophyProgression({
52
+ trophy: Trophy.fromJson(item.trophy),
53
+ earnedAt: item.earned_at !== null ? new Date(item.earned_at) : null,
54
+ isEarned: item.is_earned,
55
+ progress: item.progress,
56
+ currentValue: item.current_value,
57
+ targetValue: item.target_value,
58
+ progressDisplay: item.progress_display,
59
+ });
60
+ }
61
+
62
+ toJson(item: UserTrophyProgression) {
63
+ return {};
64
+ }
65
+ }
66
+
67
+
68
+ const adapter = new UserTrophyAdapter();