diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..d0fca08a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gradle +.idea +build/ +bin/ +ui/node_modules/ +ui/dist/ +*.sqlite \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 599d69f4..82044849 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,9 @@ -ko_fi: freekode +# GitHub Sponsors - your GitHub username +github: rodrigobrancaglion + +# Ko-fi - your Ko-fi username +ko_fi: rodrigobrancaglion + +# Custom links (up to 4 URLs) +custom: + - https://paypal.me/rodrigobrancaglion diff --git a/Dockerfile b/Dockerfile index 0877f447..f1d7218a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,17 @@ +# --- Stage 1: Build do Backend --- +FROM amazoncorretto:21-alpine AS backend-builder +WORKDIR /build-app +RUN apk add --no-cache findutils dos2unix +COPY . . + +WORKDIR /build-app/boot +RUN dos2unix gradlew && chmod +x gradlew +RUN ./gradlew :bootJar --no-daemon + +# --- Stage 2: Runner --- FROM amazoncorretto:21-alpine -EXPOSE 8080 +WORKDIR /app +COPY --from=backend-builder /build-app/boot/build/libs/*.jar app.jar -ARG JAR_PATH='boot/build/libs/tp2intervals.jar' -COPY $JAR_PATH /app/app.jar -ENTRYPOINT java -jar /app/app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/README_Run.md b/README_Run.md new file mode 100644 index 00000000..1f496803 --- /dev/null +++ b/README_Run.md @@ -0,0 +1,20 @@ +# Third Party to Intervals.icu + +## - WEB Browser (Localhost) + +### Intellij +Run Configuraction 'Application' + +View: http://localhost:4200 +### Docker +Docker image also built for every release +```shell +docker-compose up --build +``` + +## - Desktop (App Electron) +Run script +```shell +./app_rebuild_electron.sh +``` +Run the file: electron/dist/mac-arm64/tp2intervals.app \ No newline at end of file diff --git a/app_rebuild_electron.sh b/app_rebuild_electron.sh new file mode 100755 index 00000000..07e086c3 --- /dev/null +++ b/app_rebuild_electron.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# 1. Compilar o Backend (Kotlin) +echo "Compilando Backend..." +./gradlew :boot:assemble + +# 2. Mover e renomear o JAR automaticamente +echo "Movendo JAR para a pasta do Electron..." +mkdir -p electron/artifact +cp boot/build/libs/*.jar electron/artifact/boot.jar + +# 3. Gerar o App Electron +echo "Gerando aplicativo descompactado..." +cd electron +npm run build:unpack + +echo "Pronto! O app atualizado está em: electron/dist/mac-arm64/tp2intervals.app" \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyC2CRequest.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyC2CRequest.kt new file mode 100644 index 00000000..23858ff7 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyC2CRequest.kt @@ -0,0 +1,14 @@ +package org.freekode.tp2intervals.app.wellness + +import org.freekode.tp2intervals.domain.Platform +import org.freekode.tp2intervals.domain.TrainingType +import java.time.LocalDate + +data class CopyFromCalendarToCalendarRequest( + val startDate: LocalDate, + val endDate: LocalDate, + val types: List, + val skipSynced: Boolean, + val sourcePlatform: Platform, + val targetPlatform: Platform +) \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyWellnessResponse.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyWellnessResponse.kt new file mode 100644 index 00000000..cd4c027c --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/CopyWellnessResponse.kt @@ -0,0 +1,11 @@ +package org.freekode.tp2intervals.app.wellness + +import org.freekode.tp2intervals.domain.ExternalData +import java.time.LocalDate + +data class CopyWellnessResponse( + val copied: Int, + val startDate: LocalDate, + val endDate: LocalDate, + val externalData: ExternalData +) diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/WellnessService.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/WellnessService.kt new file mode 100644 index 00000000..4dd05c11 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/WellnessService.kt @@ -0,0 +1,33 @@ +package org.freekode.tp2intervals.app.wellness + +import org.freekode.tp2intervals.domain.ExternalData +import org.freekode.tp2intervals.domain.wellness.WellnessRepository +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class WellnessService( + repositories: List, +) { + private val log = LoggerFactory.getLogger(this.javaClass) + private val repositoryMap = repositories.associateBy { it.platform() } + + fun copyWellnessC2C(request: CopyFromCalendarToCalendarRequest): CopyWellnessResponse { + log.info("Received request for copy calendar to calendar: $request") + val sourceRepository = repositoryMap[request.sourcePlatform]!! + val targetRepository = repositoryMap[request.targetPlatform]!! + + val allToSync = sourceRepository.getFromCalendar(request.startDate, request.endDate) + + val response = CopyWellnessResponse( + 1, + request.startDate, + request.endDate, + ExternalData.empty() + ) + targetRepository.saveToCalendar(allToSync, request.startDate, request.endDate) + log.info("Saved Wellness (Metrics) to calendar successfully: $response") + return response + } + +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/C2CTodayScheduledRequest.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/C2CTodayScheduledRequest.kt new file mode 100644 index 00000000..a62801bf --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/C2CTodayScheduledRequest.kt @@ -0,0 +1,22 @@ +package org.freekode.tp2intervals.app.wellness.schedule + +import org.freekode.tp2intervals.app.wellness.CopyFromCalendarToCalendarRequest +import org.freekode.tp2intervals.domain.Platform +import org.freekode.tp2intervals.domain.TrainingType +import java.time.LocalDate + +data class C2CTodayScheduledRequest( + val types: List, + val skipSynced: Boolean, + val sourcePlatform: Platform, + val targetPlatform: Platform +) : Schedulable { + fun forToday() = CopyFromCalendarToCalendarRequest( + LocalDate.now(), + LocalDate.now(), + types, + skipSynced, + sourcePlatform, + targetPlatform + ) +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/Schedulable.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/Schedulable.kt new file mode 100644 index 00000000..75fddcd5 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/Schedulable.kt @@ -0,0 +1,4 @@ +package org.freekode.tp2intervals.app.wellness.schedule + + +interface Schedulable diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/ScheduledJobService.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/ScheduledJobService.kt new file mode 100644 index 00000000..e5c4d642 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/wellness/schedule/ScheduledJobService.kt @@ -0,0 +1,63 @@ +package org.freekode.tp2intervals.app.wellness.schedule + +import com.fasterxml.jackson.databind.ObjectMapper +import org.freekode.tp2intervals.app.wellness.WellnessService +import org.freekode.tp2intervals.domain.TrainingType +import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestEntity +import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestRepository +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.util.concurrent.TimeUnit + +@Service +class ScheduledJobService( + private val wellnessService: WellnessService, + private val scheduleRequestRepository: ScheduleRequestRepository, + private val objectMapper: ObjectMapper +) { + private val log = LoggerFactory.getLogger(this.javaClass) + + fun addRequest(schedulable: Schedulable) { + val requestJson = objectMapper.writeValueAsString(schedulable) + if (scheduleRequestRepository.findByRequestJson(requestJson) != null) throw IllegalArgumentException("Request already exists") + scheduleRequestRepository.save(ScheduleRequestEntity(requestJson)) + } + + fun getRequests(): List { + return scheduleRequestRepository.findAll().filter { record -> + try { + // Internal conversion just to check the types inside the JSON + val request = objectMapper.readValue(record.requestJson, C2CTodayScheduledRequest::class.java) + request.types.contains(TrainingType.WEIGHT) + } catch (e: Exception) { + // If JSON is incompatible, exclude it from the wellness list + false + } + } + } + + fun deleteRequest(id: Int) { + scheduleRequestRepository.deleteById(id) + } + + @Scheduled(fixedRate = 3, timeUnit = TimeUnit.MINUTES) + fun job() { + val requests = getRequests().map { it.toSchedulable() } + log.info("Starting processing scheduled requests. There are ${requests.size} requests") + + for (request in requests) { + handleCopyCalendarToCalendarRequest(request) + } + + log.info("Finished processing scheduled requests") + } + + private fun handleCopyCalendarToCalendarRequest(request: C2CTodayScheduledRequest) { + wellnessService.copyWellnessC2C(request.forToday()) + } + + private fun ScheduleRequestEntity.toSchedulable(): C2CTodayScheduledRequest { + return objectMapper.readValue(requestJson, C2CTodayScheduledRequest::class.java) + } +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/app/workout/schedule/WorkoutScheduledJob.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/app/workout/schedule/WorkoutScheduledJob.kt index e310b107..29ae5f54 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/app/workout/schedule/WorkoutScheduledJob.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/app/workout/schedule/WorkoutScheduledJob.kt @@ -2,6 +2,7 @@ package org.freekode.tp2intervals.app.workout.schedule import com.fasterxml.jackson.databind.ObjectMapper import org.freekode.tp2intervals.app.workout.WorkoutService +import org.freekode.tp2intervals.domain.TrainingType import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestEntity import org.freekode.tp2intervals.infrastructure.schedule.ScheduleRequestRepository import org.slf4j.LoggerFactory @@ -24,7 +25,32 @@ class WorkoutScheduledJob( } fun getRequests() = - scheduleRequestRepository.findAll().toList() + scheduleRequestRepository.findAll() + .filter { record -> + try { + // Internal conversion just to check the types inside the JSON + val request = objectMapper.readValue( + record.requestJson, + org.freekode.tp2intervals.app.wellness.schedule.C2CTodayScheduledRequest::class.java + ) + request.types.contains(TrainingType.SWIM) + || request.types.contains(TrainingType.BIKE) + || request.types.contains(TrainingType.RUN) + || request.types.contains(TrainingType.BRICK) + || request.types.contains(TrainingType.RACE) + || request.types.contains(TrainingType.DAY_OFF) + || request.types.contains(TrainingType.STRENGTH) + || request.types.contains(TrainingType.MTB) + || request.types.contains(TrainingType.VIRTUAL_BIKE) + || request.types.contains(TrainingType.WALK) + || request.types.contains(TrainingType.NOTE) + || request.types.contains(TrainingType.UNKNOWN) + } catch (e: Exception) { + // If JSON is incompatible, exclude it from the wellness list + false + } + } + .toList() fun deleteRequest(id: Int) { scheduleRequestRepository.deleteById(id) diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/CategoryType.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/CategoryType.kt new file mode 100644 index 00000000..1cb34318 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/CategoryType.kt @@ -0,0 +1,5 @@ +package org.freekode.tp2intervals.domain + +enum class CategoryType { + WORKOUT, RACE_A, RACE_B, RACE_C, NOTE, PLAN, HOLIDAY, SICK, INJURED, SET_EFTP, FITNESS_DAYS, SEASON_START, TARGET, SET_FITNESS, WELLNESS +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/TrainingType.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/TrainingType.kt index eda6b4f9..2f9009d5 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/TrainingType.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/TrainingType.kt @@ -1,17 +1,27 @@ package org.freekode.tp2intervals.domain -enum class TrainingType(val title: String) { - BIKE("Ride"), - MTB("MTB"), - VIRTUAL_BIKE("Virtual Ride"), - RUN("Run"), - SWIM("Swim"), - WALK("Walk"), - WEIGHT("Weight"), - NOTE("Note"), - UNKNOWN("Unknown"); +enum class TrainingType(val title: String, val category: CategoryType) { + SWIM("Swim", CategoryType.WORKOUT), + BIKE("Ride", CategoryType.WORKOUT), + RUN("Run", CategoryType.WORKOUT), + BRICK("Brick", CategoryType.NOTE), + //crosstrain + RACE("Race", CategoryType.WORKOUT), + DAY_OFF("Day-off", CategoryType.NOTE), + STRENGTH("Strength", CategoryType.WORKOUT), + + MTB("MTB", CategoryType.WORKOUT), + VIRTUAL_BIKE("Virtual Ride", CategoryType.WORKOUT), + WALK("Walk", CategoryType.WORKOUT), + NOTE("Note", CategoryType.NOTE), + UNKNOWN("Unknown", CategoryType.WORKOUT), + + //WELLNESS (Metrics) + WEIGHT("Weight Wellness", CategoryType.WELLNESS), + ; companion object { - val DEFAULT_LIST = listOf(BIKE, VIRTUAL_BIKE, MTB, RUN, SWIM) + val DEFAULT_LIST = listOf(BIKE, VIRTUAL_BIKE, MTB, RUN, SWIM, DAY_OFF, NOTE, BRICK) } + } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/Wellness.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/Wellness.kt new file mode 100644 index 00000000..e63dfa77 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/Wellness.kt @@ -0,0 +1,11 @@ +package org.freekode.tp2intervals.domain.wellness + +class Wellness( + val date: String?, + val type: String?, + val weight: Double?, +) { + companion object { + const val DATE_FORMAT = "yyyy-MM-dd" + } +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/WellnessRepository.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/WellnessRepository.kt new file mode 100644 index 00000000..0776d46d --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/wellness/WellnessRepository.kt @@ -0,0 +1,12 @@ +package org.freekode.tp2intervals.domain.wellness + +import org.freekode.tp2intervals.domain.Platform +import java.time.LocalDate + +interface WellnessRepository { + fun platform(): Platform + + fun getFromCalendar(startDate: LocalDate, endDate: LocalDate): List + + fun saveToCalendar(wellnesses: List, startDate: LocalDate, endDate: LocalDate) +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/WorkoutDetails.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/WorkoutDetails.kt index 63c8ca43..970a016c 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/WorkoutDetails.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/WorkoutDetails.kt @@ -1,10 +1,10 @@ package org.freekode.tp2intervals.domain.workout -import java.io.Serializable -import java.time.Duration -import java.time.LocalDate import org.freekode.tp2intervals.domain.ExternalData import org.freekode.tp2intervals.domain.TrainingType +import java.io.Serializable +import java.time.Duration +import java.time.temporal.ChronoUnit data class WorkoutDetails( val type: TrainingType, @@ -22,8 +22,9 @@ data class WorkoutDetails( other as WorkoutDetails - if (name != other.name) return false - if (duration != other.duration) return false + if (name.uppercase() != other.name.uppercase() + && !name.uppercase().startsWith(other.name.uppercase())) return false + if (duration?.truncatedTo(ChronoUnit.MINUTES) != other.duration?.truncatedTo(ChronoUnit.MINUTES)) return false if (externalData != other.externalData) return false return true diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/SingleStep.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/SingleStep.kt index d94346a4..d3e3ddc2 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/SingleStep.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/SingleStep.kt @@ -4,11 +4,19 @@ import org.freekode.tp2intervals.utils.RampConverter class SingleStep( val name: String?, + val notes: String?, val length: StepLength, val target: StepTarget, val cadence: StepTarget?, val ramp: Boolean ) : WorkoutStep { + + constructor(name: String?, + length: StepLength, + target: StepTarget, + cadence: StepTarget?, + ramp: Boolean) : this(name, null, length, target, cadence, ramp) + override fun isSingleStep() = true fun convertRampToMultiStep(): MultiStep { diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/WorkoutStructure.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/WorkoutStructure.kt index c443f183..162dda29 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/WorkoutStructure.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/domain/workout/structure/WorkoutStructure.kt @@ -19,5 +19,6 @@ data class WorkoutStructure( FTP_PERCENTAGE, LTHR_PERCENTAGE, PACE_PERCENTAGE, + RELATIVE_PERCEIVED_EFFORT, } } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/IntervalsApiClient.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/IntervalsApiClient.kt index 17f026f2..69d1c1d9 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/IntervalsApiClient.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/IntervalsApiClient.kt @@ -1,16 +1,13 @@ package org.freekode.tp2intervals.infrastructure.platform.intervalsicu import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.activity.CreateActivityResponseDTO +import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.wellness.IntervalsWellnessDTO import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout.CreateEventRequestDTO import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout.CreateWorkoutRequestDTO import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout.IntervalsEventDTO import org.springframework.cloud.openfeign.FeignClient import org.springframework.http.MediaType -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestPart +import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile @FeignClient( @@ -65,4 +62,17 @@ interface IntervalsApiClient { @PathVariable name: String, @RequestPart("file") file: MultipartFile ): CreateActivityResponseDTO + + @GetMapping("/api/v1/athlete/{athleteId}/wellness?oldest={startDate}&newest={endDate}") + fun getWellness( + @PathVariable("athleteId") athleteId: String, + @PathVariable("startDate") startDate: String, + @PathVariable("endDate") endDate: String, + ): List + + @PutMapping("/api/v1/athlete/{athleteId}/wellness") + fun updateWellness( + @PathVariable athleteId: String, + @RequestBody requestDTO: IntervalsWellnessDTO + ) } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessConverter.kt new file mode 100644 index 00000000..aa7705e6 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessConverter.kt @@ -0,0 +1,55 @@ +package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.wellness + +import org.freekode.tp2intervals.domain.TrainingType +import org.freekode.tp2intervals.domain.wellness.Wellness +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +class IntervalsWellnessConverter { + private var wellnessDTO: IntervalsWellnessDTO? = null + private var wellness: Wellness? = null + + // Construtor para quando você tem o DTO (vindo do Intervals) + constructor(wellnessDTO: IntervalsWellnessDTO) { + this.wellnessDTO = wellnessDTO + } + + // Construtor para quando você tem o Domain (para enviar ao Intervals) + constructor(wellness: Wellness?) { + this.wellness = wellness + } + + fun toDTO(): IntervalsWellnessDTO { + val outputFormatter = DateTimeFormatter.ofPattern(Wellness.DATE_FORMAT) + + // 1. Calculamos a data formatada corretamente + val formattedDate = wellness?.date?.let { dateStr -> + try { + val date = if (dateStr.contains("T")) { + java.time.OffsetDateTime.parse(dateStr).toLocalDate() + } else { + // Pega os 10 primeiros caracteres e transforma em LocalDate para validar + LocalDate.parse(dateStr.take(10)) + } + date.format(outputFormatter) + } catch (e: Exception) { + // Se falhar, tenta retornar apenas os 10 caracteres ou o que estiver disponível + dateStr.take(10) + } + } + + // 2. Usamos a 'formattedDate' no campo 'id' do DTO + return IntervalsWellnessDTO( + id = formattedDate ?: "", // Aqui estava o erro: você usava wellness?.date + weight = wellness?.weight + ) + } + + fun toDomain(): Wellness { + return Wellness( + date = wellnessDTO?.id, + type = TrainingType.WEIGHT.toString(), + weight = wellnessDTO?.weight ?: -1.0 + ) + } +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessDTO.kt new file mode 100644 index 00000000..a4fe9dac --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessDTO.kt @@ -0,0 +1,58 @@ +package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.wellness + +import java.time.OffsetDateTime + +class IntervalsWellnessDTO( + val id: String?, // Data in format "yyyy-MM-dd" + val weight: Double? = -1.0, + val ctl: Double? = null, + val atl: Double? = null, + val rampRate: Double? = null, + val ctlLoad: Double? = null, + val atlLoad: Double? = null, + val sportInfo: List? = null, + val updated: OffsetDateTime? = null, + + // Métricas de Saúde (Nullables) + val restingHR: Int? = null, + val hrv: Double? = null, + val hrvSDNN: Double? = null, + val menstrualPhase: String? = null, + val menstrualPhasePredicted: String? = null, + val kcalConsumed: Int? = null, + val sleepSecs: Int? = null, + val sleepScore: Double? = null, + val sleepQuality: Int? = null, + val avgSleepingHR: Double? = null, + val soreness: Int? = null, + val fatigue: Int? = null, + val stress: Int? = null, + val mood: Int? = null, + val motivation: Int? = null, + val injury: Int? = null, + val spO2: Double? = null, + val systolic: Int? = null, + val diastolic: Int? = null, + val hydration: Int? = null, + val hydrationVolume: Double? = null, + val readiness: Double? = null, + val baevskySI: Double? = null, + val bloodGlucose: Double? = null, + val lactate: Double? = null, + val bodyFat: Double? = null, + val abdomen: Double? = null, + val vo2max: Double? = null, + val comments: String? = null, + val steps: Int? = null, + val respiration: Double? = null, + val locked: Boolean? = null, + val tempWeight: Boolean = false, + val tempRestingHR: Boolean = false +) { + data class SportInfoDTO( + val type: String?, + val eftp: Double?, + val wPrime: Double?, + val pMax: Double? + ) +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessRepository.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessRepository.kt new file mode 100644 index 00000000..ebe5161b --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/wellness/IntervalsWellnessRepository.kt @@ -0,0 +1,75 @@ +package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.wellness + +import org.freekode.tp2intervals.domain.Platform +import org.freekode.tp2intervals.domain.wellness.Wellness +import org.freekode.tp2intervals.domain.wellness.WellnessRepository +import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.IntervalsApiClient +import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.configuration.IntervalsConfigurationRepository +import org.springframework.stereotype.Repository +import java.time.LocalDate + +@Repository +class IntervalsWellnessRepository( + private val intervalsApiClient: IntervalsApiClient, + private val intervalsConfigurationRepository: IntervalsConfigurationRepository, +) : WellnessRepository { + + override fun platform() = Platform.INTERVALS + + override fun getFromCalendar(startDate: LocalDate, endDate: LocalDate): List { + val configuration = intervalsConfigurationRepository.getConfiguration() + + val listWellnessDTO = intervalsApiClient.getWellness( + configuration.athleteId, + startDate.toString(), + endDate.toString()) + + val listWellness = listWellnessDTO + .map { IntervalsWellnessConverter(it).toDomain() } + + return listWellness + } + + /** + * Orchestrates the synchronization of wellness data for a given date range. + */ + override fun saveToCalendar(wellnesses: List, startDate: LocalDate, endDate: LocalDate) { + val athleteId = intervalsConfigurationRepository.getConfiguration().athleteId + + // Create a map for quick lookup of existing data by date + val wellnessMap = wellnesses.filterNotNull().associateBy { it.date?.take(10) ?: "" } + + var currentDate = startDate + while (!currentDate.isAfter(endDate)) { + val dateStr = currentDate.toString() + val wellnessEntry = wellnessMap[dateStr] + + if (wellnessEntry != null) { + // Day has data: Perform update + updateWellness(athleteId, wellnessEntry) + } else { + // Day is empty: Perform clear + clearWellness(athleteId, dateStr) + } + + currentDate = currentDate.plusDays(1) + } + } + + /** + * Sends a filled DTO to update wellness metrics for a specific date. + */ + private fun updateWellness(athleteId: String, wellness: Wellness) { + val requestDTO = IntervalsWellnessConverter(wellness).toDTO() + intervalsApiClient.updateWellness(athleteId, requestDTO) + } + + /** + * Sends an empty DTO to reset/clear all wellness metrics for a specific date. + */ + private fun clearWellness(athleteId: String, date: String) { + val emptyWellness = IntervalsWellnessDTO(id = date) + intervalsApiClient.updateWellness(athleteId, emptyWellness) + } + +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/CreateEventRequestDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/CreateEventRequestDTO.kt index 06f6980f..2a66436a 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/CreateEventRequestDTO.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/CreateEventRequestDTO.kt @@ -1,9 +1,12 @@ package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout class CreateEventRequestDTO( - val start_date_local: String, - val name: String, - val type: String, - val category: String, - val description: String, + val start_date_local: String?, + val name: String?, + val type: String?, + val category: String?, + val description: String?, + + val moving_time: Long?, + val icu_training_load: Int?, ) diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/FromIntervalsWorkoutConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/FromIntervalsWorkoutConverter.kt index 63db0029..f15a991c 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/FromIntervalsWorkoutConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/FromIntervalsWorkoutConverter.kt @@ -3,11 +3,7 @@ package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout import org.freekode.tp2intervals.domain.ExternalData import org.freekode.tp2intervals.domain.workout.Workout import org.freekode.tp2intervals.domain.workout.WorkoutDetails -import org.freekode.tp2intervals.domain.workout.structure.MultiStep -import org.freekode.tp2intervals.domain.workout.structure.SingleStep -import org.freekode.tp2intervals.domain.workout.structure.StepLength -import org.freekode.tp2intervals.domain.workout.structure.WorkoutStep -import org.freekode.tp2intervals.domain.workout.structure.WorkoutStructure +import org.freekode.tp2intervals.domain.workout.structure.* class FromIntervalsWorkoutConverter( private val eventDTO: IntervalsEventDTO @@ -77,6 +73,7 @@ class FromIntervalsWorkoutConverter( return SingleStep( stepDTO.text, + stepDTO.notes, getStepLength(stepDTO), mainTarget, cadenceTarget, diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsEventDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsEventDTO.kt index b5b09b85..215b0c76 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsEventDTO.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsEventDTO.kt @@ -1,8 +1,8 @@ package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout +import org.freekode.tp2intervals.domain.TrainingType import java.time.Duration import java.time.LocalDateTime -import org.freekode.tp2intervals.domain.TrainingType class IntervalsEventDTO( val id: Long, @@ -16,7 +16,17 @@ class IntervalsEventDTO( val workout_doc: IntervalsWorkoutDocDTO?, ) { - fun mapType(): TrainingType = type?.let { IntervalsTrainingTypeMapper.getByIntervalsType(it) } ?: TrainingType.UNKNOWN + fun mapType(): TrainingType { + // 1. Try mapping by the 'type' field. + val typeFromField = type?.let { IntervalsTrainingTypeMapper.getByIntervalsType(it) } ?: TrainingType.UNKNOWN + + // 2. If it's UNKNOWN, try mapping it using the 'category' field. + if (typeFromField == TrainingType.UNKNOWN) { + return IntervalsTrainingTypeMapper.getByIntervalsType(category) + } + + return typeFromField + } fun mapDuration(): Duration? = moving_time?.let { Duration.ofSeconds(it) } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsTrainingTypeMapper.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsTrainingTypeMapper.kt index bc565c7b..33ded140 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsTrainingTypeMapper.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsTrainingTypeMapper.kt @@ -10,14 +10,16 @@ class IntervalsTrainingTypeMapper { TrainingType.VIRTUAL_BIKE to "VirtualRide", TrainingType.RUN to "Run", TrainingType.SWIM to "Swim", - TrainingType.WEIGHT to "WeightTraining", - TrainingType.NOTE to "NOTE", + TrainingType.STRENGTH to "Strength", + TrainingType.NOTE to "Note", + TrainingType.BRICK to "Brick", TrainingType.UNKNOWN to "Other", TrainingType.WALK to "Walk", + TrainingType.DAY_OFF to "Day_off", ) fun getByIntervalsType(intervalsType: String): TrainingType = - typeMap.filterValues { it == intervalsType }.keys.firstOrNull() ?: TrainingType.UNKNOWN + typeMap.filterValues { it.equals(intervalsType, ignoreCase = true) }.keys.firstOrNull() ?: TrainingType.UNKNOWN fun getByTrainingType(trainingType: TrainingType): String = typeMap[trainingType]!! } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutDocDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutDocDTO.kt index 19f265af..91b70567 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutDocDTO.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutDocDTO.kt @@ -17,6 +17,7 @@ class IntervalsWorkoutDocDTO( "POWER" to WorkoutStructure.TargetUnit.FTP_PERCENTAGE, "HR" to WorkoutStructure.TargetUnit.LTHR_PERCENTAGE, "PACE" to WorkoutStructure.TargetUnit.PACE_PERCENTAGE, + "RPE" to WorkoutStructure.TargetUnit.RELATIVE_PERCEIVED_EFFORT, ) fun mapTarget(): WorkoutStructure.TargetUnit { @@ -25,6 +26,7 @@ class IntervalsWorkoutDocDTO( class WorkoutStepDTO( val text: String?, + val notes: String?, val reps: Int?, val duration: Long?, val distance: Long?, diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutRepository.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutRepository.kt index e7f7b09e..3cb45474 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutRepository.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/IntervalsWorkoutRepository.kt @@ -1,6 +1,5 @@ package org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout -import java.time.LocalDate import org.freekode.tp2intervals.domain.ExternalData import org.freekode.tp2intervals.domain.Platform import org.freekode.tp2intervals.domain.librarycontainer.LibraryContainer @@ -12,6 +11,7 @@ import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.IntervalsA import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.configuration.IntervalsConfigurationRepository import org.slf4j.LoggerFactory import org.springframework.stereotype.Repository +import java.time.LocalDate @Repository class IntervalsWorkoutRepository( @@ -56,8 +56,8 @@ class IntervalsWorkoutRepository( configuration.paceRange, ) return events - .filter { it.isWorkout() } - .mapNotNull { toWorkout(it) } + //.filter { it.isWorkout() } // Not only Workout + .mapNotNull { toEvent(it) } } override fun getWorkoutFromLibrary(externalData: ExternalData): Workout { @@ -76,6 +76,10 @@ class IntervalsWorkoutRepository( TODO("Not yet implemented") } + private fun toEvent(eventDTO: IntervalsEventDTO): Workout? { + return toWorkout(eventDTO) + } + private fun toWorkout(eventDTO: IntervalsEventDTO): Workout? { return try { FromIntervalsWorkoutConverter(eventDTO).toWorkout() diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsStructureConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsStructureConverter.kt index 56d65033..5512a83d 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsStructureConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsStructureConverter.kt @@ -7,13 +7,18 @@ import java.time.Duration class ToIntervalsStructureConverter( private val structure: WorkoutStructure, ) { + + private var cadenceWasReset: Boolean = true + private val targetTypeMap = mapOf( WorkoutStructure.TargetUnit.FTP_PERCENTAGE to "%", WorkoutStructure.TargetUnit.LTHR_PERCENTAGE to "% LTHR", WorkoutStructure.TargetUnit.PACE_PERCENTAGE to "% Pace", + WorkoutStructure.TargetUnit.RELATIVE_PERCEIVED_EFFORT to "", ) fun toIntervalsStructureStr(): String { + cadenceWasReset = true return structure.steps.joinToString(separator = "\n") { toIntervalsStep(it) } } @@ -31,27 +36,73 @@ class ToIntervalsStructureConverter( } private fun getStepString(workoutStep: SingleStep): String { - val name = workoutStep.name.orEmpty().replace("\\", "/") + val description = getDescription(structure.target, workoutStep) + val notes = getNotes(workoutStep) val length = toStepLength(workoutStep.length) val targetUnitStr = targetTypeMap[structure.target]!! - val target: String = if (workoutStep.target.isSingleValue()) { - "${workoutStep.target.start}" - } else { - "${workoutStep.target.start}-${workoutStep.target.end}" - } - val cadence = workoutStep.cadence?.let { - if (it.isSingleValue()) { - "${it.start}rpm" + val target = toTarget(structure.target, workoutStep.target) + val cadence = toCadence(workoutStep) + + val stepLine = "- $description $length $target$targetUnitStr ${structure.modifier.value} $cadence".trim() + + return "$stepLine$notes" + } + + private fun getDescription(targetUnit: WorkoutStructure.TargetUnit, workoutStep: SingleStep) : String { + var description = workoutStep.name.orEmpty().replace("\\", "/") +// ?.takeIf { it.isNotEmpty() } +// ?.let { "$it" } ?: "" //TODO Format Description + + if (targetUnit == WorkoutStructure.TargetUnit.RELATIVE_PERCEIVED_EFFORT) { + if (workoutStep.target.isSingleValue()) { + description += " RPE ${workoutStep.target.start}" } else { - "${it.start}-${it.end}rpm" + description += " RPE ${workoutStep.target.start}-${workoutStep.target.end}" } - } ?: "" + } + + return description.trim() + } - return "- $name $length $target$targetUnitStr ${structure.modifier.value} $cadence" + private fun getNotes(workoutStep: SingleStep): String { + return workoutStep.notes?.replace("\\", "/") + ?.takeIf { it.isNotEmpty() } + ?.let { " \nNotes: $it" } ?: "" +// ?.let { " \nNotes: $it" } ?: "" //TODO Format Description + } + + private fun toCadence(workoutStep: SingleStep): String { + if (workoutStep.cadence != null) { + val it = workoutStep.cadence + cadenceWasReset = false + return if (it.isSingleValue()) " ${it.start}rpm" else " ${it.start}-${it.end}rpm" + } + + if (!cadenceWasReset) { + cadenceWasReset = true + return " 0rpm" + } + + return "" } private fun toStepLength(length: StepLength) = when (length.unit) { LengthUnit.SECONDS -> Duration.ofSeconds(length.value).toString().substring(2).lowercase() LengthUnit.METERS -> (length.value / 1000.0).toString() + "km" } + + private fun toTarget(targetUnit: WorkoutStructure.TargetUnit, target: StepTarget) : String { + val targetVal = + if (targetUnit == WorkoutStructure.TargetUnit.RELATIVE_PERCEIVED_EFFORT) { + "" + } else { + if (target.isSingleValue()) { + "${target.start}" + } else { + "${target.start}-${target.end}" + } + } + + return targetVal + } } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsWorkoutConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsWorkoutConverter.kt index 82f3988f..65e187de 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsWorkoutConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/intervalsicu/workout/ToIntervalsWorkoutConverter.kt @@ -39,22 +39,35 @@ class ToIntervalsWorkoutConverter { (workout.date ?: LocalDate.now()).atStartOfDay().toString(), workout.details.name, IntervalsTrainingTypeMapper.getByTrainingType(workout.details.type), - "WORKOUT", - description + IntervalsTrainingTypeMapper.getByIntervalsType(workout.details.type.toString()).category.toString(), + description, + workout.details.duration?.seconds, + workout.details.load, ) } - private fun getDescription(workout: Workout, workoutString: String?): String { - var description = workout.details.description - .orEmpty() - .replace(unwantedStepRegex, "`-") - .let { "$it\n- - - -\n${Signature.description}" } - description += workoutString - ?.let { "\n\n- - - -\n$it" } - .orEmpty() - description += "\n\n${workout.details.externalData.toSimpleString()}" - return description + return buildString { + // Block - Description + workout.details.description?.takeIf { it.isNotBlank() }?.let { + val formatted = it.replace(unwantedStepRegex, "`-") + append("\n- - - -\n${Signature.description}") + append("#### Description\n\n$formatted\n\n
\n\n") + } + + // Block - Workout Details + workoutString?.takeIf { it.isNotBlank() }?.let { + append("\n\n- - - -\n") + append("#### Workout Details\n$it\n\n") + } + + // Block - ExternalData (Signature/Links) + val externalData = workout.details.externalData.toSimpleString() + if (externalData.isNotBlank()) { + append(externalData) + } + }.trim() + .replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } private fun getWorkoutString(workout: Workout) = diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/TrainingPeaksApiClient.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/TrainingPeaksApiClient.kt index 167b8796..fb25a565 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/TrainingPeaksApiClient.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/TrainingPeaksApiClient.kt @@ -1,16 +1,13 @@ package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.metrics.TPMetricsDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.CreateTPWorkoutRequestDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPNoteResponseDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPWorkoutCalendarResponseDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPWorkoutDetailsResponseDTO import org.springframework.cloud.openfeign.FeignClient import org.springframework.core.io.Resource -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.* @FeignClient( value = "TrainingPeaksApiClient", @@ -64,4 +61,23 @@ interface TrainingPeaksApiClient { @PathVariable userId: String, @PathVariable workoutId: String, ): Boolean + + @GetMapping("/metrics/v3/athletes/{athleteId}/consolidatedtimedmetrics/{startDate}/{endDate}") + fun getMetrics( + @PathVariable("athleteId") userId: String, + @PathVariable("startDate") startDate: String, + @PathVariable("endDate") endDate: String + ): List + + @PutMapping("/metrics/v3/athletes/{athleteId}/consolidatedtimedmetric") + fun createMetrics( + @PathVariable athleteId: String, + @RequestBody requestDTO: TPMetricsDTO + ) + + @DeleteMapping("/metrics/v3/athletes/{athleteId}/consolidatedtimedmetric") + fun deleteMetrics( + @PathVariable athleteId: String, + @RequestBody requestDTO: TPMetricsDTO + ) } diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsConverter.kt new file mode 100644 index 00000000..6dd99a97 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsConverter.kt @@ -0,0 +1,53 @@ +package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.metrics + +import org.freekode.tp2intervals.domain.TrainingType +import org.freekode.tp2intervals.domain.wellness.Wellness +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPTrainingTypeMapper + +class TPMetricsConverter { + + private var metricsDTO: TPMetricsDTO? = null + private var metrics: Wellness? = null + + // Constructor for when you have the DTO (coming from Trainingpeaks) + constructor(metricsDTO: TPMetricsDTO) { + this.metricsDTO = metricsDTO + } + + // Constructor for when you have the Domain (to submit to Trainingpeaks) + constructor(wellness: Wellness?) { + this.metrics = wellness + } + + fun toDTO(athleteId: String): TPMetricsDTO { + val timeWithHour = "${metrics?.date}T00:00:00" + val pType = TPTrainingTypeMapper.getByType(TrainingType.WEIGHT) + + return TPMetricsDTO( + id = null, + athleteId = athleteId.toLong(), + timeStamp = "${metrics?.date}T00:00:00", + details = listOf( + TPMetricsDTO.TPMetricsDetailDTO( + parentId = null, + type = pType, + value = metrics?.weight, + isPotentiallyNegative = false, + uploadClient = null, + label = TrainingType.WEIGHT.toString(), + time = timeWithHour, + modifiedTime = null + ) + ) + ) + } + + fun toDomain(): Wellness { + return Wellness( + date = metricsDTO?.timeStamp, + type = TrainingType.WEIGHT.toString(), + weight = metricsDTO?.getMetricWeight() + ) + } + +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsDTO.kt new file mode 100644 index 00000000..b2776ce6 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TPMetricsDTO.kt @@ -0,0 +1,31 @@ +package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.metrics + +import org.freekode.tp2intervals.domain.TrainingType +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPTrainingTypeMapper + +class TPMetricsDTO( + val id: String?, + val athleteId: Long?, + val timeStamp: String?, // Format: 2026-01-09T00:00:00 + val details: List = emptyList() +) { + class TPMetricsDetailDTO( + val parentId: Long?, + val type: Int?, + var value: Double?, + val isPotentiallyNegative: Boolean?, + val uploadClient: String?, + val label: String?, + val time: String?, + val modifiedTime: String? + ) + + fun getMetricWeight(): Double? { + return details.find { it.type == TPTrainingTypeMapper.getByType(TrainingType.WEIGHT) }?.value + } + + fun getDetailByType(trainingType: TrainingType): TPMetricsDetailDTO? { + val tpType = TPTrainingTypeMapper.getByType(trainingType) + return details.find { it.type == tpType } + } +} \ No newline at end of file diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TrainingPeaksMetricsRepository.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TrainingPeaksMetricsRepository.kt new file mode 100644 index 00000000..95c8c23f --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/metrics/TrainingPeaksMetricsRepository.kt @@ -0,0 +1,65 @@ +package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.metrics + +import org.freekode.tp2intervals.domain.Platform +import org.freekode.tp2intervals.domain.TrainingType +import org.freekode.tp2intervals.domain.wellness.Wellness +import org.freekode.tp2intervals.domain.wellness.WellnessRepository +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.TrainingPeaksApiClient +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.user.TrainingPeaksUserRepository +import org.springframework.cache.annotation.CacheConfig +import org.springframework.stereotype.Repository +import java.time.LocalDate + + +@CacheConfig(cacheNames = ["tpMetricsCache"]) +@Repository +class TrainingPeaksMetricsRepository( + private val trainingPeaksApiClient: TrainingPeaksApiClient, + private val trainingPeaksUserRepository: TrainingPeaksUserRepository, +) : WellnessRepository { + + override fun platform() = Platform.TRAINING_PEAKS + + override fun getFromCalendar(startDate: LocalDate, endDate: LocalDate): List { + val athleteId = trainingPeaksUserRepository.getUser().userId + val listMetricsDTO = trainingPeaksApiClient.getMetrics( + athleteId, + startDate.toString(), + endDate.toString()) + + val listWellness = listMetricsDTO.map { + TPMetricsConverter(it).toDomain() + } + + return listWellness + } + + override fun saveToCalendar(wellnesses: List, startDate: LocalDate, endDate: LocalDate) { + val athleteId = trainingPeaksUserRepository.getUser().userId + + wellnesses.filterNotNull().forEach { wellness -> + val requestDTO = TPMetricsConverter(wellness).toDTO(athleteId) + if (requestDTO.getMetricWeight() == -1.0) { + deleteWellness(athleteId, requestDTO) + } else { + trainingPeaksApiClient.createMetrics(athleteId, requestDTO) + } + } + } + + private fun deleteWellness(athleteId: String, requestDTO: TPMetricsDTO) { + val existingMetrics = trainingPeaksApiClient.getMetrics( + athleteId, + requestDTO.timeStamp.toString().take(10), + requestDTO.timeStamp.toString().take(10) + ) + + existingMetrics.forEach { metric -> + metric.getDetailByType(TrainingType.WEIGHT)?.let { weightDetail -> + weightDetail.value = -1.0 + trainingPeaksApiClient.deleteMetrics(athleteId, metric) + } + } + } + +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPToWorkoutConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPToWorkoutConverter.kt index f00d7a9e..17d18066 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPToWorkoutConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPToWorkoutConverter.kt @@ -38,10 +38,15 @@ class TPToWorkoutConverter { var description = tpWorkout.description.orEmpty() description += tpWorkout.coachComments?.let { "\n- - - -\n$it" }.orEmpty() + val capitalizedTitle = if (tpWorkout.title.isNullOrBlank()) "Workout" + else tpWorkout.title.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + } + return Workout( WorkoutDetails( tpWorkout.getWorkoutType()!!, - if (tpWorkout.title.isNullOrBlank()) "Workout" else tpWorkout.title, + capitalizedTitle, description, tpWorkout.totalTimePlanned?.let { Duration.ofMinutes((it * 60).toLong()) }, tpWorkout.tssPlanned, diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPTrainingTypeMapper.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPTrainingTypeMapper.kt index 41d6b90b..ea912bf6 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPTrainingTypeMapper.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/TPTrainingTypeMapper.kt @@ -5,20 +5,26 @@ import org.freekode.tp2intervals.domain.TrainingType class TPTrainingTypeMapper { companion object { private val typeMap = mapOf( + //WORKOUT TrainingType.SWIM to 1, TrainingType.BIKE to 2, TrainingType.VIRTUAL_BIKE to 2, TrainingType.RUN to 3, - TrainingType.MTB to 8, - TrainingType.WEIGHT to 9, - TrainingType.NOTE to 7, // day off - TrainingType.UNKNOWN to 4, // brick + TrainingType.BRICK to 4, TrainingType.UNKNOWN to 5, // crosstrain + TrainingType.RACE to 6, + TrainingType.DAY_OFF to 7, + TrainingType.NOTE to 7, + TrainingType.MTB to 8, + TrainingType.STRENGTH to 9, TrainingType.UNKNOWN to 9, // custom TrainingType.UNKNOWN to 11, // xc-ski TrainingType.UNKNOWN to 12, // rowing TrainingType.UNKNOWN to 13, // walk - TrainingType.UNKNOWN to 100 // other + TrainingType.UNKNOWN to 100, // other + + //WELLNESS (METRICS) + TrainingType.WEIGHT to 9 ) fun getByValue(value: Int): TrainingType = diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/FromTPStructureConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/FromTPStructureConverter.kt index 699b95cc..46a062f6 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/FromTPStructureConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/FromTPStructureConverter.kt @@ -1,6 +1,9 @@ package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.structure -import org.freekode.tp2intervals.domain.workout.structure.* +import org.freekode.tp2intervals.domain.workout.structure.MultiStep +import org.freekode.tp2intervals.domain.workout.structure.SingleStep +import org.freekode.tp2intervals.domain.workout.structure.WorkoutStep +import org.freekode.tp2intervals.domain.workout.structure.WorkoutStructure class FromTPStructureConverter( private val structureDTO: TPWorkoutStructureDTO @@ -27,6 +30,7 @@ class FromTPStructureConverter( private fun mapSingleStep(tPStepDTO: TPStepDTO): SingleStep { return SingleStep( tPStepDTO.name, + tPStepDTO.notes, tPStepDTO.length!!.toStepLength(), tPStepDTO.toMainTarget(), tPStepDTO.toSecondaryTarget(), diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPStepDTO.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPStepDTO.kt index 7e94b5f3..3f00dfa8 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPStepDTO.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPStepDTO.kt @@ -4,9 +4,14 @@ import org.freekode.tp2intervals.domain.workout.structure.StepTarget class TPStepDTO( var name: String?, + var notes: String?, var length: TPLengthDTO?, var targets: List = listOf(), ) { + + constructor(name: String?, length: TPLengthDTO?, targets: List) : + this(name, null, length, targets) + fun toMainTarget(): StepTarget { val target = targets.first { it.unit == null } return StepTarget( diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPTargetMapper.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPTargetMapper.kt index b945912e..08f8c1fc 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPTargetMapper.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/TPTargetMapper.kt @@ -8,6 +8,7 @@ class TPTargetMapper { WorkoutStructure.TargetUnit.FTP_PERCENTAGE to "percentOfFtp", WorkoutStructure.TargetUnit.LTHR_PERCENTAGE to "percentOfThresholdHr", WorkoutStructure.TargetUnit.PACE_PERCENTAGE to "percentOfThresholdPace", + WorkoutStructure.TargetUnit.RELATIVE_PERCEIVED_EFFORT to "rpe", ) fun getByIntensity(intensity: String): WorkoutStructure.TargetUnit = diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/ToTPStructureConverter.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/ToTPStructureConverter.kt index d8abd4f1..37849aa5 100644 --- a/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/ToTPStructureConverter.kt +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/infrastructure/platform/trainingpeaks/workout/structure/ToTPStructureConverter.kt @@ -2,12 +2,7 @@ package org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout. import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.databind.ObjectMapper -import org.freekode.tp2intervals.domain.workout.structure.MultiStep -import org.freekode.tp2intervals.domain.workout.structure.SingleStep -import org.freekode.tp2intervals.domain.workout.structure.StepLength -import org.freekode.tp2intervals.domain.workout.structure.WorkoutStep -import org.freekode.tp2intervals.domain.workout.structure.StepTarget -import org.freekode.tp2intervals.domain.workout.structure.WorkoutStructure +import org.freekode.tp2intervals.domain.workout.structure.* class ToTPStructureConverter( private val objectMapper: ObjectMapper, @@ -84,6 +79,7 @@ class ToTPStructureConverter( return TPStepDTO( workoutStep.name, + workoutStep.notes, TPLengthDTO.fromStepLength(workoutStep.length), targetList, ) diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessController.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessController.kt new file mode 100644 index 00000000..3c7f377f --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessController.kt @@ -0,0 +1,18 @@ +package org.freekode.tp2intervals.rest.wellness + +import org.freekode.tp2intervals.app.wellness.CopyFromCalendarToCalendarRequest +import org.freekode.tp2intervals.app.wellness.CopyWellnessResponse +import org.freekode.tp2intervals.app.wellness.WellnessService +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class WellnessController( + private val wellnessService: WellnessService, +) { + @PostMapping("/api/wellness/copy-calendar-to-calendar") + fun copyWellnessFromCalendarToCalendar(@RequestBody request: CopyFromCalendarToCalendarRequest): CopyWellnessResponse { + return wellnessService.copyWellnessC2C(request) + } +} diff --git a/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessScheduledJobController.kt b/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessScheduledJobController.kt new file mode 100644 index 00000000..47b44393 --- /dev/null +++ b/boot/src/main/kotlin/org/freekode/tp2intervals/rest/wellness/WellnessScheduledJobController.kt @@ -0,0 +1,23 @@ +package org.freekode.tp2intervals.rest.wellness + +import org.freekode.tp2intervals.app.wellness.schedule.C2CTodayScheduledRequest +import org.freekode.tp2intervals.app.wellness.schedule.ScheduledJobService +import org.springframework.web.bind.annotation.* + +@RestController +class WellnessScheduledJobController( + private val scheduledJob: ScheduledJobService +) { + @PostMapping("/api/wellness/copy-calendar-to-calendar/schedule") + fun scheduleC2CTodayRequest(@RequestBody request: C2CTodayScheduledRequest) { + scheduledJob.addRequest(request) + } + + @GetMapping("/api/wellness/copy-calendar-to-calendar/schedule") + fun getScheduleRequests() = + scheduledJob.getRequests() + + @DeleteMapping("/api/wellness/copy-calendar-to-calendar/schedule/{id}") + fun deleteScheduleRequest(@PathVariable id: Int) = + scheduledJob.deleteRequest(id) +} diff --git a/boot/src/test/kotlin/config/mock/TrainingPeaksApiClientMock.kt b/boot/src/test/kotlin/config/mock/TrainingPeaksApiClientMock.kt index 7deba103..ecffada5 100644 --- a/boot/src/test/kotlin/config/mock/TrainingPeaksApiClientMock.kt +++ b/boot/src/test/kotlin/config/mock/TrainingPeaksApiClientMock.kt @@ -2,8 +2,8 @@ package config.mock import com.fasterxml.jackson.core.type.TypeReference import com.fasterxml.jackson.databind.ObjectMapper -import org.freekode.tp2intervals.infrastructure.platform.intervalsicu.workout.IntervalsEventDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.TrainingPeaksApiClient +import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.metrics.TPMetricsDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.CreateTPWorkoutRequestDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPNoteResponseDTO import org.freekode.tp2intervals.infrastructure.platform.trainingpeaks.workout.TPWorkoutCalendarResponseDTO @@ -17,7 +17,7 @@ class TrainingPeaksApiClientMock( ) : TrainingPeaksApiClient { private val workouts: List = objectMapper.readValue( workoutsResponse, - object : TypeReference>() {}) as List; + object : TypeReference>() {}) as List override fun getWorkouts(userId: String, startDate: String, endDate: String) = workouts @@ -44,4 +44,26 @@ class TrainingPeaksApiClientMock( override fun deleteWorkout(userId: String, workoutId: String): Boolean { TODO("Not yet implemented") } + + override fun getMetrics( + userId: String, + startDate: String, + endDate: String + ): List { + TODO("Not yet implemented") + } + + override fun createMetrics( + athleteId: String, + requestDTO: TPMetricsDTO + ) { + TODO("Not yet implemented") + } + + override fun deleteMetrics( + athleteId: String, + requestDTO: TPMetricsDTO + ) { + TODO("Not yet implemented") + } } diff --git a/docker-compose.yml b/docker-compose.yml index 3852229e..d12ff13e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,23 @@ services: - app: - image: ghcr.io/freekode/tp2intervals:latest - container_name: tp2intervals + backend: + build: + context: . + dockerfile: Dockerfile + container_name: tp2intervals-backend restart: unless-stopped volumes: - - ./tp2intervals.sqlite:/tp2intervals.sqlite + - ./tp2intervals.sqlite:/app/tp2intervals.sqlite ports: - '8080:8080' + + frontend: + image: node:20-alpine + container_name: tp2intervals-frontend + working_dir: /app + volumes: + - ./ui:/app + ports: + - '4200:4200' + command: sh -c "npm install && npm run dev -- --host 0.0.0.0 --poll 500 --proxy-config src/proxy.docker.conf.json" + depends_on: + - backend \ No newline at end of file diff --git a/electron/package-lock.json b/electron/package-lock.json index ee376643..16658b5e 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,17 +1,16 @@ { "name": "tp2intervals", - "version": "0.12.2", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tp2intervals", - "version": "0.12.2", + "version": "0.12.3", "license": "GNU GPLv3", "dependencies": { "electron-log": "^5.1.1", "electron-squirrel-startup": "^1.0.0", - "electron-updater": "^6.1.7", "node-schedule": "^2.1.1", "semver": "^7.6.0", "typed-emitter": "^2.1.0" @@ -141,6 +140,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.9.tgz", "integrity": "sha512-5q0175NOjddqpvvzU+kDiSOAk4PfdO6FvwCWoQ6RO7rTzEe8vlo+4HVfcnAREhD4npMs0e9uZypjTwzZPCf/cw==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -1748,6 +1748,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.17.tgz", "integrity": "sha512-QmgQZGWu1Yw9TDyAP9ZzpFJKynYNeOvwMJmaxABfieQoVoiVOS6MN1WSpqpRcbeA5+RW82kraAVxCCJg+780Qw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -1851,6 +1852,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1984,7 +1986,8 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/assert-plus": { "version": "1.0.0", @@ -2123,6 +2126,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001580", "electron-to-chromium": "^1.4.648", @@ -2216,6 +2220,7 @@ "version": "9.2.3", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.3.tgz", "integrity": "sha512-FGhkqXdFFZ5dNC4C+yuQB9ak311rpGAw+/ASz8ZdxwODCv1GGMWgLDeofRkdi0F3VCHQEWy/aXcJQozx2nOPiw==", + "dev": true, "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -2631,6 +2636,7 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3050,53 +3056,6 @@ "integrity": "sha512-ZOBocMYCehr9W31+GpMclR+KBaDZOoAEabLdhpZ8oU1JFDwIaFY0UDbpXVEUFc0BIP2O2Qn3rkfCjQmMR4T/bQ==", "dev": true }, - "node_modules/electron-updater": { - "version": "6.1.7", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.1.7.tgz", - "integrity": "sha512-SNOhYizjkm4ET+Y8ilJyUzcVsFJDtINzVN1TyHnZeMidZEG3YoBebMyXc/J6WSiXdUaOjC7ngekN6rNp6ardHA==", - "dependencies": { - "builder-util-runtime": "9.2.3", - "fs-extra": "^10.1.0", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "lodash.escaperegexp": "^4.1.2", - "lodash.isequal": "^4.5.0", - "semver": "^7.3.8", - "tiny-typed-emitter": "^2.1.0" - } - }, - "node_modules/electron-updater/node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/electron-updater/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-updater/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron-vite": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-2.0.0.tgz", @@ -3618,7 +3577,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -3943,6 +3903,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "dependencies": { "argparse": "^2.0.1" }, @@ -4014,7 +3975,8 @@ "node_modules/lazy-val": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==" + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true }, "node_modules/lodash": { "version": "4.17.21", @@ -4022,16 +3984,6 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==" - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" - }, "node_modules/long-timeout": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/long-timeout/-/long-timeout-0.1.1.tgz", @@ -4212,7 +4164,8 @@ "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true }, "node_modules/nanoid": { "version": "3.3.7", @@ -4615,7 +4568,8 @@ "node_modules/sax": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.3.0.tgz", - "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==" + "integrity": "sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==", + "dev": true }, "node_modules/semver": { "version": "7.6.0", @@ -4954,11 +4908,6 @@ "node": ">= 10.0.0" } }, - "node_modules/tiny-typed-emitter": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", - "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==" - }, "node_modules/tmp": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", @@ -5082,6 +5031,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5176,6 +5126,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.1.tgz", "integrity": "sha512-wclpAgY3F1tR7t9LL5CcHC41YPkQIpKUGeIuT8MdNwNZr6OqOTLs7JX5vIHAtzqLWXts0T+GDrh9pN2arneKqg==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.19.3", "postcss": "^8.4.35", diff --git a/ui/package-lock.json b/ui/package-lock.json index 60d76d92..8a0679e1 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -293,6 +293,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-17.0.8.tgz", "integrity": "sha512-iKJ2s4ZqVoGS9tSRBuuwYEWTV+Rw6b4zDY1rqiXvbZrpNRxfzYr6s+aYsLQQEindZ4hzxgp9j60FJ8aE/g4w6A==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -307,6 +308,7 @@ "version": "17.0.4", "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.0.4.tgz", "integrity": "sha512-mh/EuIR0NPfpNqAXBSZWuJeBMXUvUDYdKhiFWZet5NLO1bDgFe1MGLBjtW4us95k4BZsMLbCKNxJgc+4JqwUvg==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -393,6 +395,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz", "integrity": "sha512-fFfwtdg7H+OkqnvV/ENu8F8KGfgIiH16DDbQqYY5KQyyQB+SMsoVW29F1fGx6Y30s7ZlsLOy6cHhgrw74itkSw==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -408,6 +411,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.0.8.tgz", "integrity": "sha512-48jWypuhBGTrUUbkz1vB9gjbKKZ3hpuJ2DUUncd331Yw4tqkqZQbBa/E3ei4IHiCxEvW2uX3lI4AwlhuozmUtA==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -428,6 +432,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.0.8.tgz", "integrity": "sha512-ny2SMVgl+icjMuU5ZM57yFGUrhjR0hNxfCn0otAD3jUFliz/Onu9l6EPRKA5Cr8MZx3mg3rTLSBMD17YT8rsOg==", "dev": true, + "peer": true, "dependencies": { "@babel/core": "7.23.2", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -455,6 +460,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/core/-/core-17.0.8.tgz", "integrity": "sha512-tzYsK24LdkNuKNJK6efF4XOqspvF/qOe9j/n1Y61a6mNvFwsJFGbcmdZMby4hI/YRm6oIDoIIFjSep8ycp6Pbw==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -470,6 +476,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.0.8.tgz", "integrity": "sha512-WZBHbMQjaSovAzOMhKqZN+m7eUPGfOzh9rKFKvj6UQLIJ9qSpEpqlvL0omU1z/47s3XXeLiBzomMiRfQISJvvw==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -551,6 +558,7 @@ "version": "17.0.8", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.0.8.tgz", "integrity": "sha512-XaI+p2AxQaIHzR761lhPUf4OcOp46WDW0IfbvOzaezHE+8r81joZyVSDQPgXSa/aRfI58YhcfUavuGqyU3PphA==", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -635,6 +643,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.2.tgz", "integrity": "sha512-n7s51eWdaWZ3vGT2tD4T7J6eJs3QoBXydv7vkUM06Bf1cbVD2Kc2UrkzhiQwobfV7NwOnQXYL7UBJ5VPU+RGoQ==", "dev": true, + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.22.13", @@ -4178,6 +4187,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.10.6.tgz", "integrity": "sha512-Vac8H+NlRNNlAmDfGUP7b5h/KA+AtWIzuXy0E6OyP8f1tCLYAtPvKRRDJjAPqhpCb0t6U2j7/xqAuLEebW2kiw==", "dev": true, + "peer": true, "dependencies": { "undici-types": "~5.26.4" } @@ -4467,6 +4477,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4540,6 +4551,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -5227,6 +5239,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001565", "electron-to-chromium": "^1.4.601", @@ -8317,7 +8330,8 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.1.tgz", "integrity": "sha512-UrzO3fL7nnxlQXlvTynNAenL+21oUQRlzqQFsA2U11ryb4+NLOCOePZ70PTojEaUKhiFugh7dG0Q+I58xlPdWg==", - "dev": true + "dev": true, + "peer": true }, "node_modules/jest-worker": { "version": "27.5.1", @@ -8450,6 +8464,7 @@ "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.2.tgz", "integrity": "sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==", "dev": true, + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8772,6 +8787,7 @@ "resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz", "integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==", "dev": true, + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -10680,6 +10696,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -11391,6 +11408,7 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -11431,6 +11449,7 @@ "resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz", "integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==", "dev": true, + "peer": true, "dependencies": { "chokidar": ">=3.0.0 <4.0.0", "immutable": "^4.0.0", @@ -12363,6 +12382,7 @@ "resolved": "https://registry.npmjs.org/terser/-/terser-5.24.0.tgz", "integrity": "sha512-ZpGR4Hy3+wBEzVEnHvstMvqpD/nABNelQn/z2r0fjVWGQsN3bpOLzQlqDxmb4CDZnXq5lpjnQ+mHQLAOpfM5iw==", "dev": true, + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.8.2", @@ -12415,6 +12435,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -12609,6 +12630,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", "dev": true, + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12839,6 +12861,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.1.tgz", "integrity": "sha512-AXXFaAJ8yebyqzoNB9fu2pHoo/nWX+xZlaRwoeYUxEqBO+Zj4msE5G+BhGBll9lYEKv9Hfks52PAF2X7qDYXQA==", "dev": true, + "peer": true, "dependencies": { "esbuild": "^0.18.10", "postcss": "^8.4.27", @@ -13323,6 +13346,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", @@ -13398,6 +13422,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", "dev": true, + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -13554,6 +13579,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -13831,6 +13857,7 @@ "version": "0.14.2", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.2.tgz", "integrity": "sha512-X4U7J1isDhoOmHmFWiLhloWc2lzMkdnumtfQ1LXzf/IOZp5NQYuMUTaviVzG/q1ugMBIXzin2AqeVJUoSEkNyQ==", + "peer": true, "dependencies": { "tslib": "^2.3.0" } diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html index aa1fc393..c332e5b4 100644 --- a/ui/src/app/app.component.html +++ b/ui/src/app/app.component.html @@ -1,4 +1,5 @@ - -
- -
+ +
+ +
+
diff --git a/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.html b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.html new file mode 100644 index 00000000..be96441d --- /dev/null +++ b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.html @@ -0,0 +1,115 @@ +
+
+ + Direction + + @for (item of directions; track item) { + {{ item.title }} + } + + +
+ +
+ + Wellness (Metrics) + + @for (type of trainingTypes; track type) { + {{ type.title }} + } + + +
+ +
+ + Date Range + + + + + + + + + +
+ +
+ + + +
+ +
+ + + Scheduled jobs + + + @for (request of scheduleRequests; track request) { + + + Types: {{ mapTrainingTypesToTitles(request.request.types) }}, + Skip synced: {{ request.request.skipSynced }}, + {{ Platform.getTitle(request.request.sourcePlatform) }} -> {{ Platform.getTitle(request.request.targetPlatform) }} + + } + +
+ +
+ + +
+ + +
diff --git a/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.scss b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.scss new file mode 100644 index 00000000..1b1e613c --- /dev/null +++ b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.scss @@ -0,0 +1,5 @@ +.date-range-text { + padding-top: 10px; + padding-bottom: 10px; + padding-left: 10px; +} diff --git a/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.ts b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.ts new file mode 100644 index 00000000..b696f587 --- /dev/null +++ b/ui/src/app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component.ts @@ -0,0 +1,160 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators} from "@angular/forms"; +import {MatButtonModule} from "@angular/material/button"; +import {MatFormFieldModule} from "@angular/material/form-field"; +import {MatInputModule} from "@angular/material/input"; +import {MatProgressBarModule} from "@angular/material/progress-bar"; +import {MatDatepickerModule} from "@angular/material/datepicker"; +import {MatNativeDateModule} from "@angular/material/core"; +import {MatSnackBarModule} from "@angular/material/snack-bar"; +import {MatSelectModule} from "@angular/material/select"; +import {MatCheckboxModule} from "@angular/material/checkbox"; +import {Platform} from "infrastructure/platform"; +import {formatDate} from "utils/date-formatter"; +import {MatDividerModule} from "@angular/material/divider"; +import {MatListModule} from "@angular/material/list"; +import {NgIf} from "@angular/common"; +import {ConfigurationClient} from "infrastructure/client/configuration.client"; +import {finalize, switchMap, tap} from "rxjs"; +import {NotificationService} from "infrastructure/notification.service"; +import {MatTooltipModule} from "@angular/material/tooltip"; +import {TrainingTypes} from "infrastructure/training-types"; +import {WellnessClient} from "infrastructure/client/wellness.client"; + +@Component({ + selector: 'copy-wellness-to-calendar', + standalone: true, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + MatProgressBarModule, + MatDatepickerModule, + MatNativeDateModule, + MatSnackBarModule, + MatSelectModule, + MatCheckboxModule, + MatDividerModule, + MatListModule, + NgIf, + MatTooltipModule, + ], + templateUrl: './copy-wellness-to-calendar.component.html', + styleUrl: './copy-wellness-to-calendar.component.scss' +}) +export class CopyWellnessToCalendarComponent implements OnInit { + readonly Platform = Platform; + readonly todayDate = new Date() + readonly tomorrowDate = new Date(new Date().getTime() + 24 * 60 * 60 * 1000) + + @Input() trainingTypes: any[] = [] + @Input() selectedTrainingTypes = ['WEIGHT'] + @Input() directions: any[] = [] + @Input() inProgress = false + + formGroup: FormGroup + platformsInfo: any + scheduleRequests: any[] = [] + + constructor( + private formBuilder: FormBuilder, + private configurationClient: ConfigurationClient, + private wellnessClient: WellnessClient, + private notificationService: NotificationService + ) { + } + + ngOnInit(): void { + this.configurationClient.getAllPlatformInfo().subscribe(value => { + this.platformsInfo = value + }) + this.formGroup = this.getFormGroup(); + this.loadScheduleRequests().subscribe() + } + + submit() { + let startDate = formatDate(this.formGroup.controls['startDate'].value) + let endDate = formatDate(this.formGroup.controls['endDate'].value) + this.copyWellness(startDate, endDate); + } + + today() { + this.copyWellnessForOneDay(formatDate(this.todayDate)); + } + + tomorrow() { + this.copyWellnessForOneDay(formatDate(this.tomorrowDate)); + } + + scheduleToday() { + let startDate = null + let endDate = null + let direction = this.formGroup.value.direction + let trainingTypes = this.formGroup.value.trainingTypes + let skipSynced = this.formGroup.value.skipSynced + + this.inProgress = true + this.wellnessClient.scheduleCopyCalendarToCalendar(startDate, endDate, trainingTypes, skipSynced, direction).pipe( + switchMap(() => this.loadScheduleRequests()), + finalize(() => this.inProgress = false) + ).subscribe(() => { + this.notificationService.success(`Scheduled sync job`) + }) + } + + mapTrainingTypesToTitles(values) { + return values.map(value => TrainingTypes.getTitle(value)) + } + + private copyWellnessForOneDay(date) { + this.copyWellness(date, date) + } + + private copyWellness(startDate, endDate) { + let direction = this.formGroup.value.direction + let trainingTypes = this.formGroup.value.trainingTypes + let skipSynced = this.formGroup.value.skipSynced + + this.inProgress = true + this.wellnessClient.copyCalendarToCalendar(startDate, endDate, trainingTypes, skipSynced, direction).pipe( + finalize(() => this.inProgress = false) + ).subscribe((response) => { + this.notificationService.success( + `Wellness (Metrics): ${response.copied}\n From ${response.startDate} to ${response.endDate}`) + }) + } + + private getFormGroup() { + return this.formBuilder.group({ + direction: [this.directions[0].value, Validators.required], + trainingTypes: [this.selectedTrainingTypes, Validators.required], + startDate: [this.todayDate, Validators.required], + endDate: [this.tomorrowDate, Validators.required], + skipSynced: [true, Validators.required], + }) + } + + private loadScheduleRequests() { + return this.wellnessClient.getScheduleRequests().pipe( + tap(values => { + this.scheduleRequests = values.map(value => { + return {id: value.id, request: JSON.parse(value.requestJson)} + }) + console.log(this.scheduleRequests) + } + ) + ) + } + + deleteJob(jobId: any) { + this.inProgress = true + this.wellnessClient.deleteScheduleRequest(jobId).pipe( + switchMap(() => this.loadScheduleRequests()), + finalize(() => this.inProgress = false) + ).subscribe(() => { + this.notificationService.success(`Deleted job`) + }) + } +} diff --git a/ui/src/app/configuration/configuration.component.ts b/ui/src/app/configuration/configuration.component.ts index cb62b709..3bb72ac0 100644 --- a/ui/src/app/configuration/configuration.component.ts +++ b/ui/src/app/configuration/configuration.component.ts @@ -17,6 +17,8 @@ import {MatSelectModule} from "@angular/material/select"; import {MatExpansionModule} from "@angular/material/expansion"; import {MatTooltipModule} from "@angular/material/tooltip"; +import {environment} from 'environments/environment'; + @Component({ selector: 'app-configuration', standalone: true, @@ -38,8 +40,10 @@ import {MatTooltipModule} from "@angular/material/tooltip"; styleUrl: './configuration.component.scss' }) export class ConfigurationComponent implements OnInit { + public config = environment; + formGroup: FormGroup = this.formBuilder.group({ - 'intervals.api-key': [null, Validators.required], + 'intervals.api-key': [this.config.intervals_api_key, Validators.required], 'intervals.athlete-id': [null, Validators.required], 'intervals.power-range': [null, [Validators.required, Validators.min(0), Validators.max(100)]], 'intervals.hr-range': [null, [Validators.required, Validators.min(0), Validators.max(100)]], @@ -64,6 +68,23 @@ export class ConfigurationComponent implements OnInit { this.inProgress = true this.configClient.getConfig().subscribe(config => { this.formGroup.patchValue(config.config); + + if (!this.formGroup.get('intervals.api-key')?.value) { + this.formGroup.patchValue({ + 'intervals.api-key': this.config.intervals_api_key + }); + } + if (!this.formGroup.get('intervals.athlete-id')?.value) { + this.formGroup.patchValue({ + 'intervals.athlete-id': this.config.intervals_athlete_id + }); + } + if (!this.formGroup.get('training-peaks.auth-cookie')?.value) { + this.formGroup.patchValue({ + 'training-peaks.auth-cookie': this.config.trainingpeaks_auth_cookie + }); + } + this.inProgress = false this.listenTrainingPeaksCookie() this.listenTrainerRoadCookie() diff --git a/ui/src/app/top-bar/top-bar.component.html b/ui/src/app/top-bar/top-bar.component.html index 608c1da0..5a5c1d97 100644 --- a/ui/src/app/top-bar/top-bar.component.html +++ b/ui/src/app/top-bar/top-bar.component.html @@ -1,43 +1,57 @@ - - @for (button of menuButtons; track button) { - @if (router.url === button.url) { - - } @else { - - } - } - - - v{{ appVersion }} - - - - kofi - - - - - + + TP2Intervals + + + + + v{{ appVersion }} + + + + kofi + + + + + + + +
+ +
+ + diff --git a/ui/src/app/top-bar/top-bar.component.scss b/ui/src/app/top-bar/top-bar.component.scss index 9ce1142d..2577d0ab 100644 --- a/ui/src/app/top-bar/top-bar.component.scss +++ b/ui/src/app/top-bar/top-bar.component.scss @@ -7,7 +7,7 @@ } .github-link { - font-size: 20px; + font-size: 18px; span { letter-spacing: 0; @@ -16,6 +16,38 @@ } .kofi-icon { - height: 48px; + height: 40px; padding-top: 4px; } + +.sidenav-container { + height: 100vh; +} + +.sidenav { + width: 250px; +} + +.menu-header { + padding: 20px; + font-size: 1.2rem; + color: #3f51b5; // Cor primária +} + +.spacer { + flex: 1 1 auto; +} + +.active { + background-color: rgba(0, 0, 0, 0.04); + color: #3f51b5 !important; + font-weight: bold; +} + +.content { + padding: 0; +} + +.bi-list { + font-size: 1.5rem; +} diff --git a/ui/src/app/top-bar/top-bar.component.ts b/ui/src/app/top-bar/top-bar.component.ts index b8fd8a27..d1cd2c0b 100644 --- a/ui/src/app/top-bar/top-bar.component.ts +++ b/ui/src/app/top-bar/top-bar.component.ts @@ -9,6 +9,10 @@ import {GitHubClient} from "infrastructure/client/github.client"; import * as semver from "semver"; import {MatTooltipModule} from "@angular/material/tooltip"; +import {MatSidenavModule} from '@angular/material/sidenav'; +import {MatListModule} from '@angular/material/list'; +import {MatIconModule} from '@angular/material/icon'; + @Component({ selector: 'app-top-bar', standalone: true, @@ -17,7 +21,10 @@ import {MatTooltipModule} from "@angular/material/tooltip"; MatToolbarModule, RouterLink, MatBadgeModule, - MatTooltipModule + MatTooltipModule, + MatSidenavModule, + MatListModule, + MatIconModule ], templateUrl: './top-bar.component.html', styleUrl: './top-bar.component.scss' diff --git a/ui/src/app/training-peaks/tp-copy-calendar-to-calendar/tp-copy-calendar-to-calendar.component.ts b/ui/src/app/training-peaks/tp-copy-calendar-to-calendar/tp-copy-calendar-to-calendar.component.ts index c09afafe..3ad9476a 100644 --- a/ui/src/app/training-peaks/tp-copy-calendar-to-calendar/tp-copy-calendar-to-calendar.component.ts +++ b/ui/src/app/training-peaks/tp-copy-calendar-to-calendar/tp-copy-calendar-to-calendar.component.ts @@ -39,8 +39,8 @@ import { export class TpCopyCalendarToCalendarComponent implements OnInit { readonly Platform = Platform; readonly directions = [ - {title: "Intervals.icu -> TrainingPeaks", value: Platform.DIRECTION_INT_TP}, {title: "TrainingPeaks -> Intervals.icu", value: Platform.DIRECTION_TP_INT}, + {title: "Intervals.icu -> TrainingPeaks", value: Platform.DIRECTION_INT_TP}, ] readonly trainingTypes = [ {title: "Ride", value: "BIKE"}, @@ -49,10 +49,12 @@ export class TpCopyCalendarToCalendarComponent implements OnInit { {title: "Run", value: "RUN"}, {title: "Swim", value: "SWIM"}, {title: "Walk", value: "WALK"}, - {title: "Weight Training", value: "WEIGHT"}, + {title: "Strength Training", value: "STRENGTH"}, {title: "Any other", value: "UNKNOWN"}, + {title: "Day-off (Notes)", value: "DAY_OFF"}, + {title: "Brick", value: "BRICK"}, ] - readonly selectedTrainingTypes = ['BIKE', 'VIRTUAL_BIKE', 'MTB', 'RUN']; + readonly selectedTrainingTypes = ['BIKE', 'VIRTUAL_BIKE', 'MTB', 'RUN', 'DAY_OFF', 'BRICK']; constructor() { } diff --git a/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.html b/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.html new file mode 100644 index 00000000..f4291f56 --- /dev/null +++ b/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.html @@ -0,0 +1,5 @@ + + diff --git a/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.scss b/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.scss new file mode 100644 index 00000000..e69de29b diff --git a/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.ts b/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.ts new file mode 100644 index 00000000..fa373c5d --- /dev/null +++ b/ui/src/app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component.ts @@ -0,0 +1,55 @@ +import {Component, OnInit} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from "@angular/forms"; +import {MatButtonModule} from "@angular/material/button"; +import {MatFormFieldModule} from "@angular/material/form-field"; +import {MatInputModule} from "@angular/material/input"; +import {MatProgressBarModule} from "@angular/material/progress-bar"; +import {MatDatepickerModule} from "@angular/material/datepicker"; +import {MatNativeDateModule} from "@angular/material/core"; +import {MatSnackBarModule} from "@angular/material/snack-bar"; +import {MatSelectModule} from "@angular/material/select"; +import {MatCheckboxModule} from "@angular/material/checkbox"; +import {Platform} from "infrastructure/platform"; +import {MatListModule} from "@angular/material/list"; +import { + CopyWellnessToCalendarComponent +} from "app/components/copy-wellness-to-calendar/copy-wellness-to-calendar.component"; + +@Component({ + selector: 'tp-copy-wellness-to-calendar', + standalone: true, + imports: [ + FormsModule, + MatButtonModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + MatProgressBarModule, + MatDatepickerModule, + MatNativeDateModule, + MatSnackBarModule, + MatSelectModule, + MatCheckboxModule, + MatListModule, + CopyWellnessToCalendarComponent, + ], + templateUrl: './tp-copy-wellness-to-calendar.component.html', + styleUrl: './tp-copy-wellness-to-calendar.component.scss' +}) +export class TpCopyWellnessToCalendarComponent implements OnInit { + readonly Platform = Platform; + readonly directions = [ + {title: "Intervals.icu -> TrainingPeaks", value: Platform.DIRECTION_INT_TP}, + {title: "TrainingPeaks -> Intervals.icu", value: Platform.DIRECTION_TP_INT}, + ] + readonly trainingTypes = [ + {title: "Weight", value: "WEIGHT"}, + ] + readonly selectedTrainingTypes = ['WEIGHT']; + + constructor() { + } + + ngOnInit(): void { + } +} diff --git a/ui/src/app/training-peaks/training-peaks.component.html b/ui/src/app/training-peaks/training-peaks.component.html index acfc76e7..5ec9af18 100644 --- a/ui/src/app/training-peaks/training-peaks.component.html +++ b/ui/src/app/training-peaks/training-peaks.component.html @@ -7,54 +7,65 @@
- - - - - Sync planned workouts - - -
- Copy workouts between Intervals and TrainingPeaks calendars -
-
-
- - -
- - - - - Copy plan or workout library - - - Copy whole training plan or workout library from TrainingPeaks to Intervals - - - - - - - - - - Copy planned workouts to library - - -
- Copy workouts from TrainingPeaks calendar to Intervals workout library -
-
-
- - -
-
+ +
+

Workouts & Plans

+ + + + + Sync planned workouts + +
+ Copy workouts between Intervals and TrainingPeaks calendars +
+
+
+ +
+ + + + Copy plan or workout library + + Copy whole training plan or workout library from TP to Intervals + + + + + + + + Copy planned workouts to library + +
+ Copy workouts from TP calendar to Intervals library +
+
+
+ +
+ +
+
+ + + +
+

Health & Wellness

+ + + + Sync WellNess Data (Metrics) + +
+ Sync metrics between Intervals and TrainingPeaks +
+
+
+ +
+
+
+
diff --git a/ui/src/app/training-peaks/training-peaks.component.scss b/ui/src/app/training-peaks/training-peaks.component.scss index e69de29b..16c241a6 100644 --- a/ui/src/app/training-peaks/training-peaks.component.scss +++ b/ui/src/app/training-peaks/training-peaks.component.scss @@ -0,0 +1,13 @@ +.group-title { + font-family: Roboto, "Helvetica Neue", sans-serif; + font-size: 14px; + font-weight: 500; + color: #666; + margin: 16px 0 8px 4px; + text-transform: uppercase; + letter-spacing: 1px; +} + +.sync-section { + margin-bottom: 24px; +} diff --git a/ui/src/app/training-peaks/training-peaks.component.ts b/ui/src/app/training-peaks/training-peaks.component.ts index 96407143..6c6245ca 100644 --- a/ui/src/app/training-peaks/training-peaks.component.ts +++ b/ui/src/app/training-peaks/training-peaks.component.ts @@ -8,12 +8,16 @@ import { import { TpCopyCalendarToLibraryComponent } from "app/training-peaks/tp-copy-calendar-to-library/tp-copy-calendar-to-library.component"; +import { + TpCopyWellnessToCalendarComponent +} from "app/training-peaks/tp-copy-wellness-to-calendar/tp-copy-wellness-to-calendar.component"; import {MatExpansionModule} from "@angular/material/expansion"; import {NgIf} from "@angular/common"; import {ConfigurationClient} from "infrastructure/client/configuration.client"; import {Platform} from "infrastructure/platform"; import {MatProgressBarModule} from "@angular/material/progress-bar"; import {MatTooltipModule} from "@angular/material/tooltip"; +import {MatListModule} from "@angular/material/list"; @Component({ selector: 'app-training-peaks', @@ -22,10 +26,12 @@ import {MatTooltipModule} from "@angular/material/tooltip"; TpCopyCalendarToCalendarComponent, TpCopyLibraryContainerComponent, TpCopyCalendarToLibraryComponent, + TpCopyWellnessToCalendarComponent, MatExpansionModule, NgIf, MatProgressBarModule, MatTooltipModule, + MatListModule, ], templateUrl: './training-peaks.component.html', styleUrl: './training-peaks.component.scss' diff --git a/ui/src/environments/environment.ts b/ui/src/environments/environment.ts new file mode 100644 index 00000000..354ceef7 --- /dev/null +++ b/ui/src/environments/environment.ts @@ -0,0 +1,5 @@ +export const environment = { + intervals_api_key: '', + intervals_athlete_id: '', + trainingpeaks_auth_cookie: '' +}; diff --git a/ui/src/infrastructure/client/wellness.client.ts b/ui/src/infrastructure/client/wellness.client.ts new file mode 100644 index 00000000..81ffd2b9 --- /dev/null +++ b/ui/src/infrastructure/client/wellness.client.ts @@ -0,0 +1,37 @@ +import {Injectable} from '@angular/core'; +import {HttpClient} from "@angular/common/http"; +import {Observable} from 'rxjs'; + + +@Injectable({ + providedIn: 'root' +}) +export class WellnessClient { + + constructor(private httpClient: HttpClient) { + } + + copyCalendarToCalendar(startDate, endDate, types, skipSynced, platformDirection): Observable { + return this.httpClient + .post(`/api/wellness/copy-calendar-to-calendar`, {startDate, endDate, types, skipSynced, ...platformDirection}) + } + + scheduleCopyCalendarToCalendar(startDate, endDate, types, skipSynced, platformDirection): Observable { + return this.httpClient + .post(`/api/wellness/copy-calendar-to-calendar/schedule`, { + startDate, + endDate, + types, + skipSynced, + ...platformDirection + }) + } + + getScheduleRequests(): Observable { + return this.httpClient.get(`/api/wellness/copy-calendar-to-calendar/schedule`) + } + + deleteScheduleRequest(id: any) { + return this.httpClient.delete(`/api/wellness/copy-calendar-to-calendar/schedule/${id}`) + } +} diff --git a/ui/src/proxy.docker.conf.json b/ui/src/proxy.docker.conf.json new file mode 100644 index 00000000..d6f0d0b5 --- /dev/null +++ b/ui/src/proxy.docker.conf.json @@ -0,0 +1,12 @@ +{ + "/api": { + "target": "http://backend:8080", + "secure": false, + "changeOrigin": true + }, + "/actuator": { + "target": "http://backend:8080", + "secure": false, + "changeOrigin": true + } +}