Skip to content

Commit

Permalink
[591] feat: garmin workout title templating support (#595)
Browse files Browse the repository at this point in the history
* [591] feat: garmin workout title templating support

* done
  • Loading branch information
philosowaffle authored Jan 5, 2024
1 parent d6b85b6 commit 1297b86
Show file tree
Hide file tree
Showing 12 changed files with 571 additions and 56 deletions.
372 changes: 372 additions & 0 deletions images/P2G_diagram.drawio

Large diffs are not rendered by default.

20 changes: 16 additions & 4 deletions mkdocs/docs/configuration/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}}"
}
```

Expand All @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/Common/Common.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

<ItemGroup>
<PackageReference Include="Flurl.Http" Version="3.2.4" />
<PackageReference Include="Handlebars.Net" Version="2.1.4" />
<PackageReference Include="JsonFlatFileDataStore" Version="2.4.2" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.6.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.0.0-rc9" />
Expand Down
2 changes: 1 addition & 1 deletion src/Common/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
1 change: 0 additions & 1 deletion src/Common/Dto/P2GWorkout.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using Common.Dto.Peloton;
using System.Collections.Generic;
using System.Linq;

namespace Common.Dto
{
Expand Down
2 changes: 1 addition & 1 deletion src/Common/Dto/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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}}";

Check warning on line 67 in src/Common/Dto/Settings.cs

View workflow job for this annotation

GitHub Actions / Publish UI Distribution (7.0.400, net7.0-windows10.0.22621.0, win10-x64)

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.
public Cycling Cycling { get; set; }
public Running Running { get; set; }
public Rowing Rowing { get; init; }
Expand Down
29 changes: 20 additions & 9 deletions src/Common/Helpers/WorkoutHelper.cs
Original file line number Diff line number Diff line change
@@ -1,33 +1,44 @@
using Common.Dto;
using Common.Dto.Peloton;
using HandlebarsDotNet;
using System.IO;

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)
Expand All @@ -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];
}
}
2 changes: 1 addition & 1 deletion src/Conversion/FitConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ protected override async Task<Tuple<string, ICollection<Mesg>>> 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);
Expand Down
184 changes: 150 additions & 34 deletions src/SharedUI/Shared/FormatSettingsForm.razor
Original file line number Diff line number Diff line change
@@ -1,46 +1,124 @@
@inject IApiClient _apiClient
@using HandlebarsDotNet;
@inject IApiClient _apiClient
@inject IHxMessengerService _toaster;

<EditForm Model="@formatSettings">
<div class="row gy-5">
<div class="row g-3">
<div class="col-md-12">
<HxSwitch Text="Convert to FIT" @bind-Value="formatSettings.Fit" />
<HxSwitch Text="Convert to TCX" @bind-Value="formatSettings.Tcx" />
<HxSwitch Text="Convert to JSON" @bind-Value="formatSettings.Json" />
</div>
<div class="col-md-4">
<HxInputText Label="Workout Title Prefix" @bind-Value="formatSettings.WorkoutTitlePrefix" />
<HxCard>
<HeaderTemplate>
Format Types
<HxPopover
Trigger="PopoverTrigger.Hover|PopoverTrigger.Click|PopoverTrigger.Focus"
Title="<b>Convert to various Format Types</b>"
Content="@FormatSettingsDocumentation"
Html="true">
<HxBadge Type="BadgeType.RoundedPill" Color="ThemeColor.Info">?</HxBadge>
</HxPopover>
</HeaderTemplate>
<BodyTemplate>
<HxSwitch Text="Convert to FIT" @bind-Value="formatSettings.Fit" />
<HxSwitch Text="Convert to TCX" @bind-Value="formatSettings.Tcx" />
<HxSwitch Text="Convert to JSON" @bind-Value="formatSettings.Json" />
</BodyTemplate>
</HxCard>
</div>
<div class="col-md-8"></div>
<div class="col-md-4">
<HxInputNumber Label="Strength: Estimated seconds per rep" TValue="int" @bind-Value="formatSettings.Strength.DefaultSecondsPerRep" />
</div>
<div class="col-md-8"></div>
<div class="col-md-4">
<HxSelect Label="Cycling Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Cycling.PreferredLapType" />
</div>
<br />
<div class="row g-3">
<div class="col-md-12">
<HxCard>
<HeaderTemplate>
Workout Title
<HxPopover
Trigger="PopoverTrigger.Hover|PopoverTrigger.Click|PopoverTrigger.Focus"
Title="<b>Workout Title Templating</b>"
Content="@WorkoutTitleDocumentation"
Html="true">
<HxBadge Type="BadgeType.RoundedPill" Color="ThemeColor.Info">?</HxBadge>
</HxPopover>
</HeaderTemplate>
<BodyTemplate>
<HxInputText Hint="@workoutTemplateExample" ValueChanged="ValueChanged" ValueExpression="() => formatSettings.WorkoutTitleTemplate" Value="@formatSettings.WorkoutTitleTemplate" />
</BodyTemplate>
</HxCard>
</div>
<div class="col-md-4">
<HxSelect Label="Running Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Running.PreferredLapType" />
</div>
<br />
<div class="row g-3">
<div class="col-md-12">
<HxCard>
<HeaderTemplate>
Lap Types
<HxPopover Trigger="PopoverTrigger.Hover|PopoverTrigger.Click|PopoverTrigger.Focus"
Title="<b>Customzing Lap Types</b>"
Content="@LapTypesDocumentation"
Html="true">
<HxBadge Type="BadgeType.RoundedPill" Color="ThemeColor.Info">?</HxBadge>
</HxPopover>
</HeaderTemplate>
<BodyTemplate>
<div class="row">
<div class="col-md-4">
<HxSelect Label="Cycling Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Cycling.PreferredLapType" />
</div>
<div class="col-md-4">
<HxSelect Label="Running Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Running.PreferredLapType" />
</div>
<div class="col-md-4">
<HxSelect Label="Rowing Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Rowing.PreferredLapType" />
</div>
</div>
</BodyTemplate>
</HxCard>
</div>
<div class="col-md-4">
<HxSelect Label="Rowing Lap Type"
Data="@lapTypes"
Nullable="false"
NullDataText="Loading info..."
@bind-Value="formatSettings.Rowing.PreferredLapType" />
</div>
<br />
<div class="row g-3">
<div class="col-md-12">
<HxCard>
<HeaderTemplate>
Strength
<HxPopover Trigger="PopoverTrigger.Hover|PopoverTrigger.Click|PopoverTrigger.Focus"
Title="<b>Strength Workouts</b>"
Content="@StrengthDocumentation"
Html="true">
<HxBadge Type="BadgeType.RoundedPill" Color="ThemeColor.Info">?</HxBadge>
</HxPopover>
</HeaderTemplate>
<BodyTemplate>
<HxInputNumber Label="Estimated seconds per rep" TValue="int" @bind-Value="formatSettings.Strength.DefaultSecondsPerRep" Hint="Example: A value of 3 means 1 rep will be counted every 3 seconds. A 30 second exercise would yield 10 reps." />
</BodyTemplate>
</HxCard>
</div>
</div>
<br />
<div class="row gy-5">

<div class="md-col-12">
<HxAccordion>
<HxAccordionItem>
<HeaderTemplate>Advanced</HeaderTemplate>
<HeaderTemplate>
Advanced
<HxPopover Trigger="PopoverTrigger.Hover|PopoverTrigger.Click|PopoverTrigger.Focus"
Title="<b>Advanced Settings</b>"
Content="@AdvancedDocumentation"
Html="true">
<HxBadge Type="BadgeType.RoundedPill" Color="ThemeColor.Info">?</HxBadge>
</HxPopover>
</HeaderTemplate>
<BodyTemplate>
<HxInputText Label="Path to custom deviceInfo.xml file" @bind-Value="formatSettings.DeviceInfoPath" /><br />
<HxSwitch Text="Include Time In HR Zones" @bind-Value="formatSettings.IncludeTimeInHRZones" />
Expand All @@ -58,12 +136,18 @@
@code {
private static ICollection<PreferredLapType> lapTypes = Enum.GetValues(typeof(PreferredLapType)).Cast<PreferredLapType>().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()
Expand All @@ -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);

Check warning on line 165 in src/SharedUI/Shared/FormatSettingsForm.razor

View workflow job for this annotation

GitHub Actions / Publish UI Distribution (7.0.400, net7.0-windows10.0.22621.0, win10-x64)

Possible null reference argument for parameter 'newValue' in 'void FormatSettingsForm.ValueChanged(string newValue)'.

var systemInfo = await _apiClient.SystemInfoGetAsync(new SystemInfoGetRequest() { CheckForUpdate = settings.App.CheckForUpdates });
configDocumentation = systemInfo.Documentation + "/configuration/json/#format-config";
}

protected async Task SaveFormatSettings()
Expand All @@ -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 => $"<ul><li>FIT is the recommended format</li><li>FIT and TCX are the only types that can be uploaded to Garmin</li><li>If you enable JSON, you'll also need to enable saving a local copy in the Advanced settings below.</li></ul><br /><a href='{configDocumentation}'>Documentation</a><br ?><small>(click the <b>?</b> to pin this window)</small>";
private string WorkoutTitleDocumentation => $"Allows you to customize how your workout title will appear in Garmin Connect using <a href='https://github.com/Handlebars-Net/Handlebars.Net'>Handlebars templates</a>. Some characters are not allowed, these characters will automatically be replaced with `-`. Below are the data fields you can use in the template:<br /><br /> <ul><li>PelotonWorkoutTitle</li><li>PelotonInstructorName</li></ul><br /><a href='{configDocumentation}'>Documentation</a><br /><small>(click the <b>?</b> to pin this window)</small>";
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.<br /><br /><a href='{configDocumentation}'>Documentation</a><br /><small>(click the <b>?</b> to pin this window)</small>";
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.<br /><br /><a href='{configDocumentation}'>Documentation</a><br /><small>(click the <b>?</b> to pin this window)</small>";
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.<br /><br /><a href='{configDocumentation}'>Documentation</a><br /><small>(click the <b>?</b> to pin this window)</small>";
}
4 changes: 4 additions & 0 deletions src/SharedUI/wwwroot/css/site.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,7 @@ a.nav-link {
.blazor-error-boundary::after {
content: "An error has occurred."
}

.popover {
--bs-popover-max-width: 500px;
}
Loading

0 comments on commit 1297b86

Please sign in to comment.