From 1297b86c7371114459f1849ef170664a15fcc79f Mon Sep 17 00:00:00 2001 From: Bailey Date: Fri, 5 Jan 2024 13:33:41 -0600 Subject: [PATCH] [591] feat: garmin workout title templating support (#595) * [591] feat: garmin workout title templating support * done --- images/P2G_diagram.drawio | 372 ++++++++++++++++++ mkdocs/docs/configuration/json.md | 20 +- src/Common/Common.csproj | 1 + src/Common/Constants.cs | 2 +- src/Common/Dto/P2GWorkout.cs | 1 - src/Common/Dto/Settings.cs | 2 +- src/Common/Helpers/WorkoutHelper.cs | 29 +- src/Conversion/FitConverter.cs | 2 +- src/SharedUI/Shared/FormatSettingsForm.razor | 184 +++++++-- src/SharedUI/wwwroot/css/site.css | 4 + .../Common/Helpers/WorkoutHelperTests.cs | 8 +- vNextReleaseNotes.md | 2 +- 12 files changed, 571 insertions(+), 56 deletions(-) create mode 100644 images/P2G_diagram.drawio diff --git a/images/P2G_diagram.drawio b/images/P2G_diagram.drawio new file mode 100644 index 000000000..4b178b1ca --- /dev/null +++ b/images/P2G_diagram.drawio @@ -0,0 +1,372 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mkdocs/docs/configuration/json.md b/mkdocs/docs/configuration/json.md index 4df12c5b7..8b0227840 100644 --- a/mkdocs/docs/configuration/json.md +++ b/mkdocs/docs/configuration/json.md @@ -111,7 +111,7 @@ This section provides settings related to conversions and what formats should be "Strength": { "DefaultSecondsPerRep": 3 }, - "WorkoutTitlePrefix": "Peloton - " + "WorkoutTitleTemplate": "{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}" } ``` @@ -132,13 +132,16 @@ This section provides settings related to conversions and what formats should be | Rowing.PreferredLapType | no | `Default` | `Conversion Tab` | The preferred [lap type to use](#lap-types). | | Strength | no | `null` | `Conversion Tab` | Configuration specific to Strength workouts. | | Strength.DefaultSecondsPerRep | no | `3` | `Conversion Tab` | For exercises that are done for time instead of reps, P2G can estimate how many reps you completed using this value. Ex. If `DefaultSecondsPerRep=3` and you do Curls for 15s, P2G will estimate you completed 5 reps. | -| WorkoutTitlePrefix | no | `null` | `Conversion Tab` | A Title Prefix to apply to all workouts. By default applies no prefix. | +| WorkoutTitleTemplate | no | `{{PelotonWorkoutTitle}} with {{PelotonInstructorName}}` | `Conversion Tab` | Allows you to customize how your workout title will appear in Garmin Connect using [Handlebars templates](https://github.com/Handlebars-Net/Handlebars.Net). [Read More...](#workout-title-templating) | ### Understanding Custom Zones -Garmin Connect expects that users have a registered device and they expect users have set up their HR and Power Zones on that device. However, this presents a problem if you either A) do not have a device capable of tracking Power or B) do not have a Garmin device at all. +Garmin Connect expects that users have a registered device and they expect users have set up their HR and Power Zones on that device. However, this presents a problem if you either: -The most common scenario for Peloton users is A, where they do not own a Power capable Garmin device and therefore are not able to configure their Power Zones in Garmin Connect. If you do not have Power or HR zones configured in Garmin Connect then you are not able to view accurate `Time In Zones` charts for a given workout. +* A) do not have a device capable of tracking Power +* B) do not have a Garmin device at all. + +The most common scenario for Peloton users is scenario `A`, where they do not own a Power capable Garmin device and therefore are not able to configure their Power Zones in Garmin Connect. If you do not have Power or HR zones configured in Garmin Connect then you are not able to view accurate `Time In Zones` charts for a given workout. P2G provides a work around for this by optionally enriching the workout with the `Time In Zones` data with one caveat: the chart will not display the range value for the zone. @@ -168,6 +171,15 @@ P2G supports several different strategies for creating Laps in Garmin Connect. | Class Segments | `Class_Segments` | If the Peloton data includes Class Segment information, then laps will be created to match each segment: Warm Up, Cycling, Weights, Cool Down, etc. | | Distance | `Distance` | P2G will caclulate Laps based on distance for each 1mi, 1km, or 500m (for Row only) based on your distance setting in Peloton. | +### Workout Title Templating + +Some characters are not allowed to be used in the workout titles. If you use these characters in your configuration they will automatically be replaced with `-`. Additionally, Garmin has a limit on how long a title will be. If the title exceeds this limit (~45 characters) then the title will be truncated. + +The below data fields are available for use in the template: + +* `PelotonWorkoutTitle` - Peloton provides this usually in the form of "10 min HITT Ride" +* `PelotonInstructorName` - Peloton provides this as the full instructors name: "Ally Love" + ## Peloton Config This section provides settings related to fetching workouts from Peloton. diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index e7abd30d7..e25eebaa4 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -8,6 +8,7 @@ + diff --git a/src/Common/Constants.cs b/src/Common/Constants.cs index 34dda003e..31f0475f3 100644 --- a/src/Common/Constants.cs +++ b/src/Common/Constants.cs @@ -9,6 +9,6 @@ public static class Constants public const string WebUIName = "p2g_webui"; public const string ClientUIName = "p2g_clientui"; - public const string AppVersion = "4.1.0"; + public const string AppVersion = "4.1.0-rc"; } } diff --git a/src/Common/Dto/P2GWorkout.cs b/src/Common/Dto/P2GWorkout.cs index e6c29defc..dbb8efdc8 100644 --- a/src/Common/Dto/P2GWorkout.cs +++ b/src/Common/Dto/P2GWorkout.cs @@ -1,6 +1,5 @@ using Common.Dto.Peloton; using System.Collections.Generic; -using System.Linq; namespace Common.Dto { diff --git a/src/Common/Dto/Settings.cs b/src/Common/Dto/Settings.cs index 4aca310c2..31657443d 100644 --- a/src/Common/Dto/Settings.cs +++ b/src/Common/Dto/Settings.cs @@ -64,7 +64,7 @@ public Format() public bool IncludeTimeInHRZones { get; set; } public bool IncludeTimeInPowerZones { get; set; } public string DeviceInfoPath { get; set; } - public string? WorkoutTitlePrefix { get; set; } + public string? WorkoutTitleTemplate { get; set; } = "{{PelotonWorkoutTitle}}{{#if PelotonInstructorName}} with {{PelotonInstructorName}}{{/if}}"; public Cycling Cycling { get; set; } public Running Running { get; set; } public Rowing Rowing { get; init; } diff --git a/src/Common/Helpers/WorkoutHelper.cs b/src/Common/Helpers/WorkoutHelper.cs index a0c619f6c..df6c5fcf7 100644 --- a/src/Common/Helpers/WorkoutHelper.cs +++ b/src/Common/Helpers/WorkoutHelper.cs @@ -1,5 +1,6 @@ using Common.Dto; using Common.Dto.Peloton; +using HandlebarsDotNet; using System.IO; namespace Common.Helpers; @@ -7,27 +8,37 @@ namespace Common.Helpers; public static class WorkoutHelper { public const char SpaceSeparator = '_'; + public const char InvalidCharacterReplacer = '-'; + public const char Space = ' '; - private static readonly char[] InvalidFileNameChars = Path.GetInvalidFileNameChars(); + private static readonly char[] InvalidFileNameChars = Path.GetInvalidFileNameChars(); public static string GetTitle(Workout workout, Format settings) { var rideTitle = workout.Ride?.Title ?? workout.Id; var instructorName = workout.Ride?.Instructor?.Name; - var prefix = settings.WorkoutTitlePrefix ?? string.Empty; - if (instructorName is object) - instructorName = $" with {instructorName}"; + var templateData = new + { + PelotonWorkoutTitle = rideTitle, + PelotonInstructorName = instructorName + }; + + var template = settings.WorkoutTitleTemplate; + if (string.IsNullOrWhiteSpace(template)) + template = new Format().WorkoutTitleTemplate; + + var compiledTemplate = Handlebars.Compile(settings.WorkoutTitleTemplate); + var title = compiledTemplate(templateData); - var title = $"{prefix}{rideTitle}{instructorName}" - .Replace(' ', SpaceSeparator); + var cleanedTitle = title.Replace(Space, SpaceSeparator); foreach (var c in InvalidFileNameChars) { - title = title.Replace(c, '-'); + cleanedTitle = cleanedTitle.Replace(c, InvalidCharacterReplacer); } - return title; + return cleanedTitle; } public static string GetUniqueTitle(Workout workout, Format settings) @@ -38,7 +49,7 @@ public static string GetUniqueTitle(Workout workout, Format settings) public static string GetWorkoutIdFromFileName(string filePath) { var fileName = Path.GetFileNameWithoutExtension(filePath); - var parts = fileName.Split("_"); + var parts = fileName.Split(SpaceSeparator); return parts[0]; } } diff --git a/src/Conversion/FitConverter.cs b/src/Conversion/FitConverter.cs index faeca75b9..6d58f8d03 100644 --- a/src/Conversion/FitConverter.cs +++ b/src/Conversion/FitConverter.cs @@ -129,7 +129,7 @@ protected override async Task>> ConvertInternalA AddMetrics(messages, workoutSamples, sport, startTime); var workoutMesg = new WorkoutMesg(); - workoutMesg.SetWktName(title.Replace(WorkoutHelper.SpaceSeparator, ' ')); + workoutMesg.SetWktName(title.Replace(WorkoutHelper.SpaceSeparator, WorkoutHelper.Space)); workoutMesg.SetCapabilities(32); workoutMesg.SetSport(sport); workoutMesg.SetSubSport(subSport); diff --git a/src/SharedUI/Shared/FormatSettingsForm.razor b/src/SharedUI/Shared/FormatSettingsForm.razor index bf76aeff1..dda1bc3c4 100644 --- a/src/SharedUI/Shared/FormatSettingsForm.razor +++ b/src/SharedUI/Shared/FormatSettingsForm.razor @@ -1,46 +1,124 @@ -@inject IApiClient _apiClient +@using HandlebarsDotNet; +@inject IApiClient _apiClient @inject IHxMessengerService _toaster; -
+
- - - -
-
- + + + Format Types + + ? + + + + + + + +
-
-
- -
-
-
- +
+
+
+
+ + + Workout Title + + ? + + + + + +
-
- +
+
+
+
+ + + Lap Types + + ? + + + +
+
+ +
+
+ +
+
+ +
+
+
+
-
- +
+
+
+
+ + + Strength + + ? + + + + + +
+
+
+
+
- Advanced + + Advanced + + ? + +
@@ -58,12 +136,18 @@ @code { private static ICollection lapTypes = Enum.GetValues(typeof(PreferredLapType)).Cast().ToList(); - private Format formatSettings; + private Format formatSettings; + + private string workoutTemplateExample; + private string configDocumentation; public FormatSettingsForm() { var settings = new SettingsGetResponse(); formatSettings = settings.Format; + + workoutTemplateExample = string.Empty; + configDocumentation = string.Empty; } protected override Task OnInitializedAsync() @@ -75,9 +159,13 @@ private async Task LoadDataAsync() { using var tracing = Tracing.ClientTrace($"{nameof(FormatSettingsForm)}.{nameof(LoadDataAsync)}", kind: ActivityKind.Client); - var settings = await _apiClient.SettingsGetAsync(); + var settings = await _apiClient.SettingsGetAsync(); formatSettings = settings.Format; + ValueChanged(formatSettings!.WorkoutTitleTemplate); + + var systemInfo = await _apiClient.SystemInfoGetAsync(new SystemInfoGetRequest() { CheckForUpdate = settings.App.CheckForUpdates }); + configDocumentation = systemInfo.Documentation + "/configuration/json/#format-config"; } protected async Task SaveFormatSettings() @@ -101,4 +189,32 @@ Log.Error("UI - Failed to save Format settings.", e); } } + + protected void ValueChanged(string newValue) + { + formatSettings.WorkoutTitleTemplate = newValue; + + var template = formatSettings.WorkoutTitleTemplate; + var sampleData = new + { + PelotonWorkoutTitle = "15 minute HIIT Ride", + PelotonInstructorName = "Ally Love" + }; + + var compiledTemplate = Handlebars.Compile(template); + var titleExample = compiledTemplate(sampleData); + + foreach (var c in Path.GetInvalidFileNameChars()) + { + titleExample = titleExample.Replace(c, '-'); + } + + workoutTemplateExample = $"Example: {titleExample}"; + } + + private string FormatSettingsDocumentation => $"
  • FIT is the recommended format
  • FIT and TCX are the only types that can be uploaded to Garmin
  • If you enable JSON, you'll also need to enable saving a local copy in the Advanced settings below.

Documentation
(click the ? to pin this window)"; + private string WorkoutTitleDocumentation => $"Allows you to customize how your workout title will appear in Garmin Connect using Handlebars templates. Some characters are not allowed, these characters will automatically be replaced with `-`. Below are the data fields you can use in the template:

  • PelotonWorkoutTitle
  • PelotonInstructorName

Documentation
(click the ? to pin this window)"; + private string LapTypesDocumentation => $"Lap type defines how/when P2G will create a new Lap within a workout. This can be customized per Cardio type. To read more about each Lap Type please see the documentation.

Documentation
(click the ? to pin this window)"; + private string StrengthDocumentation => $"Some Strength workouts have you do an exercise for time rather than for a specific number of reps. This setting allows you to customize how P2G should estimate the number of reps that were done in a time based exercise.

Documentation
(click the ? to pin this window)"; + private string AdvancedDocumentation => $"Most users should not need to modify these settings. These settings should only be modified if you are trying to solve a specific problem. Please be sure you've read the documentation about these before modifying.

Documentation
(click the ? to pin this window)"; } diff --git a/src/SharedUI/wwwroot/css/site.css b/src/SharedUI/wwwroot/css/site.css index 2882e0899..90875d433 100644 --- a/src/SharedUI/wwwroot/css/site.css +++ b/src/SharedUI/wwwroot/css/site.css @@ -71,3 +71,7 @@ a.nav-link { .blazor-error-boundary::after { content: "An error has occurred." } + +.popover { + --bs-popover-max-width: 500px; +} \ No newline at end of file diff --git a/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs b/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs index 7ac3a0103..9e97c9bf1 100644 --- a/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs +++ b/src/UnitTests/Common/Helpers/WorkoutHelperTests.cs @@ -75,7 +75,7 @@ public void GetTitle_NullInstructorName_ShouldReturn_EmptyInstructorName() } [Test] - public void GetTitle_NullPrefix_ShouldReturn_EmptyPrefix() + public void GetTitle_NullTemplate_ShouldReturn_DefaultTemplate() { var workout = new Workout() { @@ -91,11 +91,11 @@ public void GetTitle_NullPrefix_ShouldReturn_EmptyPrefix() } [Test] - public void GetTitle_With_Prefix_ShouldReturn_Title_With_Prefix() + public void GetTitle_With_Template_ShouldReturn_TemplateAppliedToTitle() { var format = new Format() { - WorkoutTitlePrefix = "Peloton - " + WorkoutTitleTemplate = "{{PelotonInstructorName}} - {{PelotonWorkoutTitle}}" }; var workout = new Workout() @@ -108,6 +108,6 @@ public void GetTitle_With_Prefix_ShouldReturn_Title_With_Prefix() }; var title = WorkoutHelper.GetTitle(workout, format); - title.Should().Be("Peloton_-_My_Title_with_Instructor"); + title.Should().Be("Instructor_-_My_Title"); } } diff --git a/vNextReleaseNotes.md b/vNextReleaseNotes.md index 282583b11..82208caa6 100644 --- a/vNextReleaseNotes.md +++ b/vNextReleaseNotes.md @@ -3,7 +3,7 @@ ## Features -- [#564] Set a custom title prefix on Workouts +- [#564] [#591] Set a custom title on Workouts using templating - [#559] Ability to exclude Outdoor Cycling workouts from sycning ## Fixes