diff --git a/src/main/java/io/redlink/more/data/model/DataType.java b/src/main/java/io/redlink/more/data/model/DataType.java index edb34fa..1e690b0 100644 --- a/src/main/java/io/redlink/more/data/model/DataType.java +++ b/src/main/java/io/redlink/more/data/model/DataType.java @@ -5,7 +5,9 @@ public enum DataType { ACTIVITY_START("activity_start"), ACTIVITY_END("activity_end"), SLEEP_START("sleep_start"), - SLEEP_END("sleep_end"); + SLEEP_END("sleep_end"), + DAILY_STEPS("daily_steps"), + EPOCH_STEPS("epoch_steps"); public final String dataType; diff --git a/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminStepData.java b/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminStepData.java new file mode 100644 index 0000000..e7bf85e --- /dev/null +++ b/src/main/java/io/redlink/more/data/model/garmin/transformation/GarminStepData.java @@ -0,0 +1,16 @@ +package io.redlink.more.data.model.garmin.transformation; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * @param steps Number of steps per Daily or Epoch Summary + * @param stepsGoal Goal of steps count. Only present in Daily Summaries + * @param distanceInMeters Distance travelled in meters per Daily or Epoch Summary + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record GarminStepData( + Integer steps, + Integer stepsGoal, + Double distanceInMeters +) { +} diff --git a/src/main/java/io/redlink/more/data/transformers/garmin/steps/DailyStepDataTransformer.java b/src/main/java/io/redlink/more/data/transformers/garmin/steps/DailyStepDataTransformer.java new file mode 100644 index 0000000..050d90a --- /dev/null +++ b/src/main/java/io/redlink/more/data/transformers/garmin/steps/DailyStepDataTransformer.java @@ -0,0 +1,33 @@ +package io.redlink.more.data.transformers.garmin.steps; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.transformers.garmin.AbstractGarminTransformer; +import io.redlink.more.data.util.garmin.GarminStepDataUtils; +import org.apache.commons.lang3.Range; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +public class DailyStepDataTransformer extends AbstractGarminTransformer { + @Override + public GarminSummaryType getSupportedType() { + return GarminSummaryType.DAILIES; + } + + @Override + protected List transformToDataPoint(List observations, GarminDataPoint garminDataPoint) { + var data = GarminStepDataUtils.getStepData(super.endDateTime(garminDataPoint), garminDataPoint); + return super.transformGarminTimeDataToDataPoint(observations, garminDataPoint.getSummaryId(), DataType.DAILY_STEPS, data); + } + + @Override + protected List filterDataPointByTimeRange(List> validTimeRanges, List dataBulk) { + return dataBulk; + } +} diff --git a/src/main/java/io/redlink/more/data/transformers/garmin/steps/EpochStepDataTransformer.java b/src/main/java/io/redlink/more/data/transformers/garmin/steps/EpochStepDataTransformer.java new file mode 100644 index 0000000..e43a2fc --- /dev/null +++ b/src/main/java/io/redlink/more/data/transformers/garmin/steps/EpochStepDataTransformer.java @@ -0,0 +1,33 @@ +package io.redlink.more.data.transformers.garmin.steps; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.transformers.garmin.AbstractGarminTransformer; +import io.redlink.more.data.util.garmin.GarminStepDataUtils; +import org.apache.commons.lang3.Range; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +public class EpochStepDataTransformer extends AbstractGarminTransformer { + @Override + public GarminSummaryType getSupportedType() { + return GarminSummaryType.EPOCHS; + } + + @Override + protected List transformToDataPoint(List observations, GarminDataPoint garminDataPoint) { + var data = GarminStepDataUtils.getStepData(super.endDateTime(garminDataPoint), garminDataPoint); + return super.transformGarminTimeDataToDataPoint(observations, garminDataPoint.getSummaryId(), DataType.EPOCH_STEPS, data); + } + + @Override + protected List filterDataPointByTimeRange(List> validTimeRanges, List dataBulk) { + return dataBulk; + } +} diff --git a/src/main/java/io/redlink/more/data/util/garmin/GarminStepDataUtils.java b/src/main/java/io/redlink/more/data/util/garmin/GarminStepDataUtils.java new file mode 100644 index 0000000..60a95aa --- /dev/null +++ b/src/main/java/io/redlink/more/data/util/garmin/GarminStepDataUtils.java @@ -0,0 +1,15 @@ +package io.redlink.more.data.util.garmin; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.garmin.transformation.GarminStepData; +import io.redlink.more.data.model.garmin.transformation.GarminTimeData; +import io.redlink.more.data.util.MapperUtils; + +import java.time.OffsetDateTime; + +public class GarminStepDataUtils { + public static GarminTimeData getStepData(OffsetDateTime endDateTime, GarminDataPoint garminDataPoint) { + GarminStepData stepData = MapperUtils.convertValue(garminDataPoint, GarminStepData.class); + return new GarminTimeData<>(endDateTime.toInstant(), stepData); + } +} diff --git a/src/main/resources/openapi/CustomModelAPI.yaml b/src/main/resources/openapi/CustomModelAPI.yaml index 4b7f5f7..9b4967f 100644 --- a/src/main/resources/openapi/CustomModelAPI.yaml +++ b/src/main/resources/openapi/CustomModelAPI.yaml @@ -113,6 +113,16 @@ components: otherSleepData: type: object additionalProperties: true + steps: + type: integer + description: Number of steps + distanceInMeters: + type: number + format: double + description: Distance traveled in meters + stepsGoal: + type: integer + description: Daily steps goal required: - userId - summaryId diff --git a/src/test/java/io/redlink/more/data/service/DailyStepDataTransformerTest.java b/src/test/java/io/redlink/more/data/service/DailyStepDataTransformerTest.java new file mode 100644 index 0000000..a35d0bf --- /dev/null +++ b/src/test/java/io/redlink/more/data/service/DailyStepDataTransformerTest.java @@ -0,0 +1,104 @@ +package io.redlink.more.data.service; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.transformers.garmin.steps.DailyStepDataTransformer; +import org.apache.commons.lang3.Range; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.*; + +class DailyStepDataTransformerTest { + + private static class TestDailyStepDataTransformer extends DailyStepDataTransformer { + List exposeFilterByTimeRange(List> validTimeRanges, List dataBulk) { + return super.filterDataPointByTimeRange(validTimeRanges, dataBulk); + } + } + + private final DailyStepDataTransformer transformer = new DailyStepDataTransformer(); + private final TestDailyStepDataTransformer testTransformer = new TestDailyStepDataTransformer(); + + @Test + @DisplayName("getSupportedType returns DAILIES") + void getSupportedType_returnsDailies() { + assertThat(transformer.getSupportedType()).isEqualTo(GarminSummaryType.DAILIES); + } + + @Test + @DisplayName("transform: returns one DAILY_STEPS DataPoint with end timestamp and step fields") + void transform_returnsDailyStepsDataPoint() { + // Given a Garmin datapoint with steps and a time window + GarminDataPoint garminDataPoint = mock(GarminDataPoint.class); + when(garminDataPoint.getSummaryId()).thenReturn("summary-steps-1"); + when(garminDataPoint.getStartTimeInSeconds()).thenReturn(1_700_000_000); // arbitrary + when(garminDataPoint.getDurationInSeconds()).thenReturn(3_600); // 1h + when(garminDataPoint.getStartTimeOffsetInSeconds()).thenReturn(0); + + // Step-related fields used by MapperUtils.convertValue -> GarminStepData + when(garminDataPoint.getSteps()).thenReturn(12345); + when(garminDataPoint.getStepsGoal()).thenReturn(10000); + when(garminDataPoint.getDistanceInMeters()).thenReturn(7654.32); + + Instant start = Instant.ofEpochSecond(1_700_000_000); + Instant end = start.plusSeconds(3_600); + + Event schedule = new Event() + .setDateStart(start.minusSeconds(600)) + .setDateEnd(end.plusSeconds(600)); + + Observation observation = new Observation( + 1, + null, + "Daily Steps Observation", + DataType.DAILY_STEPS.dataType, + null, + null, + schedule, + start, + start, + false, + false + ); + + // When + List result = transformer.transform(List.of(observation), garminDataPoint, start.minusSeconds(3600), end.plusSeconds(3600)); + + // Then + assertThat(result).hasSize(1); + DataPoint dp = result.get(0); + assertThat(dp.dataType()).isEqualTo(DataType.DAILY_STEPS.name()); + assertThat(dp.observationId()).isEqualTo("1"); + // Effective time is the end of the window (endDateTime) + assertThat(dp.effectiveDateTime()).isEqualTo(end); + + Map data = dp.data(); + assertThat(data).isNotNull(); + assertThat(data.get("steps")).isEqualTo(12345); + // verify renamed key is present and old key is absent + assertThat(data.get("stepsGoal")).isEqualTo(10000); + assertThat(data).doesNotContainKey("stepGoal"); + assertThat(data.get("distanceInMeters")).isEqualTo(7654.32); + } + + @Test + @DisplayName("filterDataPointByTimeRange: returns input unchanged (no filtering)") + void filterDataPointByTimeRange_noFiltering() { + DataPoint a = new DataPoint("a", "1", "type", DataType.DAILY_STEPS.name(), Instant.now(), Instant.now(), Map.of()); + DataPoint b = new DataPoint("b", "1", "type", DataType.DAILY_STEPS.name(), Instant.now(), Instant.now(), Map.of()); + List input = List.of(a, b); + + List out = testTransformer.exposeFilterByTimeRange(List.of(Range.of(Instant.EPOCH, Instant.now())), input); + assertThat(out).isEqualTo(input); + } +} diff --git a/src/test/java/io/redlink/more/data/service/EpochStepDataTransformerTest.java b/src/test/java/io/redlink/more/data/service/EpochStepDataTransformerTest.java new file mode 100644 index 0000000..eade183 --- /dev/null +++ b/src/test/java/io/redlink/more/data/service/EpochStepDataTransformerTest.java @@ -0,0 +1,97 @@ +package io.redlink.more.data.service; + +import io.redlink.more.data.custom.model.GarminDataPoint; +import io.redlink.more.data.model.DataPoint; +import io.redlink.more.data.model.DataType; +import io.redlink.more.data.model.Observation; +import io.redlink.more.data.model.garmin.GarminSummaryType; +import io.redlink.more.data.model.scheduler.Event; +import io.redlink.more.data.transformers.garmin.steps.EpochStepDataTransformer; +import org.apache.commons.lang3.Range; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class EpochStepDataTransformerTest { + + private static class TestEpochStepDataTransformer extends EpochStepDataTransformer { + List exposeFilterByTimeRange(List> validTimeRanges, List dataBulk) { + return super.filterDataPointByTimeRange(validTimeRanges, dataBulk); + } + } + + private final EpochStepDataTransformer transformer = new EpochStepDataTransformer(); + private final TestEpochStepDataTransformer testTransformer = new TestEpochStepDataTransformer(); + + @Test + @DisplayName("getSupportedType returns EPOCHS") + void getSupportedType_returnsEpochs() { + assertThat(transformer.getSupportedType()).isEqualTo(GarminSummaryType.EPOCHS); + } + + @Test + @DisplayName("transform: returns one EPOCH_STEPS DataPoint with end timestamp and step fields") + void transform_returnsEpochStepsDataPoint() { + GarminDataPoint garminDataPoint = mock(GarminDataPoint.class); + when(garminDataPoint.getSummaryId()).thenReturn("summary-steps-epoch-1"); + when(garminDataPoint.getStartTimeInSeconds()).thenReturn(1_700_100_000); + when(garminDataPoint.getDurationInSeconds()).thenReturn(300); // 5 min epoch + when(garminDataPoint.getStartTimeOffsetInSeconds()).thenReturn(0); + + when(garminDataPoint.getSteps()).thenReturn(120); + when(garminDataPoint.getDistanceInMeters()).thenReturn(150.5); + + Instant start = Instant.ofEpochSecond(1_700_100_000); + Instant end = start.plusSeconds(300); + + Event schedule = new Event() + .setDateStart(start.minusSeconds(60)) + .setDateEnd(end.plusSeconds(60)); + + Observation observation = new Observation( + 1, + null, + "Epoch Steps Observation", + DataType.EPOCH_STEPS.dataType, + null, + null, + schedule, + start, + start, + false, + false + ); + + List result = transformer.transform(List.of(observation), garminDataPoint, start.minusSeconds(600), end.plusSeconds(600)); + + assertThat(result).hasSize(1); + DataPoint dp = result.get(0); + assertThat(dp.dataType()).isEqualTo(DataType.EPOCH_STEPS.name()); + assertThat(dp.observationId()).isEqualTo("1"); + assertThat(dp.effectiveDateTime()).isEqualTo(end); + + Map data = dp.data(); + assertThat(data).isNotNull(); + assertThat(data.get("steps")).isEqualTo(120); + assertThat(data.get("stepsGoal")).isEqualTo(0); + assertThat(data.get("distanceInMeters")).isEqualTo(150.5); + } + + @Test + @DisplayName("filterDataPointByTimeRange: returns input unchanged (no filtering)") + void filterDataPointByTimeRange_noFiltering() { + DataPoint a = new DataPoint("a", "1", "type", DataType.EPOCH_STEPS.name(), Instant.now(), Instant.now(), Map.of()); + DataPoint b = new DataPoint("b", "1", "type", DataType.EPOCH_STEPS.name(), Instant.now(), Instant.now(), Map.of()); + List input = List.of(a, b); + + List out = testTransformer.exposeFilterByTimeRange(List.of(Range.of(Instant.EPOCH, Instant.now())), input); + assertThat(out).isEqualTo(input); + } +}