Skip to content

Commit

Permalink
Issue/406 (#409)
Browse files Browse the repository at this point in the history
* [406] WebUI - add page to see progress towards Annual Challenge tiers

* added api client and contracts

* [406] WebUI - new page to track Annual Challenge progress

* better error handling
  • Loading branch information
philosowaffle authored Jan 14, 2023
1 parent fe0eb07 commit 9378dba
Show file tree
Hide file tree
Showing 16 changed files with 403 additions and 4 deletions.
67 changes: 67 additions & 0 deletions src/Api/Controllers/PelotonAnnualChallengeController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using Common.Dto;
using Common.Dto.Api;
using Microsoft.AspNetCore.Mvc;
using Peloton.AnnualChallenge;

namespace Api.Controllers;

[ApiController]
[Produces("application/json")]
[Consumes("application/json")]
public class PelotonAnnualChallengeController : Controller
{
private readonly IAnnualChallengeService _annualChallengeService;

public PelotonAnnualChallengeController(IAnnualChallengeService annualChallengeService)
{
_annualChallengeService = annualChallengeService;
}

/// <summary>
/// Fetches a progress summary for the Peloton Annual Challenge.
/// </summary>
/// <response code="200">Returns the progress summary</response>
/// <response code="400">Invalid request values.</response>
/// <response code="500">Unhandled exception.</response>
[HttpGet]
[Route("api/pelotonannualchallenge/progress")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<ProgressGetResponse>> GetProgressSummaryAsync()
{
var userId = 1;
try
{
var serviceResult = await _annualChallengeService.GetAnnualChallengeProgressAsync(userId);

if (serviceResult.IsErrored())
return serviceResult.GetResultForError();

var data = serviceResult.Result;
var tiers = data.Tiers?.Select(t => new Common.Dto.Api.Tier()
{
BadgeUrl = t.BadgeUrl,
Title = t.Title,
RequiredMinutes = t.RequiredMinutes,
HasEarned = t.HasEarned,
PercentComplete = Convert.ToSingle(t.PercentComplete * 100),
IsOnTrackToEarndByEndOfYear = t.IsOnTrackToEarndByEndOfYear,
MinutesBehindPace = t.MinutesBehindPace,
MinutesAheadOfPace = t.MinutesAheadOfPace,
MinutesNeededPerDay = t.MinutesNeededPerDay,
MinutesNeededPerWeek = t.MinutesNeededPerWeek,
}).ToList();

return Ok(new ProgressGetResponse()
{
EarnedMinutes = data.EarnedMinutes,
Tiers = tiers ?? new List<Common.Dto.Api.Tier>(),
});
}
catch (Exception e)
{
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse($"Unexpected error occurred: {e.Message}"));
}
}
}
1 change: 0 additions & 1 deletion src/Api/Controllers/PelotonWorkoutsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ namespace Api.Controllers
[Consumes("application/json")]
public class PelotonWorkoutsController : Controller
{

private readonly IPelotonService _pelotonService;

public PelotonWorkoutsController(IPelotonService pelotonService)
Expand Down
2 changes: 2 additions & 0 deletions src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Garmin;
using Microsoft.Extensions.Caching.Memory;
using Peloton;
using Peloton.AnnualChallenge;
using Philosowaffle.Capability.ReleaseChecks;
using Prometheus;
using Serilog;
Expand Down Expand Up @@ -89,6 +90,7 @@
// PELOTON
builder.Services.AddSingleton<IPelotonApi, Peloton.ApiClient>();
builder.Services.AddSingleton<IPelotonService, PelotonService>();
builder.Services.AddSingleton<IAnnualChallengeService, AnnualChallengeService>();

// RELEASE CHECKS
builder.Services.AddGitHubReleaseChecker();
Expand Down
23 changes: 23 additions & 0 deletions src/Common/Dto/Api/PelotonAnnualChallengeContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Collections.Generic;

namespace Common.Dto.Api;

public record ProgressGetResponse
{
public double EarnedMinutes { get; init; }
public ICollection<Tier> Tiers { get; init; }
}

public record Tier
{
public string BadgeUrl { get; init; }
public string Title { get; init; }
public double RequiredMinutes { get; init; }
public bool HasEarned { get; init; }
public float PercentComplete { get; init; }
public bool IsOnTrackToEarndByEndOfYear { get; init; }
public double MinutesBehindPace { get; init; }
public double MinutesAheadOfPace { get; init; }
public double MinutesNeededPerDay { get; init; }
public double MinutesNeededPerWeek { get; init; }
}
2 changes: 0 additions & 2 deletions src/Common/Dto/ServiceResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Xml.Linq;

namespace Common.Dto;

Expand Down
24 changes: 24 additions & 0 deletions src/Peloton/AnnualChallenge/AnnualChallengeProgress.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System.Collections.Generic;

namespace Peloton.AnnualChallenge;

public record AnnualChallengeProgress
{
public bool HasJoined { get; set; }
public double EarnedMinutes { get; set; }
public ICollection<Tier> Tiers { get; set; }
}

public record Tier
{
public string BadgeUrl { get; set; }
public string Title { get; set; }
public double RequiredMinutes { get; set; }
public bool HasEarned { get; set; }
public double PercentComplete { get; set; }
public bool IsOnTrackToEarndByEndOfYear { get; set; }
public double MinutesBehindPace { get; set; }
public double MinutesAheadOfPace { get; set; }
public double MinutesNeededPerDay { get; set; }
public double MinutesNeededPerWeek { get; set; }
}
104 changes: 104 additions & 0 deletions src/Peloton/AnnualChallenge/AnnualChallengeService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
using Common.Dto;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace Peloton.AnnualChallenge;

public interface IAnnualChallengeService
{
Task<ServiceResult<AnnualChallengeProgress>> GetAnnualChallengeProgressAsync(int userId);
}

public class AnnualChallengeService : IAnnualChallengeService
{
private const string AnnualChallengeId = "66863eacd9d04447979d5dba7bf0e766";

private IPelotonApi _pelotonApi;

public AnnualChallengeService(IPelotonApi pelotonApi)
{
_pelotonApi = pelotonApi;
}

public async Task<ServiceResult<AnnualChallengeProgress>> GetAnnualChallengeProgressAsync(int userId)
{
var result = new ServiceResult<AnnualChallengeProgress>();
result.Result = new AnnualChallengeProgress();

var joinedChallenges = await _pelotonApi.GetJoinedChallengesAsync(userId);
if (joinedChallenges == null || joinedChallenges.Challenges.Length <= 0)
return result;

var annualChallenge = joinedChallenges.Challenges.FirstOrDefault(c => c.Challenge_Summary.Id == AnnualChallengeId || c.Challenge_Summary.Title == "The Annual 2023");
if (annualChallenge is null)
return result;

var annualChallengeProgressDetail = await _pelotonApi.GetUserChallengeDetailsAsync(userId, AnnualChallengeId);
if (annualChallengeProgressDetail is null)
return result;

var tiers = annualChallengeProgressDetail.Challenge_Detail.Tiers;
var progress = annualChallengeProgressDetail.Progress;

var now = DateTime.UtcNow;
var startTimeUtc = DateTimeOffset.FromUnixTimeSeconds(annualChallengeProgressDetail.Challenge_Summary.Start_Time).UtcDateTime;
var endTimeUtc = DateTimeOffset.FromUnixTimeSeconds(annualChallengeProgressDetail.Challenge_Summary.End_Time).UtcDateTime;

result.Result.HasJoined = true;
result.Result.EarnedMinutes = progress.Metric_Value;
result.Result.Tiers = tiers.Where(t => t.Metric_Value > 0).Select(t =>
{
var requiredMinutes = t.Metric_Value;
var actualMinutes = progress.Metric_Value;
var onTrackDetails = CalculateOnTrackDetails(now, startTimeUtc, endTimeUtc, actualMinutes, requiredMinutes);

return new Tier()
{
BadgeUrl = t.detailed_badge_image_url,
Title = t.Title,
RequiredMinutes = requiredMinutes,
HasEarned = onTrackDetails.HasEarned,
PercentComplete= onTrackDetails.PercentComplete,
IsOnTrackToEarndByEndOfYear = onTrackDetails.IsOnTrackToEarnByEndOfYear,
MinutesBehindPace = onTrackDetails.MinutesBehindPace,
MinutesAheadOfPace = onTrackDetails.MinutesBehindPace * -1,
MinutesNeededPerDay = onTrackDetails.MinutesNeededPerDay,
MinutesNeededPerWeek = onTrackDetails.MinutesNeededPerDay * 7,
};
}).ToList();

return result;
}

public static OnTrackDetails CalculateOnTrackDetails(DateTime now, DateTime startTimeUtc, DateTime endTimeUtc, double earnedMinutes, double requiredMinutes)
{
var totalTime = endTimeUtc - startTimeUtc;
var totalDays = Math.Ceiling(totalTime.TotalDays);

var minutesNeededPerDay = requiredMinutes / totalDays;

var elapsedTime = now - startTimeUtc;
var elapsedDays = Math.Ceiling(elapsedTime.TotalDays);

var neededMinutesToBeOnTrack = elapsedDays * minutesNeededPerDay;

return new OnTrackDetails()
{
IsOnTrackToEarnByEndOfYear = earnedMinutes >= neededMinutesToBeOnTrack,
MinutesBehindPace = neededMinutesToBeOnTrack - earnedMinutes,
MinutesNeededPerDay = minutesNeededPerDay,
HasEarned = earnedMinutes >= requiredMinutes,
PercentComplete = earnedMinutes / requiredMinutes,
};
}

public record OnTrackDetails
{
public bool IsOnTrackToEarnByEndOfYear { get; init; }
public double MinutesBehindPace { get; init; }
public double MinutesNeededPerDay { get; init; }
public bool HasEarned { get; init; }
public double PercentComplete { get; init; }
}
}
53 changes: 53 additions & 0 deletions src/Peloton/AnnualChallenge/PelotonChallenges.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace Peloton.AnnualChallenge;

public record PelotonChallenges
{
public PelotonChallenge[] Challenges { get; set; }
}

public record PelotonChallenge
{
public ChallengeSummary Challenge_Summary { get; set; }

// participants
// progress
}

public record ChallengeSummary
{
public string Id { get; set; }
public string Title { get; set; }
public string Symbol_Image_Url { get; set; }
public long Start_Time { get; set; }
public long End_Time { get; set; }
}

public record PelotonUserChallengeDetail
{
public ChallengeDetail Challenge_Detail { get; set; }
public ChallengeSummary Challenge_Summary { get; set; }
public ChallengeProgress Progress { get; set; }
}

public record ChallengeDetail
{
public string Detailed_Description { get; set; }
public PelotonChallengeTier[] Tiers { get; set; }

}

public record PelotonChallengeTier
{
public string Id { get; set; }
public string Title { get; set; }
public string detailed_badge_image_url { get; set; }
public double Metric_Value { get; set; }
public string Metric_Display_Unit { get; set; }
}

public record ChallengeProgress
{
// Current User Value
public double Metric_Value { get; set; }
public string Metric_Display_Unit { get; set; }
}
27 changes: 27 additions & 0 deletions src/Peloton/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Common.Stateful;
using Flurl.Http;
using Newtonsoft.Json.Linq;
using Peloton.AnnualChallenge;
using Peloton.Dto;
using Serilog;
using System;
Expand All @@ -21,6 +22,8 @@ public interface IPelotonApi
Task<JObject> GetWorkoutByIdAsync(string id);
Task<JObject> GetWorkoutSamplesByIdAsync(string id);
Task<UserData> GetUserDataAsync();
Task<PelotonChallenges> GetJoinedChallengesAsync(int userId);
Task<PelotonUserChallengeDetail> GetUserChallengeDetailsAsync(int userId, string challengeId);
}

public class ApiClient : IPelotonApi
Expand Down Expand Up @@ -175,5 +178,29 @@ public async Task<JObject> GetWorkoutSamplesByIdAsync(string id)
.StripSensitiveDataFromLogging(auth.Email, auth.Password)
.GetJsonAsync<JObject>();
}

public async Task<PelotonChallenges> GetJoinedChallengesAsync(int userId)
{
var auth = await GetAuthAsync();
return await $"{BaseUrl}/user/{auth.UserId}/challenges/current"
.WithCookie("peloton_session_id", auth.SessionId)
.WithCommonHeaders()
.SetQueryParams(new
{
has_joined = true
})
.StripSensitiveDataFromLogging(auth.Email, auth.Password)
.GetJsonAsync<PelotonChallenges>();
}

public async Task<PelotonUserChallengeDetail> GetUserChallengeDetailsAsync(int userId, string challengeId)
{
var auth = await GetAuthAsync();
return await $"{BaseUrl}/user/{auth.UserId}/challenge/{challengeId}"
.WithCookie("peloton_session_id", auth.SessionId)
.WithCommonHeaders()
.StripSensitiveDataFromLogging(auth.Email, auth.Password)
.GetJsonAsync<PelotonUserChallengeDetail>();
}
}
}
2 changes: 1 addition & 1 deletion src/Peloton/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@ public static void EnsurePelotonCredentialsAreProvided(this Common.Peloton setti
public static IFlurlRequest WithCommonHeaders(this IFlurlRequest request)
{
return request
.WithHeader("Peloton-Platform", "web");
.WithHeader("Peloton-Platform", "web"); // needed to get GPS points for outdoor activity in response
}
}
Binary file added src/UnitTests/Data/sample_fit/core_from_watch.fit
Binary file not shown.
Binary file not shown.
8 changes: 8 additions & 0 deletions src/WebUI/ApiClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public interface IApiClient
Task<SyncPostResponse> SyncPostAsync(SyncPostRequest syncPostRequest);

Task<SystemInfoGetResponse> SystemInfoGetAsync(SystemInfoGetRequest systemInfoGetRequest);

Task<ProgressGetResponse> GetAnnualProgressAsync();
}

public class ApiClient : IApiClient
Expand Down Expand Up @@ -98,4 +100,10 @@ public Task<PelotonWorkoutsGetAllResponse> PelotonWorkoutsGetAsync(PelotonWorkou
.SetQueryParams(request)
.GetJsonAsync<PelotonWorkoutsGetAllResponse>();
}

public Task<ProgressGetResponse> GetAnnualProgressAsync()
{
return $"{_apiUrl}/api/pelotonannualchallenge/progress"
.GetJsonAsync<ProgressGetResponse>();
}
}
Loading

0 comments on commit 9378dba

Please sign in to comment.