Added leaderboard page and api support.

This commit is contained in:
Andrew Lalis 2023-01-28 10:52:20 +01:00
parent 16a7f105f8
commit 34cd6cac2c
18 changed files with 788 additions and 535 deletions

View File

@ -1,9 +1,10 @@
package nl.andrewlalis.gymboard_api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.*;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.GymService;
import nl.andrewlalis.gymboard_api.service.LeaderboardService;
import nl.andrewlalis.gymboard_api.service.UploadService;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -20,7 +21,9 @@ public class GymController {
private final UploadService uploadService;
private final ExerciseSubmissionService submissionService;
public GymController(GymService gymService, UploadService uploadService, ExerciseSubmissionService submissionService) {
public GymController(GymService gymService,
UploadService uploadService,
ExerciseSubmissionService submissionService) {
this.gymService = gymService;
this.uploadService = uploadService;
this.submissionService = submissionService;

View File

@ -0,0 +1,37 @@
package nl.andrewlalis.gymboard_api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.service.LeaderboardService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Optional;
@RestController
@RequestMapping(path = "/leaderboards")
public class LeaderboardController {
private final LeaderboardService leaderboardService;
public LeaderboardController(LeaderboardService leaderboardService) {
this.leaderboardService = leaderboardService;
}
@GetMapping
public Page<ExerciseSubmissionResponse> getLeaderboard(
@RequestParam(name = "exercise") Optional<String> exerciseShortName,
@RequestParam(name = "gyms") Optional<String> gymCompoundIdsString,
@RequestParam(name = "t") Optional<String> timeframe,
Pageable pageable
) {
return leaderboardService.getTopSubmissions(
exerciseShortName,
gymCompoundIdsString,
timeframe,
pageable
);
}
}

View File

@ -2,7 +2,7 @@ package nl.andrewlalis.gymboard_api.controller;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;

View File

@ -0,0 +1,33 @@
package nl.andrewlalis.gymboard_api.model;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;
public enum LeaderboardTimeframe {
DAY(Duration.ofDays(1)),
WEEK(Duration.ofDays(7)),
MONTH(Duration.ofDays(30)),
YEAR(Duration.ofDays(365)),
ALL(Duration.ZERO);
private final Duration duration;
LeaderboardTimeframe(Duration duration) {
this.duration = duration;
}
public Optional<LocalDateTime> getCutoffTime(LocalDateTime now) {
if (this.duration.isZero()) return Optional.empty();
return Optional.of(now.minus(this.duration));
}
public static LeaderboardTimeframe parse(String s, LeaderboardTimeframe defaultValue) {
if (s == null || s.isBlank()) return defaultValue;
try {
return LeaderboardTimeframe.valueOf(s.toUpperCase());
} catch (IllegalArgumentException e) {
return defaultValue;
}
}
}

View File

@ -8,7 +8,7 @@ import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.UploadService;
import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord;
@ -19,7 +19,6 @@ import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.FileReader;
import java.io.IOException;

View File

@ -1,13 +1,12 @@
package nl.andrewlalis.gymboard_api.service;
import jakarta.persistence.criteria.Predicate;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
@ -16,7 +15,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.ArrayList;
import java.util.List;
@Service
@ -46,14 +44,10 @@ public class GymService {
query.orderBy(criteriaBuilder.desc(root.get("createdAt")));
query.distinct(true);
List<Predicate> predicates = new ArrayList<>();
predicates.add(criteriaBuilder.equal(root.get("gym"), gym));
predicates.add(criteriaBuilder.or(
criteriaBuilder.equal(root.get("status"), ExerciseSubmission.Status.COMPLETED),
criteriaBuilder.equal(root.get("status"), ExerciseSubmission.Status.VERIFIED)
));
return criteriaBuilder.and(predicates.toArray(new Predicate[0]));
return PredicateBuilder.and(criteriaBuilder)
.with(criteriaBuilder.equal(root.get("gym"), gym))
.with(criteriaBuilder.isTrue(root.get("complete")))
.build();
}, PageRequest.of(0, 10))
.map(ExerciseSubmissionResponse::new)
.toList();

View File

@ -0,0 +1,86 @@
package nl.andrewlalis.gymboard_api.service;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym;
import nl.andrewlalis.gymboard_api.model.LeaderboardTimeframe;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
/**
* This service is responsible for the various methods of fetching submissions
* for a gym's leaderboard pages.
*/
@Service
public class LeaderboardService {
private final ExerciseSubmissionRepository submissionRepository;
private final ExerciseRepository exerciseRepository;
private final GymRepository gymRepository;
public LeaderboardService(ExerciseSubmissionRepository submissionRepository, ExerciseRepository exerciseRepository, GymRepository gymRepository) {
this.submissionRepository = submissionRepository;
this.exerciseRepository = exerciseRepository;
this.gymRepository = gymRepository;
}
@Transactional(readOnly = true)
public Page<ExerciseSubmissionResponse> getTopSubmissions(
Optional<String> exerciseShortName,
Optional<String> gymCompoundIdsString,
Optional<String> optionalTimeframe,
Pageable pageable
) {
Optional<LocalDateTime> cutoffTime = optionalTimeframe.flatMap(s ->
LeaderboardTimeframe.parse(s, LeaderboardTimeframe.DAY)
.getCutoffTime(LocalDateTime.now())
);
Optional<Exercise> optionalExercise = exerciseShortName.flatMap(exerciseRepository::findById);
List<Gym> gyms = gymCompoundIdsString.map(this::parseGymCompoundIdsString).orElse(Collections.emptyList());
return submissionRepository.findAll((root, query, criteriaBuilder) -> {
query.distinct(true);
query.orderBy(criteriaBuilder.desc(root.get("metricWeight")));
PredicateBuilder pb = PredicateBuilder.and(criteriaBuilder);
cutoffTime.ifPresent(time -> pb.with(criteriaBuilder.greaterThan(root.get("createdAt"), time)));
optionalExercise.ifPresent(exercise -> pb.with(criteriaBuilder.equal(root.get("exercise"), exercise)));
if (!gyms.isEmpty()) {
PredicateBuilder or = PredicateBuilder.or(criteriaBuilder);
for (Gym gym : gyms) {
or.with(criteriaBuilder.equal(root.get("gym"), gym));
}
pb.with(or.build());
}
return pb.build();
}, pageable).map(ExerciseSubmissionResponse::new);
}
private List<Gym> parseGymCompoundIdsString(String s) {
if (s == null || s.isBlank()) return Collections.emptyList();
String[] ids = s.split(",");
List<Gym> gyms = new ArrayList<>(ids.length);
for (String compoundId : ids) {
try {
CompoundGymId id = CompoundGymId.parse(compoundId);
gymRepository.findByCompoundId(id).ifPresent(gyms::add);
} catch (ResponseStatusException ignored) {}
}
return gyms;
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.service;
package nl.andrewlalis.gymboard_api.service.submission;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId;
@ -14,7 +14,6 @@ import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile;
import nl.andrewlalis.gymboard_api.service.submission.SubmissionProcessingService;
import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View File

@ -0,0 +1,45 @@
package nl.andrewlalis.gymboard_api.util;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.List;
public class PredicateBuilder {
private enum Type {
AND,
OR
}
private final Type type;
private final CriteriaBuilder criteriaBuilder;
private final List<Predicate> predicates;
public PredicateBuilder(Type type, CriteriaBuilder cb) {
this.type = type;
this.criteriaBuilder = cb;
this.predicates = new ArrayList<>();
}
public PredicateBuilder with(Predicate predicate) {
this.predicates.add(predicate);
return this;
}
public Predicate build() {
Predicate[] predicatesArray = predicates.toArray(new Predicate[0]);
return switch (type) {
case OR -> this.criteriaBuilder.or(predicatesArray);
case AND -> this.criteriaBuilder.and(predicatesArray);
};
}
public static PredicateBuilder and(CriteriaBuilder cb) {
return new PredicateBuilder(Type.AND, cb);
}
public static PredicateBuilder or(CriteriaBuilder cb) {
return new PredicateBuilder(Type.OR, cb);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { GeoPoint } from 'src/api/main/models';
import SubmissionsModule, {ExerciseSubmission} from 'src/api/main/submission';
import { api } from 'src/api/main/index';
import {GymRoutable} from "src/router/gym-routing";
import {GymRoutable} from 'src/router/gym-routing';
export interface Gym {
countryCode: string;

View File

@ -1,6 +1,7 @@
import axios from 'axios';
import GymsModule from 'src/api/main/gyms';
import ExercisesModule from 'src/api/main/exercises';
import LeaderboardsModule from 'src/api/main/leaderboards';
export const BASE_URL = 'http://localhost:8080';
@ -12,5 +13,6 @@ export const api = axios.create({
class GymboardApi {
public readonly gyms = new GymsModule();
public readonly exercises = new ExercisesModule();
public readonly leaderboards = new LeaderboardsModule();
}
export default new GymboardApi();

View File

@ -0,0 +1,51 @@
import { ExerciseSubmission } from 'src/api/main/submission';
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { api } from 'src/api/main/index';
export enum LeaderboardTimeframe {
DAY = "DAY",
WEEK = "WEEK",
MONTH = "MONTH",
YEAR = "YEAR",
ALL = "ALL"
}
export interface LeaderboardParams {
exerciseShortName?: string;
gyms?: Array<GymRoutable>;
timeframe?: LeaderboardTimeframe;
page?: number;
size?: number;
}
interface RequestParams {
exercise?: string;
gyms?: string;
t?: string;
page?: number;
size?: number;
}
class LeaderboardsModule {
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
const requestParams: RequestParams = {};
if (params.exerciseShortName) {
requestParams.exercise = params.exerciseShortName;
}
if (params.gyms) {
requestParams.gyms = params.gyms
.map(gym => getGymCompoundId(gym))
.join(',');
}
if (params.timeframe) {
requestParams.t = params.timeframe;
}
if (params.page) requestParams.page = params.page;
if (params.size) requestParams.size = params.size;
const response = await api.get('/leaderboards', { params: requestParams });
return response.data.content;
}
}
export default LeaderboardsModule;

View File

@ -1,7 +1,7 @@
import { SimpleGym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import {api, BASE_URL} from 'src/api/main/index';
import { GymRoutable } from 'src/router/gym-routing';
import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing';
import { sleep } from 'src/utils';
/**
@ -17,7 +17,7 @@ export interface ExerciseSubmissionPayload {
}
export interface ExerciseSubmission {
id: number;
id: string;
createdAt: string;
gym: SimpleGym;
exercise: Exercise;
@ -38,12 +38,9 @@ export enum ExerciseSubmissionStatus {
}
class SubmissionsModule {
public async getSubmission(
gym: GymRoutable,
submissionId: number
): Promise<ExerciseSubmission> {
public async getSubmission(submissionId: string): Promise<ExerciseSubmission> {
const response = await api.get(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions/${submissionId}`
`/submissions/${submissionId}`
);
return response.data;
}
@ -55,15 +52,16 @@ class SubmissionsModule {
) {
return null;
}
return BASE_URL + `/gyms/${submission.gym.countryCode}_${submission.gym.cityShortName}_${submission.gym.shortName}/submissions/${submission.id}/video`
return BASE_URL + `/submissions/${submission.id}/video`
}
public async createSubmission(
gym: GymRoutable,
payload: ExerciseSubmissionPayload
): Promise<ExerciseSubmission> {
const gymId = getGymCompoundId(gym);
const response = await api.post(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions`,
`/gyms/${gymId}/submissions`,
payload
);
return response.data;
@ -72,8 +70,9 @@ class SubmissionsModule {
public async uploadVideoFile(gym: GymRoutable, file: File): Promise<number> {
const formData = new FormData();
formData.append('file', file);
const gymId = getGymCompoundId(gym);
const response = await api.post(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/submissions/upload`,
`/gyms/${gymId}/submissions/upload`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
@ -84,20 +83,16 @@ class SubmissionsModule {
/**
* Asynchronous method that waits until a submission is done processing.
* @param gym The gym that the submission is for.
* @param submissionId The submission's id.
*/
public async waitUntilSubmissionProcessed(
gym: GymRoutable,
submissionId: number
): Promise<ExerciseSubmission> {
public async waitUntilSubmissionProcessed(submissionId: string): Promise<ExerciseSubmission> {
let failureCount = 0;
let attemptCount = 0;
while (failureCount < 5 && attemptCount < 60) {
await sleep(1000);
attemptCount++;
try {
const response = await this.getSubmission(gym, submissionId);
const response = await this.getSubmission(submissionId);
failureCount = 0;
if (
response.status !== ExerciseSubmissionStatus.WAITING &&

View File

@ -4,7 +4,7 @@
<div class="col-xs-12 col-md-6 q-pt-md">
<p>{{ $t('gymPage.homePage.overview') }}</p>
<ul>
<li>Website: <a :href="gym.websiteUrl">{{ gym.websiteUrl }}</a></li>
<li v-if="gym.websiteUrl">Website: <a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a></li>
<li>Address: <em>{{ gym.streetAddress }}</em></li>
<li>City: <em>{{ gym.cityName }}</em></li>
<li>Country: <em>{{ gym.countryName }}</em></li>

View File

@ -1,10 +1,91 @@
<template>
<q-page>
<h3>Leaderboards</h3>
<p>Some text here.</p>
<div class="q-ma-md row justify-end q-gutter-sm">
<q-spinner
color="primary"
size="3em"
v-if="loadingIndicatorActive"
/>
<q-select
v-model="selectedExercise"
:options="exerciseOptions"
:disable="loadingIndicatorActive"
map-options
emit-value
/>
<q-select
v-model="selectedTimeframe"
:options="timeframeOptions"
:disable="loadingIndicatorActive"
map-options
emit-value
/>
</div>
<q-list>
<ExerciseSubmissionListItem v-for="sub in submissions" :submission="sub" :key="sub.id"/>
</q-list>
</q-page>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import api from 'src/api/main';
import { Exercise } from 'src/api/main/exercises';
import { Gym } from 'src/api/main/gyms';
import { LeaderboardTimeframe } from 'src/api/main/leaderboards';
import { ExerciseSubmission } from 'src/api/main/submission';
import ExerciseSubmissionListItem from 'src/components/ExerciseSubmissionListItem.vue';
import { getGymFromRoute } from 'src/router/gym-routing';
import { sleep } from 'src/utils';
import { onMounted, ref, Ref, watch, computed } from 'vue';
const submissions: Ref<Array<ExerciseSubmission>> = ref([]);
const gym: Ref<Gym | undefined> = ref();
const exercises: Ref<Array<Exercise>> = ref([]);
const exerciseOptions = computed(() => {
let options = exercises.value.map(exercise => {
return {
value: exercise.shortName,
label: exercise.displayName
};
});
options.push({ value: '', label: 'Any' });
return options;
});
const selectedExercise: Ref<string> = ref('');
const timeframeOptions = [
{ value: LeaderboardTimeframe.DAY, label: 'Day' },
{ value: LeaderboardTimeframe.WEEK, label: 'Week' },
{ value: LeaderboardTimeframe.MONTH, label: 'Month' },
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
{ value: LeaderboardTimeframe.ALL, label: 'All' },
];
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(LeaderboardTimeframe.DAY);
const loadingIndicatorActive = ref(false);
onMounted(async () => {
gym.value = await getGymFromRoute();
exercises.value = await api.exercises.getExercises();
doSearch();
});
async function doSearch() {
submissions.value = [];
if (gym.value) {
loadingIndicatorActive.value = true;
await sleep(500);
submissions.value = await api.leaderboards.getLeaderboard({
timeframe: selectedTimeframe.value,
gyms: [gym.value],
exerciseShortName: selectedExercise.value
});
loadingIndicatorActive.value = false;
}
}
watch([selectedTimeframe, selectedExercise], doSearch);
</script>
<style scoped></style>

View File

@ -12,6 +12,7 @@ A high-level overview of the submission process is as follows:
-->
<template>
<q-page v-if="gym">
<!-- The below form contains the fields that will become part of the submission. -->
<q-form @submit="onSubmitted">
<SlimForm>
<div class="row">
@ -82,6 +83,9 @@ A high-level overview of the submission process is as follows:
:disable="!submitButtonEnabled()"
/>
</div>
<div class="row text-center" v-if="infoMessage">
<p>{{ infoMessage }}</p>
</div>
</SlimForm>
</q-form>
</q-page>
@ -95,6 +99,7 @@ import api from 'src/api/main';
import { Gym } from 'src/api/main/gyms';
import { Exercise } from 'src/api/main/exercises';
import {useRouter} from 'vue-router';
import { sleep } from 'src/utils';
interface Option {
value: string;
@ -120,8 +125,8 @@ const selectedVideoFile: Ref<File | undefined> = ref<File>();
const weightUnits = ['KG', 'LBS'];
const submitting = ref(false);
const infoMessage: Ref<string | undefined> = ref();
// TODO: Make it possible to pass the gym to this via props instead.
onMounted(async () => {
try {
gym.value = await getGymFromRoute();
@ -139,7 +144,7 @@ onMounted(async () => {
});
function submitButtonEnabled() {
return selectedVideoFile.value !== undefined && validateForm();
return selectedVideoFile.value !== undefined && !submitting.value && validateForm();
}
function validateForm() {
@ -149,22 +154,29 @@ function validateForm() {
async function onSubmitted() {
if (!selectedVideoFile.value || !gym.value) throw new Error('Invalid state.');
submitting.value = true;
try {
infoMessage.value = 'Uploading video...';
await sleep(1000);
submissionModel.value.videoId = await api.gyms.submissions.uploadVideoFile(
gym.value,
selectedVideoFile.value
);
infoMessage.value = 'Creating submission...';
await sleep(1000);
const submission = await api.gyms.submissions.createSubmission(
gym.value,
submissionModel.value
);
infoMessage.value = 'Submission processing...';
const completedSubmission =
await api.gyms.submissions.waitUntilSubmissionProcessed(
gym.value,
submission.id
);
await api.gyms.submissions.waitUntilSubmissionProcessed(submission.id);
console.log(completedSubmission);
submitting.value = false;
infoMessage.value = 'Submission complete!';
await router.push(getGymRoute(gym.value));
} finally {
submitting.value = false;
}
}
</script>

View File

@ -30,3 +30,12 @@ export async function getGymFromRoute(): Promise<Gym> {
route.params.gymShortName as string
);
}
/**
* Gets the compound id for a gym; that is, the universally unique identifier
* that can be used for certain API requests.
* @param gym The gym to get the compound id for.
*/
export function getGymCompoundId(gym: GymRoutable): string {
return `${gym.countryCode}_${gym.cityShortName}_${gym.shortName}`;
}