diff --git a/suripu-core/src/main/java/com/hello/suripu/core/models/SleepStats.java b/suripu-core/src/main/java/com/hello/suripu/core/models/SleepStats.java index ed705c8b1..2046d11eb 100644 --- a/suripu-core/src/main/java/com/hello/suripu/core/models/SleepStats.java +++ b/suripu-core/src/main/java/com/hello/suripu/core/models/SleepStats.java @@ -41,9 +41,12 @@ public class SleepStats { public SleepStats(final Integer soundSleepDurationInMinutes, final Integer mediumSleepDurationInMinutes, final Integer lightSleepDurationInMinutes, - final Integer sleepDurationInMinutes, final boolean isInBedDuration, + final Integer sleepDurationInMinutes, + final boolean isInBedDuration, final Integer numberOfMotionEvents, - final Long sleepTime, final Long wakeTime, final Integer sleepOnsetTimeMinutes) { + final Long sleepTime, + final Long wakeTime, + final Integer sleepOnsetTimeMinutes) { this.soundSleepDurationInMinutes = soundSleepDurationInMinutes; this.mediumSleepDurationInMinutes = mediumSleepDurationInMinutes; this.lightSleepDurationInMinutes = lightSleepDurationInMinutes; diff --git a/suripu-core/src/main/java/com/hello/suripu/core/processors/QuestionProcessor.java b/suripu-core/src/main/java/com/hello/suripu/core/processors/QuestionProcessor.java index 07b6e9ebe..bfcc875d6 100644 --- a/suripu-core/src/main/java/com/hello/suripu/core/processors/QuestionProcessor.java +++ b/suripu-core/src/main/java/com/hello/suripu/core/processors/QuestionProcessor.java @@ -7,15 +7,18 @@ import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.hello.suripu.core.db.QuestionResponseDAO; +import com.hello.suripu.core.db.SleepStatsDAODynamoDB; import com.hello.suripu.core.db.util.MatcherPatternsDB; import com.hello.suripu.core.db.TimeZoneHistoryDAODynamoDB; import com.hello.suripu.core.models.AccountQuestion; import com.hello.suripu.core.models.AccountQuestionResponses; +import com.hello.suripu.core.models.AggregateSleepStats; import com.hello.suripu.core.models.Choice; import com.hello.suripu.core.models.Question; import com.hello.suripu.core.models.Response; import com.hello.suripu.core.models.Questions.QuestionCategory; import com.hello.suripu.core.models.TimeZoneHistory; +import com.hello.suripu.core.util.DateTimeUtil; import org.joda.time.DateTime; import org.joda.time.DateTimeZone; import org.joda.time.Days; @@ -57,6 +60,7 @@ public class QuestionProcessor extends FeatureFlippedProcessor{ private final TimeZoneHistoryDAODynamoDB timeZoneHistoryDAODynamoDB; private final QuestionResponseDAO questionResponseDAO; + private final SleepStatsDAODynamoDB sleepStatsDAODynamoDB; private final int checkSkipsNum; private final ListMultimap availableQuestionIds = ArrayListMultimap.create(); @@ -68,6 +72,7 @@ public class QuestionProcessor extends FeatureFlippedProcessor{ public static class Builder { private QuestionResponseDAO questionResponseDAO; private TimeZoneHistoryDAODynamoDB timeZoneHistoryDAODynamoDB; + private SleepStatsDAODynamoDB sleepStatsDAODynamoDB; private int checkSkipsNum; private ListMultimap availableQuestionIds; private ListMultimap questionAskTimeMap; @@ -85,6 +90,11 @@ public Builder withTimeZoneHistoryDaoDynamoDB(final TimeZoneHistoryDAODynamoDB t return this; } + public Builder withSleepStatsDAODynamoDB(final SleepStatsDAODynamoDB sleepStatsDAODynamoDB) { + this.sleepStatsDAODynamoDB = sleepStatsDAODynamoDB; + return this; + } + public Builder withCheckSkipsNum(final int checkSkipsNum) { this.checkSkipsNum = checkSkipsNum; return this; @@ -128,13 +138,19 @@ public Builder withQuestions(final QuestionResponseDAO questionResponseDAO) { } public QuestionProcessor build() { - return new QuestionProcessor(this.questionResponseDAO, this.timeZoneHistoryDAODynamoDB, this.checkSkipsNum, + return new QuestionProcessor(this.questionResponseDAO, + this.timeZoneHistoryDAODynamoDB, + this.sleepStatsDAODynamoDB, + this.checkSkipsNum, this.availableQuestionIds, this.questionAskTimeMap, this.questionIdMap, this.baseQuestionIds, this.questionCategoryMap); } } - public QuestionProcessor(final QuestionResponseDAO questionResponseDAO, final TimeZoneHistoryDAODynamoDB timeZoneHistoryDAODynamoDB, final int checkSkipsNum, + public QuestionProcessor(final QuestionResponseDAO questionResponseDAO, + final TimeZoneHistoryDAODynamoDB timeZoneHistoryDAODynamoDB, + final SleepStatsDAODynamoDB sleepStatsDAODynamoDB, + final int checkSkipsNum, final ListMultimap availableQuestionIds, final ListMultimap questionAskTimeMap, final Map questionIdMap, @@ -142,6 +158,7 @@ public QuestionProcessor(final QuestionResponseDAO questionResponseDAO, final Ti final Map> questionCategoryMap) { this.questionResponseDAO = questionResponseDAO; this.timeZoneHistoryDAODynamoDB = timeZoneHistoryDAODynamoDB; + this.sleepStatsDAODynamoDB = sleepStatsDAODynamoDB; this.checkSkipsNum = checkSkipsNum; this.availableQuestionIds.putAll(availableQuestionIds); this.questionAskTimeMap.putAll(questionAskTimeMap); @@ -152,22 +169,22 @@ public QuestionProcessor(final QuestionResponseDAO questionResponseDAO, final Ti /** * Get a list of questions for the user, or pre-generate one */ - public List getQuestions(final Long accountId, final int accountAgeInDays, final DateTime today, final Integer numQuestions, final Boolean checkPause) { + public List getQuestions(final Long accountId, final int accountAgeInDays, final DateTime todayLocal, final Integer numQuestions, final Boolean checkPause) { // brand new user - get on-boarding questions if (accountAgeInDays < NEW_ACCOUNT_AGE) { - return this.getOnBoardingQuestions(accountId, today); + return this.getOnBoardingQuestions(accountId, todayLocal); } // check if user has skipped too many questions in the past. if (checkPause) { - final boolean pauseQuestion = this.pauseQuestions(accountId, today); + final boolean pauseQuestion = this.pauseQuestions(accountId, todayLocal); if (pauseQuestion) { LOGGER.debug("Pause questions for user {}", accountId); return Collections.emptyList(); } } else { - this.resetNextAsk(accountId, today); + this.resetNextAsk(accountId, todayLocal); } // check if we have already generated a list of questions @@ -175,7 +192,7 @@ public List getQuestions(final Long accountId, final int accountAgeInD final LinkedHashMap preGeneratedQuestions = Maps.newLinkedHashMap(); // grab user question and response status for today if this is not a "get-more questions" request - final DateTime expiration = today.plusDays(1); + final DateTime expiration = todayLocal.plusDays(1); final ImmutableList questionResponseList = this.questionResponseDAO.getQuestionsResponsesByDate(accountId, expiration); // check if we have generated any questions for this user TODAY @@ -183,6 +200,18 @@ public List getQuestions(final Long accountId, final int accountAgeInD boolean foundAnomalyQuestion = false; boolean hasCBTIGoals = hasCBTIGoalGoOutside(accountId); + // check if user has a valid timeline last night. Note: getTZOffset by date is not possible, circular logic + Boolean hasTimeline; + final Optional timeZoneOffsetOptional = this.sleepStatsDAODynamoDB.getTimeZoneOffset(accountId); + if (!timeZoneOffsetOptional.isPresent()) { + hasTimeline = Boolean.FALSE; + } else { + final DateTime yesterdayLocal = todayLocal.minusDays(1); + final String yesterdayLocalString = DateTimeUtil.dateToYmdString(yesterdayLocal); + final Optional sleepStatOptional = this.sleepStatsDAODynamoDB.getSingleStat(accountId, yesterdayLocalString); + hasTimeline = sleepStatOptional.isPresent(); + } + if (!questionResponseList.isEmpty()) { // check number of today's question the user has answered for (final AccountQuestionResponses question : questionResponseList) { @@ -224,7 +253,7 @@ public List getQuestions(final Long accountId, final int accountAgeInD // question inserted into queue preGeneratedQuestions.put(qid, Question.withAskTimeAccountQId(questionTemplate, accountQId, - today, + todayLocal, question.questionCreationDate)); } @@ -252,9 +281,9 @@ public List getQuestions(final Long accountId, final int accountAgeInD List questions; if (accountAgeInDays < OLD_ACCOUNT_AGE) { - questions = this.getNewbieQuestions(accountId, today, getMoreNum, preGeneratedQuestions.keySet()); + questions = this.getNewbieQuestions(accountId, todayLocal, getMoreNum, preGeneratedQuestions.keySet(), hasTimeline); } else { - questions = this.getOldieQuestions(accountId, today, getMoreNum, preGeneratedQuestions.keySet()); + questions = this.getOldieQuestions(accountId, todayLocal, getMoreNum, preGeneratedQuestions.keySet(), hasTimeline); } if (!preGeneratedQuestions.isEmpty()) { @@ -418,7 +447,7 @@ private List getOnBoardingQuestions(Long accountId, DateTime today) { /** * Get questions for accounts less than 2 weeks old */ - private List getNewbieQuestions(final Long accountId, final DateTime today, final Integer numQuestions, final Set seenIds) { + private List getNewbieQuestions(final Long accountId, final DateTime today, final Integer numQuestions, final Set seenIds, final Boolean hasTimeline) { final List questions = new ArrayList<>(); @@ -428,15 +457,17 @@ private List getNewbieQuestions(final Long accountId, final DateTime t // add questions that has already been selected addedIds.addAll(seenIds); - // always include the ONE daily calibration question, most important Q has lower id + // choose ONE random daily-question IF there was a timeline generated last night. Most important Q has lower id // This should always be question 22 - final Integer questionId = this.availableQuestionIds.get(Question.FREQUENCY.DAILY).get(0); - if (!addedIds.contains(questionId)) { - addedIds.add(questionId); - final Long savedID = this.saveGeneratedQuestion(accountId, questionId, today); - if (savedID > 0L) { - final Question question = this.questionIdMap.get(questionId); - questions.add(Question.withAskTimeAccountQId(question, savedID, today, DateTime.now(DateTimeZone.UTC))); + if (hasTimeline) { + final Integer questionId = this.availableQuestionIds.get(Question.FREQUENCY.DAILY).get(0); + if (!addedIds.contains(questionId)) { + addedIds.add(questionId); + final Long savedID = this.saveGeneratedQuestion(accountId, questionId, today); + if (savedID > 0L) { + final Question question = this.questionIdMap.get(questionId); + questions.add(Question.withAskTimeAccountQId(question, savedID, today, DateTime.now(DateTimeZone.UTC))); + } } } @@ -473,7 +504,7 @@ private List getNewbieQuestions(final Long accountId, final DateTime t - take weekdays/weekends into account - do not repeat base/ongoing questions within 2 ask days */ - private List getOldieQuestions(final Long accountId, final DateTime today, final Integer numQuestions, final Set seenIds) { + private List getOldieQuestions(final Long accountId, final DateTime today, final Integer numQuestions, final Set seenIds, final Boolean hasTimeline) { final List questions = new ArrayList<>(); @@ -483,14 +514,15 @@ private List getOldieQuestions(final Long accountId, final DateTime to // add questions that has already been selected addedIds.addAll(seenIds); - // always choose ONE random daily-question - List dailyQs = this.randomlySelectFromQuestionPool(accountId, seenIds, Question.FREQUENCY.DAILY, today, 1); - if (dailyQs.size() > 0 && !addedIds.contains(dailyQs.get(0).id)) { - addedIds.add(dailyQs.get(0).id); - questions.add(dailyQs.get(0)); + // choose ONE random daily-question IF there was a timeline generated last night + if (hasTimeline) { + List dailyQs = this.randomlySelectFromQuestionPool(accountId, seenIds, Question.FREQUENCY.DAILY, today, 1); + if (dailyQs.size() > 0 && !addedIds.contains(dailyQs.get(0).id)) { + addedIds.add(dailyQs.get(0).id); + questions.add(dailyQs.get(0)); + } } - // first dib for base-question, randomly choose ONE if (questions.size() < numQuestions) { final Boolean answeredAll = addedIds.containsAll(this.baseQuestionIds); @@ -520,6 +552,8 @@ private List randomlySelectFromQuestionPool(final long accountId, fina final Question.FREQUENCY questionType, final DateTime today, final int numQuestions) { + //Note: only daily question delivered is "how was your sleep last night" right now. Use of this method needs to change if we add other daily questions. + final List questions = new ArrayList<>(); List eligibleQuestions = new ArrayList<>(); diff --git a/suripu-core/src/test/java/com/hello/suripu/core/processors/QuestionProcessorTest.java b/suripu-core/src/test/java/com/hello/suripu/core/processors/QuestionProcessorTest.java index b96537c08..1c288787b 100644 --- a/suripu-core/src/test/java/com/hello/suripu/core/processors/QuestionProcessorTest.java +++ b/suripu-core/src/test/java/com/hello/suripu/core/processors/QuestionProcessorTest.java @@ -6,14 +6,19 @@ import com.google.common.collect.Maps; import com.hello.suripu.core.ObjectGraphRoot; import com.hello.suripu.core.db.QuestionResponseDAO; +import com.hello.suripu.core.db.SleepStatsDAODynamoDB; import com.hello.suripu.core.flipper.FeatureFlipper; import com.hello.suripu.core.models.AccountInfo; import com.hello.suripu.core.models.AccountQuestion; import com.hello.suripu.core.models.AccountQuestionResponses; +import com.hello.suripu.core.models.AggregateSleepStats; import com.hello.suripu.core.models.Choice; +import com.hello.suripu.core.models.MotionScore; import com.hello.suripu.core.models.Question; import com.hello.suripu.core.models.Questions.QuestionCategory; import com.hello.suripu.core.models.Response; +import com.hello.suripu.core.models.SleepStats; +import com.hello.suripu.core.util.DateTimeUtil; import com.librato.rollout.RolloutAdapter; import com.librato.rollout.RolloutClient; import dagger.Module; @@ -118,6 +123,12 @@ public void setUp() { ObjectGraphRoot.getInstance().init(new RolloutLocalModule()); features.clear(); + final SleepStatsDAODynamoDB sleepStatsDAODynamoDB = mock(SleepStatsDAODynamoDB.class); + when(sleepStatsDAODynamoDB.getTimeZoneOffset(ACCOUNT_ID_PASS)).thenReturn(Optional.of(0)); + when(sleepStatsDAODynamoDB.getSingleStat(ACCOUNT_ID_PASS, DateTimeUtil.dateToYmdString(this.today.minusDays(1)))).thenReturn(Optional.of(new AggregateSleepStats(0L, this.today, 0, 0, "String", new MotionScore(0,0,0F,0,0), 0, 0, 0, new SleepStats(0,0,0,0,Boolean.TRUE,0, 0L, 0L, 0)))); + + when(sleepStatsDAODynamoDB.getTimeZoneOffset(ACCOUNT_ID_FAIL)).thenReturn(Optional.of(0)); + when(sleepStatsDAODynamoDB.getSingleStat(ACCOUNT_ID_FAIL, DateTimeUtil.dateToYmdString(this.today.minusDays(1)))).thenReturn(Optional.of(new AggregateSleepStats(0L, this.today, 0, 0, "String", new MotionScore(0,0,0F,0,0), 0, 0, 0, new SleepStats(0,0,0,0,Boolean.TRUE,0, 0L, 0L, 0)))); final List questions = this.getMockQuestions(); final QuestionResponseDAO questionResponseDAO = mock(QuestionResponseDAO.class); @@ -176,6 +187,7 @@ public void setUp() { final QuestionProcessor.Builder builder = new QuestionProcessor.Builder() .withQuestionResponseDAO(questionResponseDAO) .withCheckSkipsNum(CHECK_SKIP_NUM) + .withSleepStatsDAODynamoDB(sleepStatsDAODynamoDB) .withQuestions(questionResponseDAO); when(questionResponseDAO.getBaseAndRecentResponses(ACCOUNT_ID_PASS, Question.FREQUENCY.ONE_TIME.toSQLString(), oneWeekAgo))