Skip to content

Instantly share code, notes, and snippets.

@vprtwn
Created August 28, 2015 20:07
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vprtwn/eae5d069c7c38f008f2a to your computer and use it in GitHub Desktop.
Save vprtwn/eae5d069c7c38f008f2a to your computer and use it in GitHub Desktop.
//
// PFTrendsViewModel.m
// SUM
//
// Created by Ben Guo on 11/26/14.
// Copyright (c) 2014 Project Florida. All rights reserved.
//
#import "PFTestUtilities.h"
#import "PFTrendsViewModel.h"
#import "UIColor+SUM.h"
#import "PFTrendsCategoryCellModel.h"
#import "PFTrendsDetailViewModel.h"
#import "NSCalendar+SUM.h"
#import "NSDate+SUM.h"
#import "PFDateUtils.h"
#import "PFChartValue.h"
#import "PFScoreManager.h"
#import "PFDailyScoreModel.h"
#import "PFTimeseriesDescriptor.h"
#import "PFAPIManager+Timeseries.h"
#import "PFAccountManager.h"
#import "PFUserModel.h"
#import "PFUserProfileModel.h"
#import "PFAccountManager.h"
@interface PFTrendsViewModel ()
@property (nonatomic, strong, readwrite) NSDate *startDate;
@property (nonatomic, strong, readwrite) NSDate *endDate;
@property (nonatomic, readwrite) NSUInteger numberOfWeeks;
@property (nonatomic, readwrite) BOOL rightBarButtonVisible;
@property (nonatomic, strong, readwrite) NSString *title;
@property (nonatomic, strong, readwrite) NSString *allTimeHeaderTopText;
@property (nonatomic, strong, readwrite) NSString *allTimeHeaderMiddleText;
@property (nonatomic, strong, readwrite) NSString *allTimeHeaderBottomText;
@property (nonatomic, strong, readwrite) NSString *weeklyHeaderTopText;
@property (nonatomic, strong, readwrite) NSString *weeklyHeaderMiddleText;
@property (nonatomic, strong, readwrite) NSString *weeklyHeaderBottomText;
@property (nonatomic, strong, readwrite) NSString *categoryHeaderDetailText;
@property (nonatomic, strong) RACSignal *allTimeScoreSignal;
@property (nonatomic, strong) RACSignal *createdDateSignal;
@property (nonatomic, strong) PFTrendsDetailViewModel *detailViewModel;
@property (nonatomic, strong) PFTrendsDataSourceObject *sleepPointsDataSourceObject;
@property (nonatomic, strong) PFTrendsDataSourceObject *activityPointsDataSourceObject;
@property (nonatomic, strong) PFTrendsDataSourceObject *calmPointsDataSourceObject;
@property (nonatomic, strong) PFTrendsDataSourceObject *stressPointsDataSourceObject;
@end
@implementation PFTrendsViewModel
- (instancetype)initWithAllTimeScoreSignal:(RACSignal *)scoreSignal userCreatedDateSignal:(RACSignal *)createdDateSignal
{
self = [super init];
if (self) {
_allTimeScoreSignal = scoreSignal;
_createdDateSignal = createdDateSignal;
_title = NSLocalizedString(@"Trends", @"Trends title");
_timeseriesDescriptor = [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdPoints];
_backgroundColor = [UIColor pf_darkBlueColor];
_darkBackgroundColor = [UIColor pf_darkBlueDarkColor];
NSDate *now = [NSDate date];
NSDate *beginningOfWeek = [now beginningOfWeek];
NSDate *endOfWeek = [now endOfWeek];
_startDate = beginningOfWeek;
_endDate = endOfWeek;
_allTimeHeaderTopText = [PFTrendsDetailViewModel stringForDataType:PFTrendsDataTypeSumScore];
_allTimeHeaderBottomText = [NSLocalizedString(@"All-Time Average", @"Trends header text") uppercaseStringWithLocale:[NSLocale currentLocale]];
_weeklyHeaderTopText = _allTimeHeaderTopText;
_categoryCells = @[
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategorySleep score:@(0)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryActivity score:@(0)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryCalm score:@(0)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryStress score:@(0)]
];
_timeWindowType = PFTrendsTimeWindowTypeWeekly;
@weakify(self)
[[[self.createdDateSignal ignore:nil] take:1] subscribeNext:^(NSDate *createdDate) {
@strongify(self)
NSDate *todayEnd = [[NSDate date] trailingEdge];
NSDate *beginningOfWeekJoined = [createdDate beginningOfWeek];
NSDate *endOfCurrentWeek = [todayEnd endOfWeek];
self.startDate = beginningOfWeekJoined;
self.endDate = endOfCurrentWeek;
self.numberOfWeeks = [endOfCurrentWeek numberOfWeeksSinceDate:beginningOfWeekJoined];
/// Only show the all-time toggle after 2 full weeks
NSUInteger numberOfDaysInWeek = [NSCalendar numberOfDaysPerWeek];
NSUInteger days = [[NSCalendar pf_currentCalendar] components:NSCalendarUnitDay fromDate:createdDate
toDate:todayEnd options:0].day;
self.rightBarButtonVisible = days >= numberOfDaysInWeek*2;
self.currentWeek = self.numberOfWeeks - 1;
self.dataSource = [[PFTrendsDataSourceObject alloc] initWithDataType:PFTrendsDataTypeSumScore
startDate:self.startDate];
self.detailViewModel = [[PFTrendsDetailViewModel alloc] initWithCategory:PFTrendsCategoryActivity
dataType:PFTrendsDataTypeActivityPoints
timeWindowType:PFTrendsTimeWindowTypeWeekly
numberOfWeeks:self.numberOfWeeks
currentWeek:self.currentWeek
startDate:self.startDate];
self.activityPointsDataSourceObject = [self.detailViewModel dataSourceForDataType:PFTrendsDataTypeActivityPoints];
self.sleepPointsDataSourceObject = [self.detailViewModel dataSourceForDataType:PFTrendsDataTypeSleepPoints];
self.calmPointsDataSourceObject = [self.detailViewModel dataSourceForDataType:PFTrendsDataTypeCalmPoints];
self.stressPointsDataSourceObject = [self.detailViewModel dataSourceForDataType:PFTrendsDataTypeStressPoints];
[PFTrendsViewModel fetchDataWithStartDate:self.startDate numberOfWeeks:self.numberOfWeeks
timeseries:self.timeseriesDescriptor
apiManager:[PFAPIManager sharedManager]
callback:^(NSArray *data, NSArray *trendlineData, NSError *error) {
if (data) {
self.dataSource.data = data;
self.dataSource.trendlineData = trendlineData;
self.active = YES;
}
else {
// TODO: handle this error
}
}];
}];
RACSignal *dateRangeStringSignal =
[[RACSignal combineLatest:@[RACObserve(self, currentWeek), RACObserve(self, startDate)]] map:^NSString *(RACTuple *value) {
NSUInteger weekIndex = [[value first] unsignedIntegerValue];
NSDate *startDate = [value second];
NSDate *weekStart = [PFTrendsViewModel dateForDayAtIndex:0 weekIndex:weekIndex startDate:startDate];
NSDate *weekEnd = [PFTrendsViewModel dateForDayAtIndex:6 weekIndex:weekIndex startDate:startDate];
return [PFDateUtils stringForRangeOfDatesWithStartDate:weekStart endDate:weekEnd];
}];
RAC(self, weeklyHeaderMiddleText) = dateRangeStringSignal;
RAC(self, categoryHeaderDetailText) =
[RACSignal combineLatest:@[dateRangeStringSignal, RACObserve(self, timeWindowType)]
reduce:^NSString *(NSString *dateRangeString, NSNumber *boxedTimeWindowType) {
PFTrendsTimeWindowType timeWindowType = [boxedTimeWindowType integerValue];
if (timeWindowType == PFTrendsTimeWindowTypeWeekly) {
NSString *formatString = NSLocalizedString(@"Averages for %@", @"Averages for {DATE RANGE}");
return [[NSString stringWithFormat:formatString, dateRangeString]
uppercaseStringWithLocale:[NSLocale currentLocale]];
}
else {
return [NSLocalizedString(@"All-Time Averages", @"Trends category header detail text") uppercaseStringWithLocale:[NSLocale currentLocale]];
}
}];
RAC(self, weeklyHeaderBottomText) =
[RACSignal combineLatest:@[RACObserve(self, currentWeek),
self.didBecomeActiveSignal]
reduce:^NSString *(NSNumber *boxedWeek, NSNumber *_) {
@strongify(self);
NSString *bottomPrefix = NSLocalizedString(@"Weekly Avg", @"part of Weekly Avg: 72");
CGFloat average = [self.dataSource averageForWeek:[boxedWeek integerValue]];
return [[NSString stringWithFormat:@"%@: %d", bottomPrefix, (int)round(average)]
uppercaseStringWithLocale:[NSLocale currentLocale]];
}];
RAC(self, allTimeHeaderMiddleText) =
[self.allTimeScoreSignal map:^NSString *(PFDailyScoreModel *scoreModel) {
return [NSString stringWithFormat:@"%d", (int)round([scoreModel.totalScore floatValue])];
}];
// Category cells should display all time averages when viewing the all-time chart, and otherwise display
// weekly averages for the current week.
RACSignal *isAllTimeSignal = [RACObserve(self, timeWindowType) map:^id(id value) {
return @([value unsignedIntegerValue] == PFTrendsTimeWindowTypeAllTime);
}];
RACSignal *allTimeCellsSignal =
[self.allTimeScoreSignal map:^NSArray *(PFDailyScoreModel *scoreModel) {
return @[
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategorySleep
score:scoreModel.sleep],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryActivity
score:scoreModel.activity],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryCalm
score:scoreModel.calm],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryStress
score:scoreModel.stress],
];
}];
RACSignal *allLoadedSignal =
[[[RACSignal combineLatest:@[
[RACObserve(self, sleepPointsDataSourceObject.dataLoaded) ignore:nil],
[RACObserve(self, activityPointsDataSourceObject.dataLoaded) ignore:nil],
[RACObserve(self, calmPointsDataSourceObject.dataLoaded) ignore:nil],
[RACObserve(self, stressPointsDataSourceObject.dataLoaded) ignore:nil]
]] and] distinctUntilChanged];
RACSignal *emptyWeeklyCellsSignal =
[RACSignal return:@[
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategorySleep score:nil],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryActivity score:nil],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryCalm score:nil],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryStress score:nil],
]];
RACSignal *loadedWeeklyCellsSignal =
[RACObserve(self, currentWeek) map:^NSArray *(NSNumber *boxedWeek) {
NSUInteger currentWeek = [boxedWeek unsignedIntegerValue];
CGFloat sleepAvg = [self.sleepPointsDataSourceObject averageForWeek:currentWeek];
CGFloat activityAvg = [self.activityPointsDataSourceObject averageForWeek:currentWeek];
CGFloat calmAvg = [self.calmPointsDataSourceObject averageForWeek:currentWeek];
CGFloat stressAvg = [self.stressPointsDataSourceObject averageForWeek:currentWeek];
return @[
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategorySleep score:@(sleepAvg)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryActivity score:@(activityAvg)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryCalm score:@(calmAvg)],
[[PFTrendsCategoryCellModel alloc] initWithCategory:PFTrendsCategoryStress score:@(stressAvg)]
];
}];
RACSignal *weeklyCellsSignal = [RACSignal if:allLoadedSignal then:loadedWeeklyCellsSignal else:emptyWeeklyCellsSignal];
RAC(self, categoryCells) = [[RACSignal if:isAllTimeSignal then:allTimeCellsSignal else:weeklyCellsSignal] deliverOn:[RACScheduler mainThreadScheduler]];
}
return self;
}
- (void)setTimeWindowType:(PFTrendsTimeWindowType)timeWindowType
{
_timeWindowType = timeWindowType;
if (timeWindowType == PFTrendsTimeWindowTypeAllTime) {
[[PFAnalytics sharedInstance] trackEvent:[PFAnalyticsEvent eventWithCategory:kPFEventCategoryUI action:kPFEventActionButtonPress label:@"trends_all_time" value:nil]];
}
else {
[[PFAnalytics sharedInstance] trackEvent:[PFAnalyticsEvent eventWithCategory:kPFEventCategoryUI action:kPFEventActionButtonPress label:@"trends_weekly" value:nil]];
}
}
- (PFTrendsDetailViewModel *)detailViewModelForCategory:(PFTrendsCategory)category
{
[self.detailViewModel selectCategory:category];
return self.detailViewModel;
}
// Returns the time series descriptor for the given data type.
+ (PFTimeseriesDescriptor *)timeseriesDescriptorForDataType:(PFTrendsDataType)type
{
switch (type) {
case PFTrendsDataTypeSumScore:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdPoints];
break;
case PFTrendsDataTypeSleepPoints:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdSleepPoints];
break;
case PFTrendsDataTypeWakes:
return nil;
break;
case PFTrendsDataTypeSleepTotalTime:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdSleepMinutes];
break;
case PFTrendsDataTypeSleepEfficiency:
return nil;
break;
case PFTrendsDataTypeActivityPoints:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdActivityPoints];
break;
case PFTrendsDataTypeSteps:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdSteps];
break;
case PFTrendsDataTypeCalories:
return nil;
break;
case PFTrendsDataTypeActivityTotalTime:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdActivityMinutes];
break;
case PFTrendsDataTypeActivityHighIntensityTime:
return nil;
break;
case PFTrendsDataTypeStressPoints:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdStressPoints];
break;
case PFTrendsDataTypeStressTotalTime:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdStressMinutes];
break;
case PFTrendsDataTypeCalmPoints:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdCalmPoints];
break;
case PFTrendsDataTypeCalmTotalTime:
return [PFTimeseriesDescriptor descriptorWithId:PFTimeseriesIdCalmMinutes];
break;
default:
break;
}
}
+ (NSArray *)emptyDataArrayForNumberOfWeeks:(NSUInteger)numberOfWeeks descriptor:(PFTimeseriesDescriptor *)descriptor
{
NSUInteger daysPerWeek = [NSCalendar numberOfDaysPerWeek];
NSArray *data = @[];
BOOL isMinutes = descriptor.timeseriesId == PFTimeseriesIdCalmMinutes ||
descriptor.timeseriesId == PFTimeseriesIdSleepMinutes ||
descriptor.timeseriesId == PFTimeseriesIdActivityMinutes ||
descriptor.timeseriesId == PFTimeseriesIdStressMinutes;
BOOL isTrendline = !descriptor;
if (isTrendline) {
NSUInteger pointCount = numberOfWeeks*daysPerWeek;
for (int d=0; d < pointCount; d++) {
NSNumber *v = @(d/2.0);
PFChartValue *value = [[PFChartValue alloc] initWithValue:v];
data = [data arrayByAddingObject:value];
}
return data;
}
for (int w = 0; w < numberOfWeeks; w++) {
NSMutableArray *dayValues = [NSMutableArray new];
for (int d = 0; d < daysPerWeek; d++) {
NSNumber *v = isMinutes ? @(arc4random_uniform(180)): @(arc4random_uniform(120));
PFChartValue *value = [[PFChartValue alloc] initWithValue:v];
[dayValues addObject:value];
}
data = [data arrayByAddingObject:dayValues];
}
return data;
}
+ (void)fetchDataWithStartDate:(NSDate *)startDate numberOfWeeks:(NSUInteger)numberOfWeeks
timeseries:(PFTimeseriesDescriptor *)timeseries
apiManager:(PFAPIManager *)apiManager
callback:(void(^)(NSArray *data, NSArray *trendline, NSError *error))callback {
if (!callback) {
return;
}
if ([PFAccountManager sharedInstance].showFakeData) {
NSArray *trendlineData = [self emptyDataArrayForNumberOfWeeks:numberOfWeeks descriptor:nil];
NSArray *data = [self emptyDataArrayForNumberOfWeeks:numberOfWeeks descriptor:timeseries];
callback(data, trendlineData, nil);
}
else {
NSUInteger daysPerWeek = [NSCalendar numberOfDaysPerWeek];
NSUInteger numberOfDays = numberOfWeeks * daysPerWeek;
NSDate *endDate = [startDate dateByAddingDays:numberOfDays];
// NOTE: this method is inefficient but defensive - we check the date of each item update the data array,
// rather than assuming the API will return everything in the expected order.
void(^_callback)(id, NSError *) = ^(PFTimeseriesResponse *payload, NSError *error) {
NSArray *data = nil;
NSArray *trendlineData = [self emptyDataArrayForNumberOfWeeks:numberOfWeeks descriptor:nil];
NSDate *now = [NSDate date];
if (payload) {
data = [self emptyDataArrayForNumberOfWeeks:numberOfWeeks descriptor:timeseries];
for (PFTimeseriesItem *item in payload.items) {
// convert start date to a day boundary.
NSDateComponents *components = [item.startDate components:(NSCalendarUnitYear|NSCalendarUnitMonth|NSCalendarUnitDay)];
components.hour = 12;
NSCalendar *calendar = [NSCalendar pf_currentCalendar];
NSDate *date = [[calendar dateFromComponents:components] leadingEdge];
NSNumber *boxedWeek = [PFTrendsViewModel weekIndexForDate:date startDate:startDate];
NSNumber *boxedDay = [PFTrendsViewModel dayIndexForDate:date startDate:startDate];
BOOL weekAndDayAreNonNil = boxedWeek && boxedDay;
BOOL enoughWeeks = [boxedWeek integerValue] < [data count];
if (weekAndDayAreNonNil && enoughWeeks &&
[boxedDay integerValue] < [data[[boxedWeek integerValue]] count] &&
[[item.startDate laterDate:now] isEqualToDate:now]) {
NSUInteger weekIndex = [boxedWeek unsignedIntegerValue];
NSUInteger dayIndex = [boxedDay unsignedIntegerValue];
PFChartValue *value = [[PFChartValue alloc] initWithValue:item.value];
data[weekIndex][dayIndex] = value;
}
}
if (payload.trendlineItems && payload.trendlineItems.count == payload.items.count) {
NSArray *sortedTrendlineItems = [payload.trendlineItems sortedArrayUsingComparator:^NSComparisonResult(PFTimeseriesItem *obj1, PFTimeseriesItem * obj2) {
return [obj1.startDate compare:obj2.startDate];
}];
trendlineData = [[[sortedTrendlineItems rac_sequence] map:^PFChartValue *(PFTimeseriesItem *currentItem) {
return [[PFChartValue alloc] initWithValue:currentItem.value];
}] array];
}
}
callback(data, trendlineData, nil);
};
[apiManager fetchTimeseriesWithId:timeseries.remoteId
start:startDate
end:endDate
resolution:PFTimeseriesResolutionDay
binWidth:1
binDownsample:PFTimeseriesBinDownsampleDefault
includesTrendline:YES
callback:_callback];
}
}
+ (NSDate *)dateForDayAtIndex:(NSUInteger)dayIndex
weekIndex:(NSUInteger)weekIndex
startDate:(NSDate *)startDate
{
NSDate *weekStartDate = [startDate dateByAddingDays:weekIndex * 7];
return [[weekStartDate dateByAddingDays:dayIndex] leadingEdge];
}
+ (NSNumber *)weekIndexForDate:(NSDate *)date startDate:(NSDate *)startDate
{
if (![[date earlierDate:startDate] isEqualToDate:startDate] && ![date isEqualToDate:startDate]) {
return nil;
}
NSDate *end = [date trailingEdge];
NSDateComponents *components = [[NSCalendar pf_currentCalendar] components:NSCalendarUnitDay
fromDate:startDate toDate:end options:0];
NSInteger days = components.day;
if (days < 1) {
return nil;
}
return @((NSUInteger)((days - 1)/7.0));
}
+ (NSNumber *)dayIndexForDate:(NSDate *)date startDate:(NSDate *)startDate
{
if (![[date earlierDate:startDate] isEqualToDate:startDate] && ![date isEqualToDate:startDate]) {
return nil;
}
NSDate *end = [date trailingEdge];
NSDateComponents *components = [[NSCalendar pf_currentCalendar] components:NSCalendarUnitDay
fromDate:startDate toDate:end options:0];
NSInteger days = components.day;
if (days < 1) {
return nil;
}
return @((days - 1)%7);
}
+ (NSNumber *)categoryForDataType:(PFTrendsDataType)dataType
{
switch (dataType) {
case PFTrendsDataTypeSleepPoints:
case PFTrendsDataTypeWakes:
case PFTrendsDataTypeSleepTotalTime:
case PFTrendsDataTypeSleepEfficiency:
return @(PFTrendsCategorySleep);
break;
case PFTrendsDataTypeActivityPoints:
case PFTrendsDataTypeSteps:
case PFTrendsDataTypeCalories:
case PFTrendsDataTypeActivityTotalTime:
case PFTrendsDataTypeActivityHighIntensityTime:
return @(PFTrendsCategoryActivity);
break;
case PFTrendsDataTypeStressPoints:
case PFTrendsDataTypeStressTotalTime:
return @(PFTrendsCategoryStress);
break;
case PFTrendsDataTypeCalmPoints:
case PFTrendsDataTypeCalmTotalTime:
return @(PFTrendsCategoryCalm);
break;
case PFTrendsDataTypeSumScore:
default:
return nil;
break;
}
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment