diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java
index 927e86c..b23b722 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/controller/GymController.java
@@ -6,6 +6,8 @@ import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
import nl.andrewlalis.gymboard_api.domains.api.service.GymService;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
+import nl.andrewlalis.gymboard_api.domains.auth.model.User;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -37,8 +39,9 @@ public class GymController {
@PostMapping(path = "/submissions")
public ExerciseSubmissionResponse createSubmission(
@PathVariable String compoundId,
+ @AuthenticationPrincipal User user,
@RequestBody ExerciseSubmissionPayload payload
) {
- return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload);
+ return submissionService.createSubmission(CompoundGymId.parse(compoundId), user.getId(), payload);
}
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java
index b7c1d95..5fffa6e 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionPayload.java
@@ -1,7 +1,6 @@
package nl.andrewlalis.gymboard_api.domains.api.dto;
public record ExerciseSubmissionPayload(
- String name,
String exerciseShortName,
float weight,
String weightUnit,
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java
index fb5e91d..897a5b5 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/dto/ExerciseSubmissionResponse.java
@@ -1,17 +1,17 @@
package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
+import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse;
import nl.andrewlalis.gymboard_api.util.StandardDateFormatter;
-import java.time.format.DateTimeFormatter;
-
public record ExerciseSubmissionResponse(
String id,
String createdAt,
GymSimpleResponse gym,
ExerciseResponse exercise,
+ UserResponse user,
+ String performedAt,
String videoFileId,
- String submitterName,
double rawWeight,
String weightUnit,
double metricWeight,
@@ -23,8 +23,9 @@ public record ExerciseSubmissionResponse(
StandardDateFormatter.format(submission.getCreatedAt()),
new GymSimpleResponse(submission.getGym()),
new ExerciseResponse(submission.getExercise()),
+ new UserResponse(submission.getUser()),
+ StandardDateFormatter.format(submission.getPerformedAt()),
submission.getVideoFileId(),
- submission.getSubmitterName(),
submission.getRawWeight().doubleValue(),
submission.getWeightUnit().name(),
submission.getMetricWeight().doubleValue(),
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java
index 7f8fc86..57ba2f8 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/model/exercise/ExerciseSubmission.java
@@ -3,9 +3,11 @@ package nl.andrewlalis.gymboard_api.domains.api.model.exercise;
import jakarta.persistence.*;
import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
+import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal;
+import java.time.LocalDate;
import java.time.LocalDateTime;
@Entity
@@ -24,6 +26,12 @@ public class ExerciseSubmission {
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private Exercise exercise;
+ @ManyToOne(optional = false, fetch = FetchType.LAZY)
+ private User user;
+
+ @Column(nullable = false)
+ private LocalDateTime performedAt;
+
/**
* The id of the video file that was submitted for this submission. It lives
* on the gymboard-cdn service as a stored file, which can be
@@ -32,9 +40,6 @@ public class ExerciseSubmission {
@Column(nullable = false, updatable = false, length = 26)
private String videoFileId;
- @Column(nullable = false, updatable = false, length = 63)
- private String submitterName;
-
@Column(nullable = false, precision = 7, scale = 2)
private BigDecimal rawWeight;
@@ -50,12 +55,13 @@ public class ExerciseSubmission {
public ExerciseSubmission() {}
- public ExerciseSubmission(String id, Gym gym, Exercise exercise, String videoFileId, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
+ public ExerciseSubmission(String id, Gym gym, Exercise exercise, User user, LocalDateTime performedAt, String videoFileId, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
this.id = id;
this.gym = gym;
this.exercise = exercise;
this.videoFileId = videoFileId;
- this.submitterName = submitterName;
+ this.user = user;
+ this.performedAt = performedAt;
this.rawWeight = rawWeight;
this.weightUnit = unit;
this.metricWeight = metricWeight;
@@ -82,8 +88,12 @@ public class ExerciseSubmission {
return videoFileId;
}
- public String getSubmitterName() {
- return submitterName;
+ public User getUser() {
+ return user;
+ }
+
+ public LocalDateTime getPerformedAt() {
+ return performedAt;
}
public BigDecimal getRawWeight() {
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java
index 8029b94..9f355de 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/CdnClient.java
@@ -32,7 +32,13 @@ public class CdnClient {
.GET()
.build();
HttpResponse response = httpClient.send(req, HttpResponse.BodyHandlers.ofString());
- return objectMapper.readValue(response.body(), responseType);
+ if (response.statusCode() == 200) {
+ return objectMapper.readValue(response.body(), responseType);
+ } else if (response.statusCode() == 404) {
+ return null;
+ } else {
+ throw new IOException("Request failed with code " + response.statusCode());
+ }
}
public T postFile(String urlPath, Path filePath, String contentType, Class responseType) throws IOException, InterruptedException {
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java
index fc8a954..a08b25d 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/cdn_client/UploadsClient.java
@@ -6,6 +6,14 @@ public record UploadsClient(CdnClient client) {
public record FileUploadResponse(String id) {}
public record VideoProcessingTaskStatusResponse(String status) {}
+ public record FileMetadataResponse(
+ String filename,
+ String mimeType,
+ long size,
+ String uploadedAt,
+ boolean availableForDownload
+ ) {}
+
public FileUploadResponse uploadVideo(Path filePath, String contentType) throws Exception {
return client.postFile("/uploads/video", filePath, contentType, FileUploadResponse.class);
}
@@ -13,4 +21,8 @@ public record UploadsClient(CdnClient client) {
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) throws Exception {
return client.get("/uploads/video/" + id + "/status", VideoProcessingTaskStatusResponse.class);
}
+
+ public FileMetadataResponse getFileMetadata(String id) throws Exception {
+ return client.get("/files/" + id + "/metadata", FileMetadataResponse.class);
+ }
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java
index c08a34e..f3e6860 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/domains/api/service/submission/ExerciseSubmissionService.java
@@ -10,6 +10,8 @@ import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
+import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
+import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -19,6 +21,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
/**
* Service which handles the rather mundane tasks associated with exercise
@@ -29,15 +33,17 @@ public class ExerciseSubmissionService {
private static final Logger log = LoggerFactory.getLogger(ExerciseSubmissionService.class);
private final GymRepository gymRepository;
+ private final UserRepository userRepository;
private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionRepository exerciseSubmissionRepository;
private final ULID ulid;
public ExerciseSubmissionService(GymRepository gymRepository,
- ExerciseRepository exerciseRepository,
+ UserRepository userRepository, ExerciseRepository exerciseRepository,
ExerciseSubmissionRepository exerciseSubmissionRepository,
ULID ulid) {
this.gymRepository = gymRepository;
+ this.userRepository = userRepository;
this.exerciseRepository = exerciseRepository;
this.exerciseSubmissionRepository = exerciseSubmissionRepository;
this.ulid = ulid;
@@ -53,11 +59,14 @@ public class ExerciseSubmissionService {
/**
* Handles the creation of a new exercise submission.
* @param id The gym id.
+ * @param userId The user's id.
* @param payload The submission data.
* @return The saved submission.
*/
@Transactional
- public ExerciseSubmissionResponse createSubmission(CompoundGymId id, ExerciseSubmissionPayload payload) {
+ public ExerciseSubmissionResponse createSubmission(CompoundGymId id, String userId, ExerciseSubmissionPayload payload) {
+ User user = userRepository.findById(userId)
+ .orElseThrow(() -> new ResponseStatusException(HttpStatus.FORBIDDEN));
Gym gym = gymRepository.findByCompoundId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
Exercise exercise = exerciseRepository.findById(payload.exerciseShortName())
@@ -76,8 +85,9 @@ public class ExerciseSubmissionService {
ulid.nextULID(),
gym,
exercise,
+ user,
+ LocalDateTime.now(),
payload.videoFileId(),
- payload.name(),
rawWeight,
weightUnit,
metricWeight,
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java
index 90d0f43..33e34cc 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/sample_data/SampleSubmissionGenerator.java
@@ -1,61 +1,124 @@
package nl.andrewlalis.gymboard_api.util.sample_data;
+import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
-import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
-import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
+import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository;
+import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.domains.api.model.WeightUnit;
+import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
+import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
-import nl.andrewlalis.gymboard_api.util.CsvUtil;
+import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
+import nl.andrewlalis.gymboard_api.domains.auth.model.User;
+import nl.andrewlalis.gymboard_api.util.ULID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.nio.file.Path;
-import java.util.Collection;
-import java.util.Set;
+import java.time.Duration;
+import java.time.LocalDateTime;
+import java.util.*;
@Component
@Profile("development")
public class SampleSubmissionGenerator implements SampleDataGenerator {
+ private final GymRepository gymRepository;
+ private final UserRepository userRepository;
private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionService submissionService;
+ private final ExerciseSubmissionRepository submissionRepository;
+ private final ULID ulid;
@Value("${app.cdn-origin}")
private String cdnOrigin;
- public SampleSubmissionGenerator(ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService) {
+ public SampleSubmissionGenerator(GymRepository gymRepository, UserRepository userRepository, ExerciseRepository exerciseRepository, ExerciseSubmissionService submissionService, ExerciseSubmissionRepository submissionRepository, ULID ulid) {
+ this.gymRepository = gymRepository;
+ this.userRepository = userRepository;
this.exerciseRepository = exerciseRepository;
this.submissionService = submissionService;
+ this.submissionRepository = submissionRepository;
+ this.ulid = ulid;
}
@Override
public void generate() throws Exception {
final CdnClient cdnClient = new CdnClient(cdnOrigin);
- CsvUtil.load(Path.of("sample_data", "submissions.csv"), r -> {
- var exercise = exerciseRepository.findById(r.get("exercise-short-name")).orElseThrow();
- BigDecimal weight = new BigDecimal(r.get("raw-weight"));
- WeightUnit unit = WeightUnit.parse(r.get("weight-unit"));
- int reps = Integer.parseInt(r.get("reps"));
- String name = r.get("submitter-name");
- CompoundGymId gymId = CompoundGymId.parse(r.get("gym-id"));
- String videoFilename = r.get("video-filename");
- var video = cdnClient.uploads.uploadVideo(Path.of("sample_data", videoFilename), "video/mp4");
- submissionService.createSubmission(gymId, new ExerciseSubmissionPayload(
- name,
- exercise.getShortName(),
- weight.floatValue(),
- unit.name(),
- reps,
- video.id()
- ));
- });
+ List videoIds = new ArrayList<>();
+ var video1 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_curl.mp4"), "video/mp4");
+ var video2 = cdnClient.uploads.uploadVideo(Path.of("sample_data", "sample_video_ohp.mp4"), "video/mp4");
+ videoIds.add(video1.id());
+ videoIds.add(video2.id());
+
+ List gyms = gymRepository.findAll();
+ List users = userRepository.findAll();
+ List exercises = exerciseRepository.findAll();
+
+ final int count = 10000;
+ final LocalDateTime earliestSubmission = LocalDateTime.now().minusYears(3);
+ final LocalDateTime latestSubmission = LocalDateTime.now();
+
+ Random random = new Random(1);
+ for (int i = 0; i < count; i++) {
+ generateRandomSubmission(
+ gyms,
+ users,
+ exercises,
+ videoIds,
+ earliestSubmission,
+ latestSubmission,
+ random
+ );
+ }
+ }
+
+ private void generateRandomSubmission(
+ List gyms,
+ List users,
+ List exercises,
+ List videoIds,
+ LocalDateTime earliestSubmission,
+ LocalDateTime latestSubmission,
+ Random random
+ ) {
+ LocalDateTime time = randomTime(earliestSubmission, latestSubmission, random);
+ BigDecimal metricWeight = new BigDecimal(random.nextInt(20, 250));
+ BigDecimal rawWeight = new BigDecimal(metricWeight.toString());
+ WeightUnit weightUnit = WeightUnit.KILOGRAMS;
+ if (random.nextDouble() > 0.5) {
+ weightUnit = WeightUnit.POUNDS;
+ rawWeight = metricWeight.multiply(new BigDecimal("2.2046226218"));
+ }
+
+ submissionRepository.save(new ExerciseSubmission(
+ ulid.nextULID(),
+ randomChoice(gyms, random),
+ randomChoice(exercises, random),
+ randomChoice(users, random),
+ time,
+ randomChoice(videoIds, random),
+ rawWeight,
+ weightUnit,
+ metricWeight,
+ random.nextInt(13)
+ ));
}
@Override
public Collection> dependencies() {
- return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class);
+ return Set.of(SampleExerciseGenerator.class, SampleUserGenerator.class, SampleGymGenerator.class);
+ }
+
+ private T randomChoice(List items, Random rand) {
+ return items.get(rand.nextInt(items.size()));
+ }
+
+ private LocalDateTime randomTime(LocalDateTime start, LocalDateTime end, Random rand) {
+ Duration dur = Duration.between(start, end);
+ return start.plusSeconds(rand.nextLong(dur.toSeconds() + 1));
}
}
diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts
index 93ea35c..dbca033 100644
--- a/gymboard-app/src/api/main/submission.ts
+++ b/gymboard-app/src/api/main/submission.ts
@@ -3,6 +3,7 @@ import { Exercise } from 'src/api/main/exercises';
import { api } from 'src/api/main/index';
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { DateTime } from 'luxon';
+import {User} from "src/api/main/auth";
/**
* The data that's sent when creating a submission.
@@ -33,8 +34,9 @@ export interface ExerciseSubmission {
createdAt: DateTime;
gym: SimpleGym;
exercise: Exercise;
+ user: User;
+ performedAt: DateTime;
videoFileId: string;
- submitterName: string;
rawWeight: number;
weightUnit: WeightUnit;
metricWeight: number;
@@ -43,7 +45,7 @@ export interface ExerciseSubmission {
export function parseSubmission(data: any): ExerciseSubmission {
data.createdAt = DateTime.fromISO(data.createdAt);
- console.log(data);
+ data.performedAt = DateTime.fromISO(data.performedAt);
return data as ExerciseSubmission;
}
diff --git a/gymboard-app/src/api/search/index.ts b/gymboard-app/src/api/search/index.ts
index 78dedb0..800bcbc 100644
--- a/gymboard-app/src/api/search/index.ts
+++ b/gymboard-app/src/api/search/index.ts
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { GymSearchResult } from 'src/api/search/models';
+import {GymSearchResult, UserSearchResult} from 'src/api/search/models';
const api = axios.create({
baseURL: 'http://localhost:8081',
@@ -15,3 +15,14 @@ export async function searchGyms(
const response = await api.get('/search/gyms?q=' + query);
return response.data;
}
+
+/**
+ * Searches for users using the given query, and eventually returns results.
+ * Note that only users whose accounts are not private will be included in
+ * search results.
+ * @param query The query to use.
+ */
+export async function searchUsers(query: string): Promise> {
+ const response = await api.get('/search/users?q=' + query);
+ return response.data;
+}
diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue
index 6dfc2b1..3126d2e 100644
--- a/gymboard-app/src/components/AccountMenuItem.vue
+++ b/gymboard-app/src/components/AccountMenuItem.vue
@@ -8,6 +8,11 @@
icon="person"
>
+
+
+ {{ $t('accountMenuItem.myAccount') }}
+
+
{{ $t('accountMenuItem.logOut') }}
diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts
index e1c11e2..a36ca18 100644
--- a/gymboard-app/src/i18n/en-US/index.ts
+++ b/gymboard-app/src/i18n/en-US/index.ts
@@ -41,6 +41,7 @@ export default {
},
accountMenuItem: {
logIn: 'Login',
+ myAccount: 'My Account',
logOut: 'Log out',
},
};
diff --git a/gymboard-app/src/pages/UserPage.vue b/gymboard-app/src/pages/UserPage.vue
new file mode 100644
index 0000000..5f792fc
--- /dev/null
+++ b/gymboard-app/src/pages/UserPage.vue
@@ -0,0 +1,41 @@
+
+
+
+ {{ user?.name }}
+ {{ user?.email }}
+ This is your account!
+
+
+
+
+
+
+
diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts
index 611732a..b6220b3 100644
--- a/gymboard-app/src/router/routes.ts
+++ b/gymboard-app/src/router/routes.ts
@@ -12,6 +12,7 @@ import RegisterPage from 'pages/auth/RegisterPage.vue';
import RegistrationSuccessPage from 'pages/auth/RegistrationSuccessPage.vue';
import ActivationPage from 'pages/auth/ActivationPage.vue';
import SubmissionPage from 'pages/SubmissionPage.vue';
+import UserPage from 'pages/UserPage.vue';
const routes: RouteRecordRaw[] = [
// Auth-related pages, which live outside the main layout.
@@ -27,6 +28,7 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: '', component: IndexPage },
{ path: 'testing', component: TestingPage },
+ { path: 'users/:userId', component: UserPage },
{
path: 'gyms/:countryCode/:cityShortName/:gymShortName',
component: GymPage,