Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/java/io/redlink/more/data/model/DataType.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

@westei westei Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gut dass die Distanz eine Double ist, sonst könnte man sie nicht bis auf Micrometer Genauigkeit spezifizieren ;)

) {
}
Original file line number Diff line number Diff line change
@@ -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<DataPoint> transformToDataPoint(List<Observation> observations, GarminDataPoint garminDataPoint) {
var data = GarminStepDataUtils.getStepData(super.endDateTime(garminDataPoint), garminDataPoint);
return super.transformGarminTimeDataToDataPoint(observations, garminDataPoint.getSummaryId(), DataType.DAILY_STEPS, data);
}

@Override
protected List<DataPoint> filterDataPointByTimeRange(List<Range<Instant>> validTimeRanges, List<DataPoint> dataBulk) {
return dataBulk;
}
}
Original file line number Diff line number Diff line change
@@ -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<DataPoint> transformToDataPoint(List<Observation> observations, GarminDataPoint garminDataPoint) {
var data = GarminStepDataUtils.getStepData(super.endDateTime(garminDataPoint), garminDataPoint);
return super.transformGarminTimeDataToDataPoint(observations, garminDataPoint.getSummaryId(), DataType.EPOCH_STEPS, data);
}

@Override
protected List<DataPoint> filterDataPointByTimeRange(List<Range<Instant>> validTimeRanges, List<DataPoint> dataBulk) {
return dataBulk;
}
}
Original file line number Diff line number Diff line change
@@ -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<GarminStepData> getStepData(OffsetDateTime endDateTime, GarminDataPoint garminDataPoint) {
GarminStepData stepData = MapperUtils.convertValue(garminDataPoint, GarminStepData.class);
return new GarminTimeData<>(endDateTime.toInstant(), stepData);
}
}
10 changes: 10 additions & 0 deletions src/main/resources/openapi/CustomModelAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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<DataPoint> exposeFilterByTimeRange(List<Range<Instant>> validTimeRanges, List<DataPoint> 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<DataPoint> 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<String, Object> 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<DataPoint> input = List.of(a, b);

List<DataPoint> out = testTransformer.exposeFilterByTimeRange(List.of(Range.of(Instant.EPOCH, Instant.now())), input);
assertThat(out).isEqualTo(input);
}
}
Original file line number Diff line number Diff line change
@@ -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<DataPoint> exposeFilterByTimeRange(List<Range<Instant>> validTimeRanges, List<DataPoint> 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<DataPoint> 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<String, Object> 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<DataPoint> input = List.of(a, b);

List<DataPoint> out = testTransformer.exposeFilterByTimeRange(List.of(Range.of(Instant.EPOCH, Instant.now())), input);
assertThat(out).isEqualTo(input);
}
}
Loading