Skip to content

Commit

Permalink
Merge pull request #290 from MORE-Platform/263_study_timeline_interve…
Browse files Browse the repository at this point in the history
…ntions

Study-Timeline: Interventions
  • Loading branch information
ja-fra authored May 28, 2024
2 parents f29335b + bcd0e9b commit 4987942
Show file tree
Hide file tree
Showing 9 changed files with 141 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
package io.redlink.more.studymanager.model.timeline;

import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.Trigger;

import java.time.Instant;

public record InterventionTimelineEvent(
Integer interventionId,
Integer studyGroupId,
String title,
String purpose,
String type,
Instant start,
Instant end,
String scheduleType
) {}
) {
public static InterventionTimelineEvent fromInterventionAndTrigger(Intervention intervention, Trigger trigger, Instant start) {
return new InterventionTimelineEvent(
intervention.getInterventionId(),
intervention.getStudyGroupId(),
intervention.getTitle(),
intervention.getPurpose(),
start,
trigger.getType()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,7 @@ public static InterventionTimelineEventDTO toInterventionTimelineEventDTO(Interv
.studyGroupId(interventionTimelineEvent.studyGroupId())
.title(interventionTimelineEvent.title())
.purpose(interventionTimelineEvent.purpose())
.type(interventionTimelineEvent.type())
.start(Transformers.toOffsetDateTime(interventionTimelineEvent.start()))
.end(Transformers.toOffsetDateTime(interventionTimelineEvent.end()))
.scheduleType(interventionTimelineEvent.scheduleType());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public class InterventionRepository {
private static final String IMPORT_INTERVENTION = "INSERT INTO interventions(study_id,intervention_id,title,purpose,study_group_id,schedule) VALUES (:study_id,:intervention_id,:title,:purpose,:study_group_id,:schedule::jsonb) RETURNING *";
private static final String GET_INTERVENTION_BY_IDS = "SELECT * FROM interventions WHERE study_id = ? AND intervention_id = ?";
private static final String LIST_INTERVENTIONS = "SELECT * FROM interventions WHERE study_id = ?";
private static final String LIST_INTERVENTIONS_FOR_GROUP = "SELECT * FROM interventions WHERE study_id = :study_id AND (study_group_id IS NULL OR study_group_id = :study_group_id)";
private static final String DELETE_INTERVENTION_BY_IDS = "DELETE FROM interventions WHERE study_id = ? AND intervention_id = ?";
private static final String DELETE_ALL = "DELETE FROM interventions";
private static final String UPDATE_INTERVENTION = "UPDATE interventions SET title=:title, study_group_id=:study_group_id, purpose=:purpose, schedule=:schedule::jsonb WHERE study_id=:study_id AND intervention_id=:intervention_id";
Expand Down Expand Up @@ -84,6 +85,14 @@ public List<Intervention> listInterventions(Long studyId) {
return template.query(LIST_INTERVENTIONS, getInterventionRowMapper(), studyId);
}

public List<Intervention> listInterventionsForGroup(Long studyId, Integer groupId) {
return namedTemplate.query(LIST_INTERVENTIONS_FOR_GROUP,
new MapSqlParameterSource("study_id", studyId)
.addValue("study_group_id", groupId),
getInterventionRowMapper()
);
}

public Intervention getByIds(Long studyId, Integer interventionId) {
return template.queryForObject(GET_INTERVENTION_BY_IDS, getInterventionRowMapper(), studyId, interventionId);
}
Expand Down Expand Up @@ -228,5 +237,4 @@ private static RowMapper<Intervention> getInterventionRowMapper() {
.setCreated(RepositoryUtils.readInstant(rs,"created"))
.setModified(RepositoryUtils.readInstant(rs,"modified"));
}

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.redlink.more.studymanager.service;

import io.redlink.more.studymanager.exception.NotFoundException;
import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.Observation;
import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.model.Trigger;
import io.redlink.more.studymanager.model.scheduler.Duration;
import io.redlink.more.studymanager.model.timeline.InterventionTimelineEvent;
import io.redlink.more.studymanager.model.timeline.ObservationTimelineEvent;
import io.redlink.more.studymanager.model.timeline.StudyTimeline;
import io.redlink.more.studymanager.utils.SchedulerUtils;
Expand All @@ -16,6 +19,7 @@
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import org.apache.commons.lang3.Range;
import org.springframework.stereotype.Service;

Expand All @@ -27,21 +31,19 @@ public class CalendarService {
private final ObservationService observationService;
private final InterventionService interventionService;
private final ParticipantService participantService;
private final StudyGroupService studyGroupService;

public CalendarService(StudyService studyService, ObservationService observationService, InterventionService interventionService,
ParticipantService participantService, StudyGroupService studyGroupService) {
ParticipantService participantService) {
this.studyService = studyService;
this.observationService = observationService;
this.interventionService = interventionService;
this.participantService = participantService;
this.studyGroupService = studyGroupService;
}

public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer studyGroupId, OffsetDateTime referenceDate, LocalDate from, LocalDate to) {
final Study study = studyService.getStudy(studyId, null)
.orElseThrow(() -> NotFoundException.Study(studyId));
final Range<LocalDate> studyRange = Range.between(
final Range<LocalDate> studyRange = Range.of(
Objects.requireNonNullElse(study.getStartDate(), study.getPlannedStartDate()),
Objects.requireNonNullElse(study.getEndDate(), study.getPlannedEndDate()),
LocalDate::compareTo
Expand Down Expand Up @@ -87,6 +89,7 @@ public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer st
}

final List<Observation> observations = observationService.listObservationsForGroup(studyId, effectiveGroup);
final List<Intervention> interventions = interventionService.listInterventionsForGroup(studyId, effectiveGroup);

// Shift the effective study-start if the participant would miss a relative observation
final LocalDate firstDayInStudy = SchedulerUtils.alignStartDateToSignupInstant(participantStart, observations);
Expand All @@ -105,18 +108,18 @@ public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer st
);
// Note: the "lastDayInStudy" *could* be after the "(planned) study end", but that's OK

final Range<Instant> effectiveRange = Range.between(
final Range<Instant> effectiveRange = Range.of(
firstDayInStudy.atTime(LocalTime.MIN).atZone(ZoneId.systemDefault()).toInstant(),
lastDayInStudy.atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant()
);
final Range<Instant> filterWindow = Range.between(
final Range<Instant> filterWindow = Range.of(
from.atTime(LocalTime.MIN).atZone(ZoneId.systemDefault()).toInstant(),
to.atTime(LocalTime.MAX).atZone(ZoneId.systemDefault()).toInstant()
);

return new StudyTimeline(
participantStart,
Range.between(firstDayInStudy, lastDayInStudy, LocalDate::compareTo),
Range.of(firstDayInStudy, lastDayInStudy, LocalDate::compareTo),
observations.stream()
.flatMap(o -> SchedulerUtils
.parseToObservationSchedules(
Expand All @@ -128,7 +131,23 @@ public StudyTimeline getTimeline(Long studyId, Integer participantId, Integer st
.map(e -> ObservationTimelineEvent.fromObservation(o, e.getMinimum(), e.getMaximum()))
)
.toList(),
List.of()
interventions.stream()
.map(intervention -> {
Trigger trigger = interventionService.getTriggerByIds(studyId, intervention.getInterventionId());
return SchedulerUtils.parseToInterventionSchedules(
trigger,
effectiveRange.getMinimum(),
effectiveRange.getMaximum()
)
.stream()
// Disabled client-side filter for now...
// .filter(filterWindow::contains)
.map(event -> InterventionTimelineEvent.fromInterventionAndTrigger(intervention, trigger, event))
.toList();
})
.flatMap(List::stream)
.collect(Collectors.toList())

);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ public List<Intervention> listInterventions(Long studyId) {
return repository.listInterventions(studyId);
}

public List<Intervention> listInterventionsForGroup(Long studyId, Integer groupId) {
return repository.listInterventionsForGroup(studyId, groupId);
}

public Intervention getIntervention(Long studyId, Integer interventionId) {
return repository.getByIds(studyId, interventionId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,11 +219,11 @@ public Optional<Duration> getStudyDuration(Long studyId) {
.flatMap(study ->
Optional.ofNullable(study.getDuration())
.or(() -> Optional.of(new Duration()
.setValue((int)
java.time.Duration.between(
.setValue(
java.time.Period.between(
Objects.requireNonNullElse(study.getStartDate(), study.getPlannedStartDate()),
Objects.requireNonNullElse(study.getEndDate(), study.getPlannedEndDate())
).toDays())
).getDays())
.setUnit(Duration.Unit.DAY)
))
);
Expand All @@ -235,7 +235,9 @@ public Optional<Duration> getStudyDuration(Long studyId, Integer studyGroupId) {
if (group.isEmpty()) return Optional.empty();

return group
// Get the groups duration...
.map(StudyGroup::getDuration)
// ... of fallback to the study-duration if not set.
.or(() -> getStudyDuration(studyId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@
import biweekly.util.Recurrence;
import biweekly.util.com.google.ical.compat.javautil.DateIterator;
import io.redlink.more.studymanager.model.Observation;
import io.redlink.more.studymanager.model.Trigger;
import io.redlink.more.studymanager.model.scheduler.*;
import java.time.LocalDate;
import java.time.LocalTime;
import org.apache.commons.lang3.Range;
import org.quartz.CronExpression;

import java.sql.Date;
import java.text.ParseException;
import java.time.Instant;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;
Expand All @@ -22,7 +27,7 @@ public static List<Range<Instant>> parseToObservationSchedulesForRelativeEvent(

final List<Range<Instant>> events = new ArrayList<>();

Range<Instant> currentEvt = Range.between(
Range<Instant> currentEvt = Range.of(
toInstantFrom(event.getDtstart(), start),
toInstantFrom(event.getDtend(), start)
);
Expand All @@ -36,7 +41,7 @@ public static List<Range<Instant>> parseToObservationSchedulesForRelativeEvent(
while (currentEvt.getMaximum().isBefore(maxEnd)) {
events.add(currentEvt);
Instant estart = currentEvt.getMinimum().plus(rrule.getFrequency().getValue(), rrule.getFrequency().getUnit().toChronoUnit());
currentEvt = Range.between(estart, estart.plusMillis(durationInMs));
currentEvt = Range.of(estart, estart.plusMillis(durationInMs));
}
} else {
events.add(currentEvt);
Expand Down Expand Up @@ -65,7 +70,7 @@ public static List<Range<Instant>> parseToObservationSchedulesForEvent(Event eve
Instant ostart = it.next().toInstant();
Instant oend = ostart.plus(eventDuration, ChronoUnit.SECONDS);
if (ostart.isBefore(end) && oend.isAfter(start)) {
observationSchedules.add(Range.between(ostart, oend));
observationSchedules.add(Range.of(ostart, oend));
}
}
}
Expand Down Expand Up @@ -113,6 +118,43 @@ public static LocalDate alignStartDateToSignupInstant(final Instant signup, List
);
}

public static List<Instant> parseToInterventionSchedules(Trigger trigger, Instant start, Instant end) {
if(trigger == null) return Collections.emptyList();
if(Objects.equals(trigger.getType(), "relative-time-trigger")) {
return parseToInterventionSchedulesForRelativeTrigger(trigger, start);
} else if(Objects.equals(trigger.getType(), "scheduled-trigger")) {
return parseToInterventionSchedulesForScheduledTrigger(trigger, start, end);
} else {
return Collections.emptyList();
}
}

private static List<Instant> parseToInterventionSchedulesForRelativeTrigger(Trigger trigger, Instant start) {
return List.of(
toInstantFrom(
new RelativeDate()
.setTime(LocalTime.of(trigger.getProperties().getInt("hour"), 0))
.setOffset(new Duration().setValue(trigger.getProperties().getInt("day")).setUnit(Duration.Unit.DAY)),
start)
);
}

private static List<Instant> parseToInterventionSchedulesForScheduledTrigger(Trigger trigger, Instant start, Instant end) {
List<Instant> events = new ArrayList<>();
String cronString = trigger.getProperties().get("cronSchedule").toString();
if (CronExpression.isValidExpression(cronString)) {
try {
CronExpression cronExpression = new CronExpression(cronString);
Instant currentDate = cronExpression.getNextValidTimeAfter(Date.from(start)).toInstant();
while (currentDate.isBefore(end)) {
events.add(currentDate);
currentDate = cronExpression.getNextValidTimeAfter(Date.from(currentDate)).toInstant();
}
} catch (ParseException ignore) {}
}
return List.copyOf(events);
}

public static Instant shiftStartIfObservationAlreadyEnded(Instant start, List<Observation> observations) {
// returns start date, if now event ends before, otherwise start date + 1 day
return observations.stream()
Expand Down
5 changes: 0 additions & 5 deletions studymanager/src/main/resources/openapi/StudyManagerAPI.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1587,14 +1587,9 @@ components:
type: string
purpose:
type: string
type:
type: string
start:
type: string
format: date-time
end:
type: string
format: date-time
scheduleType:
type: string

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package io.redlink.more.studymanager.service;

import io.redlink.more.studymanager.core.properties.TriggerProperties;
import io.redlink.more.studymanager.exception.NotFoundException;
import io.redlink.more.studymanager.model.Intervention;
import io.redlink.more.studymanager.model.Observation;
import io.redlink.more.studymanager.model.Participant;
import io.redlink.more.studymanager.model.Study;
import io.redlink.more.studymanager.model.Trigger;
import io.redlink.more.studymanager.model.scheduler.Duration;
import io.redlink.more.studymanager.model.scheduler.Event;
import io.redlink.more.studymanager.model.scheduler.RecurrenceRule;
Expand Down Expand Up @@ -130,13 +133,45 @@ void testGetTimeline() {
.setEndAfter(new Duration().setValue(6).setUnit(Duration.Unit.DAY))))
.setHidden(false);

Intervention relativeIntervention = new Intervention()
.setInterventionId(1)
.setStudyGroupId(2)
.setTitle("title")
.setPurpose("purpose");

Intervention scheduledIntervention = new Intervention()
.setInterventionId(2)
.setStudyGroupId(2)
.setTitle("title2")
.setPurpose("purpose2");

TriggerProperties relativeProperties = new TriggerProperties();
relativeProperties.put("day", 1);
relativeProperties.put("hour", 1);

TriggerProperties cronProperties = new TriggerProperties();
cronProperties.put("cronSchedule", "0 0 12 * * ?");

Trigger relativeTrigger = new Trigger()
.setType("relative-time-trigger")
.setProperties(relativeProperties);

Trigger scheduledTrigger = new Trigger()
.setType("scheduled-trigger")
.setProperties(cronProperties);

when(studyService.getStudy(any(), any())).thenReturn(Optional.of(study));
when(participantService.getParticipant(any(), any())).thenReturn(participant);
when(studyService.getStudyDuration(any(), any()))
.thenReturn(Optional.of(new Duration().setValue(5).setUnit(Duration.Unit.DAY)));
when(observationService.listObservationsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn(
List.of(observationAbsolute, observationAbsoluteRecurrent, observationRelative, observationRelativeRecurrent));

when(interventionService.listInterventionsForGroup(any(), eq(participant.getStudyGroupId()))).thenReturn(
List.of(scheduledIntervention, relativeIntervention));
when(interventionService.getTriggerByIds(any(), eq(1))).thenReturn(relativeTrigger);
when(interventionService.getTriggerByIds(any(), eq(2))).thenReturn(scheduledTrigger);

StudyTimeline timeline = calendarService.getTimeline(
1L,
1,
Expand All @@ -150,7 +185,7 @@ void testGetTimeline() {
);

assertEquals(7, timeline.observationTimelineEvents().size());

assertEquals(6, timeline.interventionTimelineEvents().size());
}

}

0 comments on commit 4987942

Please sign in to comment.