Merge pull request #110 from SamR1/sport-preferences

Add user sport preferences
This commit is contained in:
Sam 2021-11-13 15:48:23 +01:00 committed by GitHub
commit d60bf52505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 2047 additions and 271 deletions

View File

@ -7,6 +7,7 @@
#### New Features #### New Features
* [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout * [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI * [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI
#### Bugs Fixed #### Bugs Fixed
@ -22,6 +23,8 @@
* [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement * [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement
* [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports * [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports
In this release 5 issue were closed.
## Version 0.4.9 (2021/07/16) ## Version 0.4.9 (2021/07/16)

View File

@ -8,6 +8,8 @@ Authentication
auth.logout_user, auth.logout_user,
auth.get_authenticated_user_profile, auth.get_authenticated_user_profile,
auth.edit_user, auth.edit_user,
auth.edit_user_preferences,
auth.edit_user_sport_preferences,
auth.edit_picture, auth.edit_picture,
auth.del_picture, auth.del_picture,
auth.request_password_reset, auth.request_password_reset,

View File

@ -7,6 +7,7 @@
#### New Features #### New Features
* [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout * [#91](https://github.com/SamR1/FitTrackee/issues/91) - Display elevation chart with min and max altitude of workout
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI * [#18](https://github.com/SamR1/FitTrackee/issues/18) - Better UI
#### Bugs Fixed #### Bugs Fixed
@ -22,6 +23,8 @@
* [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement * [#98/#109](https://github.com/SamR1/FitTrackee/pull/109) - Added stopped_speed_threshold to support slow movement
* [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports * [#84/#93](https://github.com/SamR1/FitTrackee/pull/93) - Add elevation data and new sports
In this release 5 issue were closed.
## Version 0.4.9 (2021/07/16) ## Version 0.4.9 (2021/07/16)

View File

@ -31,10 +31,21 @@ Administration
- enable or disable a sport (a sport can be disabled even if workout with this sport exists) - enable or disable a sport (a sport can be disabled even if workout with this sport exists)
Account Account & preferences
^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
- A user can create, update and deleted his account - A user can create, update and deleted his account
- A user can reset his password (*new in 0.3.0*) - A user can reset his password (*new in 0.3.0*)
- A user can set language, timezone and first day of week.
- A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
- disable/enable a sport.
.. note::
| If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has user's workouts and the user can only change sport color.
| A disabled sport (by admin or user) will not appear in dropdown when **adding a workout**.
| A workout with a disabled sport will still be displayed in the application.
Workouts Workouts
@ -51,29 +62,33 @@ Workouts
- Skiing (Cross Country) (**new in 0.5.0**) - Skiing (Cross Country) (**new in 0.5.0**)
- Trail (**new in 0.5.0**) - Trail (**new in 0.5.0**)
- Walking - Walking
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one (0.1 km/h instead of 1 km/h) for the following sports: - (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one for the following sports (0.1 km/h instead of 1 km/h):
- Hiking - Hiking
- Skiing (Cross Country) - Skiing (Cross Country)
- Trail - Trail
- Walking - Walking
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance) .. note::
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed It can be overridden in user preferences.
- Workout edition and deletion. User can add a note
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.
- Workout edition and deletion. User can add a note.
- User statistics - User statistics
- User records by sports: - User records by sports:
- average speed - average speed
- farest distance - farest distance
- longest duration - longest duration
- maximum speed - maximum speed
- Workouts list and filter - Workouts list and filter. Only sports with workouts are displayed in sport dropdown.
.. note:: .. note::
for now, only the owner of the workout can see it. For now, only the owner of the workout can see it.
Translations Translations
^^^^^^^^^^^^ ^^^^^^^^^^^^
FitTrackee is available in English and French (which can be saved in the user settings). FitTrackee is available in English and French (which can be saved in the user preferences).
Dashboard Dashboard

View File

@ -516,6 +516,179 @@
</dl> </dl>
</dd></dl> </dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-profile-edit-preferences">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/profile/edit/preferences</span></span><a class="headerlink" href="#post--api-auth-profile-edit-preferences" title="Permalink to this definition"></a></dt>
<dd><p>edit authenticated user preferences</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">POST</span> <span class="nn">/api/auth/profile/edit/preferences</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example response</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;admin&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;bio&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;birth_date&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;created_at&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 14 Jul 2019 14:09:58 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;email&quot;</span><span class="p">:</span> <span class="s2">&quot;sam@example.com&quot;</span><span class="p">,</span>
<span class="nt">&quot;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;language&quot;</span><span class="p">:</span> <span class="s2">&quot;en&quot;</span><span class="p">,</span>
<span class="nt">&quot;last_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;nb_sports&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;nb_workouts&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;picture&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;records&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">9</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;AS&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">10</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;FD&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">11</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;LD&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="s2">&quot;1:01:00&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">12</span><span class="p">,</span>
<span class="nt">&quot;record_type&quot;</span><span class="p">:</span> <span class="s2">&quot;MS&quot;</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user&quot;</span><span class="p">:</span> <span class="s2">&quot;sam&quot;</span><span class="p">,</span>
<span class="nt">&quot;value&quot;</span><span class="p">:</span> <span class="mi">18</span><span class="p">,</span>
<span class="nt">&quot;workout_date&quot;</span><span class="p">:</span> <span class="s2">&quot;Sun, 07 Jul 2019 08:00:00 GMT&quot;</span><span class="p">,</span>
<span class="nt">&quot;workout_id&quot;</span><span class="p">:</span> <span class="s2">&quot;hvYBqYBRa7wwXpaStWR4V2&quot;</span>
<span class="p">}</span>
<span class="p">],</span>
<span class="nt">&quot;sports_list&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="mi">1</span><span class="p">,</span>
<span class="mi">4</span><span class="p">,</span>
<span class="mi">6</span>
<span class="p">],</span>
<span class="nt">&quot;timezone&quot;</span><span class="p">:</span> <span class="s2">&quot;Europe/Paris&quot;</span><span class="p">,</span>
<span class="nt">&quot;total_distance&quot;</span><span class="p">:</span> <span class="mf">67.895</span><span class="p">,</span>
<span class="nt">&quot;total_duration&quot;</span><span class="p">:</span> <span class="s2">&quot;6:50:27&quot;</span><span class="p">,</span>
<span class="nt">&quot;username&quot;</span><span class="p">:</span> <span class="nt">&quot;sam&quot;</span>
<span class="nt">&quot;weekm&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">},</span>
<span class="nt">&quot;message&quot;</span><span class="p">:</span> <span class="s2">&quot;user preferences updated&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Request JSON Object</dt>
<dd class="field-odd"><ul class="simple">
<li><p><strong>timezone</strong> (<em>string</em>) user time zone</p></li>
<li><p><strong>weekm</strong> (<em>string</em>) does week start on Monday?</p></li>
<li><p><strong>language</strong> (<em>string</em>) language preferences</p></li>
</ul>
</dd>
<dt class="field-even">Request Headers</dt>
<dd class="field-even"><ul class="simple">
<li><p><span><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a></span> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-odd">Status Codes</dt>
<dd class="field-odd"><ul class="simple">
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a></span> user preferences updated</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1">400 Bad Request</a></span> <ul>
<li><p>invalid payload</p></li>
<li><p>password: password and password confirmation dont match</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a></span> <ul>
<li><p>provide a valid auth token</p></li>
<li><p>signature expired, please log in again</p></li>
<li><p>invalid token, please log in again</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a></span> error, please try again or contact the administrator</p></li>
</ul>
</dd>
</dl>
</dd></dl>
<dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-profile-edit-sports">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/profile/edit/sports</span></span><a class="headerlink" href="#post--api-auth-profile-edit-sports" title="Permalink to this definition"></a></dt>
<dd><p>edit authenticated user sport preferences</p>
<p><strong>Example request</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="nf">POST</span> <span class="nn">/api/auth/profile/edit/sports</span> <span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
</pre></div>
</div>
<p><strong>Example response</strong>:</p>
<div class="highlight-http notranslate"><div class="highlight"><pre><span></span><span class="kr">HTTP</span><span class="o">/</span><span class="m">1.1</span> <span class="m">200</span> <span class="ne">OK</span>
<span class="na">Content-Type</span><span class="o">:</span> <span class="l">application/json</span>
<span class="p">{</span>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="s2">&quot;#000000&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;sport_id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;user_id&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span>
<span class="nt">&quot;message&quot;</span><span class="p">:</span> <span class="s2">&quot;user sport preferences updated&quot;</span><span class="p">,</span>
<span class="nt">&quot;status&quot;</span><span class="p">:</span> <span class="s2">&quot;success&quot;</span>
<span class="p">}</span>
</pre></div>
</div>
<dl class="field-list simple">
<dt class="field-odd">Request JSON Object</dt>
<dd class="field-odd"><ul class="simple">
<li><p><strong>color</strong> (<em>string</em>) valid hexadecimal color</p></li>
<li><p><strong>is_active</strong> (<em>boolean</em>) is sport available when adding a workout</p></li>
<li><p><strong>stopped_speed_threshold</strong> (<em>float</em>) stopped speed threshold used by gpxpy</p></li>
</ul>
</dd>
<dt class="field-even">Request Headers</dt>
<dd class="field-even"><ul class="simple">
<li><p><span><a class="reference external" href="https://tools.ietf.org/html/rfc7235#section-4.2">Authorization</a></span> OAuth 2.0 Bearer Token</p></li>
</ul>
</dd>
<dt class="field-odd">Status Codes</dt>
<dd class="field-odd"><ul class="simple">
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.2.1">200 OK</a></span> user preferences updated</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1">400 Bad Request</a></span> <ul>
<li><p>invalid payload</p></li>
<li><p>invalid hexadecimal color</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.2">401 Unauthorized</a></span> <ul>
<li><p>provide a valid auth token</p></li>
<li><p>signature expired, please log in again</p></li>
<li><p>invalid token, please log in again</p></li>
</ul>
</p></li>
<li><p><span><a class="reference external" href="https://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.5.1">500 Internal Server Error</a></span> error, please try again or contact the administrator</p></li>
</ul>
</dd>
</dl>
</dd></dl>
<dl class="http post"> <dl class="http post">
<dt class="sig sig-object http" id="post--api-auth-picture"> <dt class="sig sig-object http" id="post--api-auth-picture">
<span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/picture</span></span><a class="headerlink" href="#post--api-auth-picture" title="Permalink to this definition"></a></dt> <span class="sig-name descname"><span class="pre">POST</span> </span><span class="sig-name descname"><span class="pre">/api/auth/picture</span></span><a class="headerlink" href="#post--api-auth-picture" title="Permalink to this definition"></a></dt>

View File

@ -147,40 +147,52 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span> <span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-transport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/hiking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/mountain-biking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/running.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/walking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">}</span> <span class="p">}</span>
<span class="p">]</span> <span class="p">]</span>
<span class="p">},</span> <span class="p">},</span>
@ -198,46 +210,58 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span> <span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-transport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Transport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/hiking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Hiking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">4</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/mountain-biking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Mountain Biking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/running.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Running&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">},</span> <span class="p">},</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">6</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/walking.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Walking&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mf">0.1</span>
<span class="p">}</span> <span class="p">}</span>
<span class="p">]</span> <span class="p">]</span>
<span class="p">},</span> <span class="p">},</span>
@ -290,10 +314,12 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span> <span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span> <span class="p">}</span>
<span class="p">]</span> <span class="p">]</span>
<span class="p">},</span> <span class="p">},</span>
@ -311,11 +337,13 @@
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span> <span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span> <span class="p">}</span>
<span class="p">]</span> <span class="p">]</span>
<span class="p">},</span> <span class="p">},</span>
@ -385,11 +413,13 @@ Authenticated user must be an admin</p>
<span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&quot;data&quot;</span><span class="p">:</span> <span class="p">{</span>
<span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span> <span class="nt">&quot;sports&quot;</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="p">{</span>
<span class="nt">&quot;color&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;has_workouts&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&quot;id&quot;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="nt">&quot;img&quot;</span><span class="p">:</span> <span class="s2">&quot;/img/sports/cycling-sport.png&quot;</span><span class="p">,</span>
<span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nt">&quot;is_active&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span> <span class="nt">&quot;is_active_for_user&quot;</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nt">&quot;label&quot;</span><span class="p">:</span> <span class="s2">&quot;Cycling (Sport)&quot;</span><span class="p">,</span>
<span class="nt">&quot;stopped_speed_threshold&quot;</span><span class="p">:</span> <span class="mi">1</span>
<span class="p">}</span> <span class="p">}</span>
<span class="p">]</span> <span class="p">]</span>
<span class="p">},</span> <span class="p">},</span>

View File

@ -278,6 +278,7 @@
<h4>New Features<a class="headerlink" href="#new-features" title="Permalink to this headline"></a></h4> <h4>New Features<a class="headerlink" href="#new-features" title="Permalink to this headline"></a></h4>
<ul class="simple"> <ul class="simple">
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/91">#91</a> - Display elevation chart with min and max altitude of workout</p></li> <li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/91">#91</a> - Display elevation chart with min and max altitude of workout</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/90">#90</a> - Add user sports preferences</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/18">#18</a> - Better UI</p></li> <li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/issues/18">#18</a> - Better UI</p></li>
</ul> </ul>
</section> </section>
@ -300,6 +301,7 @@
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/109">#98/#109</a> - Added stopped_speed_threshold to support slow movement</p></li> <li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/109">#98/#109</a> - Added stopped_speed_threshold to support slow movement</p></li>
<li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/93">#84/#93</a> - Add elevation data and new sports</p></li> <li><p><a class="reference external" href="https://github.com/SamR1/FitTrackee/pull/93">#84/#93</a> - Add elevation data and new sports</p></li>
</ul> </ul>
<p>In this release 5 issue were closed.</p>
</section> </section>
</section> </section>
<section id="version-0-4-9-2021-07-16"> <section id="version-0-4-9-2021-07-16">

View File

@ -80,7 +80,7 @@
<li><a class="reference internal" href="#">Features</a><ul> <li><a class="reference internal" href="#">Features</a><ul>
<li><a class="reference internal" href="#list">List</a><ul> <li><a class="reference internal" href="#list">List</a><ul>
<li><a class="reference internal" href="#administration">Administration</a></li> <li><a class="reference internal" href="#administration">Administration</a></li>
<li><a class="reference internal" href="#account">Account</a></li> <li><a class="reference internal" href="#account-preferences">Account &amp; preferences</a></li>
<li><a class="reference internal" href="#workouts">Workouts</a></li> <li><a class="reference internal" href="#workouts">Workouts</a></li>
<li><a class="reference internal" href="#translations">Translations</a></li> <li><a class="reference internal" href="#translations">Translations</a></li>
</ul> </ul>
@ -174,12 +174,30 @@
</li> </li>
</ul> </ul>
</section> </section>
<section id="account"> <section id="account-preferences">
<h3>Account<a class="headerlink" href="#account" title="Permalink to this headline"></a></h3> <h3>Account &amp; preferences<a class="headerlink" href="#account-preferences" title="Permalink to this headline"></a></h3>
<ul class="simple"> <ul class="simple">
<li><p>A user can create, update and deleted his account</p></li> <li><p>A user can create, update and deleted his account</p></li>
<li><p>A user can reset his password (<em>new in 0.3.0</em>)</p></li> <li><p>A user can reset his password (<em>new in 0.3.0</em>)</p></li>
<li><p>A user can set language, timezone and first day of week.</p></li>
<li><dl class="simple">
<dt>A user can set sport preferences (<em>new in 0.5.0</em>):</dt><dd><ul>
<li><p>change sport color (used for sport image and charts)</p></li>
<li><p>can override stopped speed threshold (for next uploaded gpx files)</p></li>
<li><p>disable/enable a sport.</p></li>
</ul> </ul>
</dd>
</dl>
</li>
</ul>
<div class="admonition note">
<p class="admonition-title">Note</p>
<div class="line-block">
<div class="line">If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has users workouts and the user can only change sport color.</div>
<div class="line">A disabled sport (by admin or user) will not appear in dropdown when <strong>adding a workout</strong>.</div>
<div class="line">A workout with a disabled sport will still be displayed in the application.</div>
</div>
</div>
</section> </section>
<section id="workouts"> <section id="workouts">
<h3>Workouts<a class="headerlink" href="#workouts" title="Permalink to this headline"></a></h3> <h3>Workouts<a class="headerlink" href="#workouts" title="Permalink to this headline"></a></h3>
@ -202,7 +220,7 @@
</dl> </dl>
</li> </li>
<li><dl class="simple"> <li><dl class="simple">
<dt>(<em>new in 0.5.0</em>) Stopped speed threshold used by <a class="reference external" href="https://github.com/tkrajina/gpxpy">gpxpy</a> is not the default one (0.1 km/h instead of 1 km/h) for the following sports:</dt><dd><ul> <dt>(<em>new in 0.5.0</em>) Stopped speed threshold used by <a class="reference external" href="https://github.com/tkrajina/gpxpy">gpxpy</a> is not the default one for the following sports (0.1 km/h instead of 1 km/h):</dt><dd><ul>
<li><p>Hiking</p></li> <li><p>Hiking</p></li>
<li><p>Skiing (Cross Country)</p></li> <li><p>Skiing (Cross Country)</p></li>
<li><p>Trail</p></li> <li><p>Trail</p></li>
@ -211,10 +229,16 @@
</dd> </dd>
</dl> </dl>
</li> </li>
<li><p>Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.</p></li> </ul>
<li><p>Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance)</p></li> <div class="admonition note">
<li><p>A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed</p></li> <p class="admonition-title">Note</p>
<li><p>Workout edition and deletion. User can add a note</p></li> <p>It can be overridden in user preferences.</p>
</div>
<ul class="simple">
<li><p>Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.</p></li>
<li><p>Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).</p></li>
<li><p>A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.</p></li>
<li><p>Workout edition and deletion. User can add a note.</p></li>
<li><p>User statistics</p></li> <li><p>User statistics</p></li>
<li><dl class="simple"> <li><dl class="simple">
<dt>User records by sports:</dt><dd><ul> <dt>User records by sports:</dt><dd><ul>
@ -226,16 +250,16 @@
</dd> </dd>
</dl> </dl>
</li> </li>
<li><p>Workouts list and filter</p></li> <li><p>Workouts list and filter. Only sports with workouts are displayed in sport dropdown.</p></li>
</ul> </ul>
<div class="admonition note"> <div class="admonition note">
<p class="admonition-title">Note</p> <p class="admonition-title">Note</p>
<p>for now, only the owner of the workout can see it.</p> <p>For now, only the owner of the workout can see it.</p>
</div> </div>
</section> </section>
<section id="translations"> <section id="translations">
<h3>Translations<a class="headerlink" href="#translations" title="Permalink to this headline"></a></h3> <h3>Translations<a class="headerlink" href="#translations" title="Permalink to this headline"></a></h3>
<p>FitTrackee is available in English and French (which can be saved in the user settings).</p> <p>FitTrackee is available in English and French (which can be saved in the user preferences).</p>
</section> </section>
</section> </section>
<section id="dashboard"> <section id="dashboard">

View File

@ -255,6 +255,16 @@
<td> <td>
<a href="api/auth.html#post--api-auth-profile-edit"><code class="xref">POST /api/auth/profile/edit</code></a></td><td> <a href="api/auth.html#post--api-auth-profile-edit"><code class="xref">POST /api/auth/profile/edit</code></a></td><td>
<em></em></td></tr> <em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/auth.html#post--api-auth-profile-edit-preferences"><code class="xref">POST /api/auth/profile/edit/preferences</code></a></td><td>
<em></em></td></tr>
<tr>
<td></td>
<td>
<a href="api/auth.html#post--api-auth-profile-edit-sports"><code class="xref">POST /api/auth/profile/edit/sports</code></a></td><td>
<em></em></td></tr>
<tr> <tr>
<td></td> <td></td>
<td> <td>

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -8,6 +8,8 @@ Authentication
auth.logout_user, auth.logout_user,
auth.get_authenticated_user_profile, auth.get_authenticated_user_profile,
auth.edit_user, auth.edit_user,
auth.edit_user_preferences,
auth.edit_user_sport_preferences,
auth.edit_picture, auth.edit_picture,
auth.del_picture, auth.del_picture,
auth.request_password_reset, auth.request_password_reset,

View File

@ -31,10 +31,21 @@ Administration
- enable or disable a sport (a sport can be disabled even if workout with this sport exists) - enable or disable a sport (a sport can be disabled even if workout with this sport exists)
Account Account & preferences
^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^
- A user can create, update and deleted his account - A user can create, update and deleted his account
- A user can reset his password (*new in 0.3.0*) - A user can reset his password (*new in 0.3.0*)
- A user can set language, timezone and first day of week.
- A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
- disable/enable a sport.
.. note::
| If a sport is disabled by an administrator, it can not be enabled by a user. In this case, it will only appear in preferences if it has user's workouts and the user can only change sport color.
| A disabled sport (by admin or user) will not appear in dropdown when **adding a workout**.
| A workout with a disabled sport will still be displayed in the application.
Workouts Workouts
@ -51,29 +62,33 @@ Workouts
- Skiing (Cross Country) (**new in 0.5.0**) - Skiing (Cross Country) (**new in 0.5.0**)
- Trail (**new in 0.5.0**) - Trail (**new in 0.5.0**)
- Walking - Walking
- (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one (0.1 km/h instead of 1 km/h) for the following sports: - (*new in 0.5.0*) Stopped speed threshold used by `gpxpy <https://github.com/tkrajina/gpxpy>`_ is not the default one for the following sports (0.1 km/h instead of 1 km/h):
- Hiking - Hiking
- Skiing (Cross Country) - Skiing (Cross Country)
- Trail - Trail
- Walking - Walking
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user settings). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance) .. note::
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed It can be overridden in user preferences.
- Workout edition and deletion. User can add a note
- Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.
- Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).
- A workout with a gpx file can be displayed with map, weather (if the DarkSky API key is provided) and charts (speed and elevation). Segments can be displayed.
- Workout edition and deletion. User can add a note.
- User statistics - User statistics
- User records by sports: - User records by sports:
- average speed - average speed
- farest distance - farest distance
- longest duration - longest duration
- maximum speed - maximum speed
- Workouts list and filter - Workouts list and filter. Only sports with workouts are displayed in sport dropdown.
.. note:: .. note::
for now, only the owner of the workout can see it. For now, only the owner of the workout can see it.
Translations Translations
^^^^^^^^^^^^ ^^^^^^^^^^^^
FitTrackee is available in English and French (which can be saved in the user settings). FitTrackee is available in English and French (which can be saved in the user preferences).
Dashboard Dashboard

View File

@ -1 +1 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"><link rel="stylesheet" href="/static/css/leaflet.css"><title>FitTrackee</title><link href="/static/css/admin.04e24276.css" rel="prefetch"><link href="/static/css/main.c790adb1.css" rel="prefetch"><link href="/static/css/main~workouts.66c5ef05.css" rel="prefetch"><link href="/static/css/profile.b52bc193.css" rel="prefetch"><link href="/static/css/reset.bd9657a8.css" rel="prefetch"><link href="/static/css/workouts.d0767062.css" rel="prefetch"><link href="/static/js/admin.2f1d393d.js" rel="prefetch"><link href="/static/js/chunk-2d0c9189.c81458cc.js" rel="prefetch"><link href="/static/js/chunk-2d0cf391.020c75ea.js" rel="prefetch"><link href="/static/js/chunk-2d0da8f3.c8c3e7e8.js" rel="prefetch"><link href="/static/js/chunk-2d2248b6.d84473c1.js" rel="prefetch"><link href="/static/js/chunk-2d22523a.4b710d99.js" rel="prefetch"><link href="/static/js/main.e5da50b8.js" rel="prefetch"><link href="/static/js/main~workouts.a74990d7.js" rel="prefetch"><link href="/static/js/profile.6a786c1d.js" rel="prefetch"><link href="/static/js/reset.518e646f.js" rel="prefetch"><link href="/static/js/workouts.1c22fd12.js" rel="prefetch"><link href="/static/css/app.534b9c5c.css" rel="preload" as="style"><link href="/static/js/app.9ada5ac5.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.71654064.js" rel="preload" as="script"><link href="/static/css/app.534b9c5c.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.71654064.js"></script><script src="/static/js/app.9ada5ac5.js"></script></body></html> <!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><!--[if IE]><link rel="icon" href="/favicon.ico"><![endif]--><link rel="stylesheet" href="/static/css/fork-awesome.min.css"><link rel="stylesheet" href="/static/css/leaflet.css"><title>FitTrackee</title><link href="/static/css/admin.babfd43e.css" rel="prefetch"><link href="/static/css/main.7229c1ab.css" rel="prefetch"><link href="/static/css/main~workouts.0edb3403.css" rel="prefetch"><link href="/static/css/profile.05400f70.css" rel="prefetch"><link href="/static/css/reset.46776e72.css" rel="prefetch"><link href="/static/css/workouts.1b0a7916.css" rel="prefetch"><link href="/static/js/admin.2f1d393d.js" rel="prefetch"><link href="/static/js/chunk-2d0c9189.c81458cc.js" rel="prefetch"><link href="/static/js/chunk-2d0cf391.020c75ea.js" rel="prefetch"><link href="/static/js/chunk-2d0da8f3.c8c3e7e8.js" rel="prefetch"><link href="/static/js/chunk-2d2248b6.d84473c1.js" rel="prefetch"><link href="/static/js/chunk-2d22523a.4b710d99.js" rel="prefetch"><link href="/static/js/main.db9cee98.js" rel="prefetch"><link href="/static/js/main~workouts.a74990d7.js" rel="prefetch"><link href="/static/js/profile.62578012.js" rel="prefetch"><link href="/static/js/reset.518e646f.js" rel="prefetch"><link href="/static/js/workouts.d69cf48a.js" rel="prefetch"><link href="/static/css/app.e1e7e23c.css" rel="preload" as="style"><link href="/static/js/app.0f3b3ab5.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.71654064.js" rel="preload" as="script"><link href="/static/css/app.e1e7e23c.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="/img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/img/icons/favicon-16x16.png"><link rel="manifest" href="/manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="no"><meta name="apple-mobile-web-app-status-bar-style" content="default"><meta name="apple-mobile-web-app-title" content="fittrackee_client"><link rel="apple-touch-icon" href="/img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="/img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="/img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><noscript><strong>We're sorry but FitTrackee doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script src="/static/js/chunk-vendors.71654064.js"></script><script src="/static/js/app.0f3b3ab5.js"></script></body></html>

View File

@ -64,7 +64,7 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/img/workouts/mountains.svg" "url": "/img/workouts/mountains.svg"
}, },
{ {
"revision": "04e22b3a72bc3a810319eb7a512e4b23", "revision": "926210f132992651a9543d9c76da25ba",
"url": "/index.html" "url": "/index.html"
}, },
{ {
@ -76,12 +76,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/robots.txt" "url": "/robots.txt"
}, },
{ {
"revision": "ca5244fc3bcfc65816b9", "revision": "33002d1c4452ecd02e50",
"url": "/static/css/admin.04e24276.css" "url": "/static/css/admin.babfd43e.css"
}, },
{ {
"revision": "fe2e967e1efad7b13e67", "revision": "4f95d958d90a2ac1b9a0",
"url": "/static/css/app.534b9c5c.css" "url": "/static/css/app.e1e7e23c.css"
}, },
{ {
"revision": "82c1118c918377daaa71a320ab8eea42", "revision": "82c1118c918377daaa71a320ab8eea42",
@ -92,24 +92,24 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/leaflet.css" "url": "/static/css/leaflet.css"
}, },
{ {
"revision": "d3cf46cfc6340753d540", "revision": "00c35b353719122c16cd",
"url": "/static/css/main.c790adb1.css" "url": "/static/css/main.7229c1ab.css"
}, },
{ {
"revision": "68364924c988a1f11b42", "revision": "11b770a11a1cd8dae5f4",
"url": "/static/css/main~workouts.66c5ef05.css" "url": "/static/css/main~workouts.0edb3403.css"
}, },
{ {
"revision": "3438ac3f32223591afd9", "revision": "058a877bc4b9cbf8929f",
"url": "/static/css/profile.b52bc193.css" "url": "/static/css/profile.05400f70.css"
}, },
{ {
"revision": "688d813785d3c55a7d33", "revision": "8635e7636aa413afd289",
"url": "/static/css/reset.bd9657a8.css" "url": "/static/css/reset.46776e72.css"
}, },
{ {
"revision": "5e13fc66c78986a630a0", "revision": "c78ff76a4bb0919c4b94",
"url": "/static/css/workouts.d0767062.css" "url": "/static/css/workouts.1b0a7916.css"
}, },
{ {
"revision": "e719f9244c69e28e7d00e725ca1e280e", "revision": "e719f9244c69e28e7d00e725ca1e280e",
@ -192,12 +192,12 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/img/pt-sans-v9-latin-regular.f1f73e45.svg" "url": "/static/img/pt-sans-v9-latin-regular.f1f73e45.svg"
}, },
{ {
"revision": "ca5244fc3bcfc65816b9", "revision": "33002d1c4452ecd02e50",
"url": "/static/js/admin.2f1d393d.js" "url": "/static/js/admin.2f1d393d.js"
}, },
{ {
"revision": "fe2e967e1efad7b13e67", "revision": "4f95d958d90a2ac1b9a0",
"url": "/static/js/app.9ada5ac5.js" "url": "/static/js/app.0f3b3ab5.js"
}, },
{ {
"revision": "bd7d183c9f68e5f4027d", "revision": "bd7d183c9f68e5f4027d",
@ -224,23 +224,23 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/chunk-vendors.71654064.js" "url": "/static/js/chunk-vendors.71654064.js"
}, },
{ {
"revision": "d3cf46cfc6340753d540", "revision": "00c35b353719122c16cd",
"url": "/static/js/main.e5da50b8.js" "url": "/static/js/main.db9cee98.js"
}, },
{ {
"revision": "68364924c988a1f11b42", "revision": "11b770a11a1cd8dae5f4",
"url": "/static/js/main~workouts.a74990d7.js" "url": "/static/js/main~workouts.a74990d7.js"
}, },
{ {
"revision": "3438ac3f32223591afd9", "revision": "058a877bc4b9cbf8929f",
"url": "/static/js/profile.6a786c1d.js" "url": "/static/js/profile.62578012.js"
}, },
{ {
"revision": "688d813785d3c55a7d33", "revision": "8635e7636aa413afd289",
"url": "/static/js/reset.518e646f.js" "url": "/static/js/reset.518e646f.js"
}, },
{ {
"revision": "5e13fc66c78986a630a0", "revision": "c78ff76a4bb0919c4b94",
"url": "/static/js/workouts.1c22fd12.js" "url": "/static/js/workouts.d69cf48a.js"
} }
]); ]);

View File

@ -14,7 +14,7 @@
importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js"); importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js");
importScripts( importScripts(
"/precache-manifest.200309f56fa439f700e940d169066203.js" "/precache-manifest.c1f31e9729586ecf3c442890704f31cc.js"
); );
workbox.core.setCacheNameDetails({prefix: "fittrackee_client"}); workbox.core.setCacheNameDetails({prefix: "fittrackee_client"});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["profile"],{"0bb3":function(e,t,n){},"36e8":function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("dad5"),o=n("2906"),u=function(e){return Object(c["pushScopeId"])("data-v-d342b648"),e=e(),Object(c["popScopeId"])(),e},a={key:0,id:"profile",class:"container view"},s=u((function(){return Object(c["createElementVNode"])("div",{id:"bottom"},null,-1)})),d=Object(c["defineComponent"])({setup:function(e){var t=Object(o["a"])(),n=Object(c["computed"])((function(){return t.getters[r["a"].GETTERS.AUTH_USER_PROFILE]}));return function(e,t){var r=Object(c["resolveComponent"])("router-view");return Object(c["unref"])(n).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",a,[Object(c["createVNode"])(r,{user:Object(c["unref"])(n)},null,8,["user"]),s])):Object(c["createCommentVNode"])("",!0)}}}),b=(n("6171"),n("6b0d")),i=n.n(b);const f=i()(d,[["__scopeId","data-v-d342b648"]]);t["default"]=f},6171:function(e,t,n){"use strict";n("0bb3")},"9b98":function(e,t,n){"use strict";n("d332")},ad3d:function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("6c02"),o=n("3c44"),u=n("71a7"),a=n("dad5"),s=n("2906"),d={key:0,id:"user",class:"view"},b={class:"box"},i=Object(c["defineComponent"])({setup:function(e){var t=Object(r["c"])(),n=Object(s["a"])(),i=Object(c["computed"])((function(){return n.getters[a["e"].GETTERS.USER]}));return Object(c["onBeforeMount"])((function(){t.params.username&&"string"===typeof t.params.username&&n.dispatch(a["e"].ACTIONS.GET_USER,t.params.username)})),Object(c["onBeforeUnmount"])((function(){n.dispatch(a["e"].ACTIONS.EMPTY_USER)})),function(e,t){return Object(c["unref"])(i).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",d,[Object(c["createVNode"])(o["a"],{user:Object(c["unref"])(i)},null,8,["user"]),Object(c["createElementVNode"])("div",b,[Object(c["createVNode"])(u["a"],{user:Object(c["unref"])(i),"from-admin":!0},null,8,["user"])])])):Object(c["createCommentVNode"])("",!0)}}}),f=(n("9b98"),n("6b0d")),O=n.n(f);const p=O()(i,[["__scopeId","data-v-218f8f1e"]]);t["default"]=p},d332:function(e,t,n){}}]);
//# sourceMappingURL=profile.62578012.js.map

File diff suppressed because one or more lines are too long

View File

@ -1,2 +0,0 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["profile"],{"36e8":function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("dad5"),o=n("2906"),u={key:0,id:"profile",class:"container view"},a=Object(c["defineComponent"])({setup:function(e){var t=Object(o["a"])(),n=Object(c["computed"])((function(){return t.getters[r["a"].GETTERS.AUTH_USER_PROFILE]}));return function(e,t){var r=Object(c["resolveComponent"])("router-view");return Object(c["unref"])(n).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",u,[Object(c["createVNode"])(r,{user:Object(c["unref"])(n)},null,8,["user"])])):Object(c["createCommentVNode"])("",!0)}}}),s=(n("44ab"),n("6b0d")),b=n.n(s);const i=b()(a,[["__scopeId","data-v-bb090bfa"]]);t["default"]=i},"44ab":function(e,t,n){"use strict";n("4fe6")},"4fe6":function(e,t,n){},"9b98":function(e,t,n){"use strict";n("d332")},ad3d:function(e,t,n){"use strict";n.r(t);var c=n("7a23"),r=n("6c02"),o=n("3c44"),u=n("71a7"),a=n("dad5"),s=n("2906"),b={key:0,id:"user",class:"view"},i={class:"box"},d=Object(c["defineComponent"])({setup:function(e){var t=Object(r["c"])(),n=Object(s["a"])(),d=Object(c["computed"])((function(){return n.getters[a["e"].GETTERS.USER]}));return Object(c["onBeforeMount"])((function(){t.params.username&&"string"===typeof t.params.username&&n.dispatch(a["e"].ACTIONS.GET_USER,t.params.username)})),Object(c["onBeforeUnmount"])((function(){n.dispatch(a["e"].ACTIONS.EMPTY_USER)})),function(e,t){return Object(c["unref"])(d).username?(Object(c["openBlock"])(),Object(c["createElementBlock"])("div",b,[Object(c["createVNode"])(o["a"],{user:Object(c["unref"])(d)},null,8,["user"]),Object(c["createElementVNode"])("div",i,[Object(c["createVNode"])(u["a"],{user:Object(c["unref"])(d),"from-admin":!0},null,8,["user"])])])):Object(c["createCommentVNode"])("",!0)}}}),f=(n("9b98"),n("6b0d")),O=n.n(f);const j=O()(d,[["__scopeId","data-v-218f8f1e"]]);t["default"]=j},d332:function(e,t,n){}}]);
//# sourceMappingURL=profile.6a786c1d.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,76 @@
"""add sport preferences
Revision ID: 080acc8ee956
Revises: 9842464bb885
Create Date: 2021-11-12 10:20:23.786727
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '080acc8ee956'
down_revision = '9842464bb885'
branch_labels = None
depends_on = None
def upgrade():
op.drop_constraint('sports_img_key', 'sports', type_='unique')
op.drop_column('sports', 'img')
op.create_table(
'users_sports_preferences',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('sport_id', sa.Integer(), nullable=False),
sa.Column('color', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column(
'stopped_speed_threshold',
sa.Float(),
nullable=False,
),
sa.ForeignKeyConstraint(
['sport_id'],
['sports.id'],
),
sa.ForeignKeyConstraint(
['user_id'],
['users.id'],
),
sa.PrimaryKeyConstraint('user_id', 'sport_id'),
)
def downgrade():
op.drop_table('users_sports_preferences')
op.add_column(
'sports',
sa.Column(
'img', sa.VARCHAR(length=255), autoincrement=False, nullable=True
),
)
op.create_unique_constraint('sports_img_key', 'sports', ['img'])
op.execute(
"""
UPDATE sports AS s
SET img = si.img
FROM (VALUES
('Cycling (Sport)','/img/sports/cycling-sport.png'),
('Cycling (Transport)','/img/sports/cycling-transport.png'),
('Hiking','/img/sports/hiking.png'),
('Mountain Biking','/img/sports/mountain-biking.png'),
('Running','/img/sports/running.png'),
('Walking','/img/sports/walking.png'),
('Mountain Biking (Electric)','/img/sports/electric-mountain-biking.png'),
('Trail','/img/sports/trail.png'),
('Skiing (Alpine)','/img/sports/alpine-skiing.png'),
('Skiing (Cross Country)','/img/sports/cross-country-skiing.png'),
('Rowing','/img/sports/rowing.png')
) AS si(label, img)
WHERE si.label = s.label;
"""
)

View File

@ -3,7 +3,8 @@ import datetime
import pytest import pytest
from fittrackee import db from fittrackee import db
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport
@pytest.fixture() @pytest.fixture()
@ -81,3 +82,31 @@ def user_3() -> User:
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
return user return user
@pytest.fixture()
def user_sport_1_preference(
user_1: User, sport_1_cycling: Sport
) -> UserSportPreference:
user_sport = UserSportPreference(
user_id=user_1.id,
sport_id=sport_1_cycling.id,
stopped_speed_threshold=sport_1_cycling.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.commit()
return user_sport
@pytest.fixture()
def user_admin_sport_1_preference(
user_1_admin: User, sport_1_cycling: Sport
) -> UserSportPreference:
user_sport = UserSportPreference(
user_id=user_1_admin.id,
sport_id=sport_1_cycling.id,
stopped_speed_threshold=sport_1_cycling.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.commit()
return user_sport

View File

@ -830,6 +830,186 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
assert 'error' in data['status'] assert 'error' in data['status']
class TestUserSportPreferencesUpdate(ApiTestCaseMixin):
def test_it_returns_error_if_payload_is_empty(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'invalid payload' in data['message']
assert 'error' in data['status']
def test_it_returns_error_if_sport_id_is_missing(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid payload'
assert response.status_code == 400
def test_it_returns_error_if_sport_not_found(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(sport_id=1, is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 404
assert 'not found' in data['status']
def test_it_returns_error_if_payload_contains_only_sport_id(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(dict(sport_id=1)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid payload'
assert response.status_code == 400
def test_it_returns_error_if_color_is_invalid(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
color='invalid',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'invalid hexadecimal color'
assert response.status_code == 400
@pytest.mark.parametrize(
'input_color',
['#000000', '#FFF'],
)
def test_it_updates_sport_color_for_auth_user(
self,
app: Flask,
user_1: User,
sport_2_running: Sport,
input_color: str,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_2_running.id,
color=input_color,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_2_running.id
assert data['data']['color'] == input_color
assert data['data']['is_active'] is True
assert data['data']['stopped_speed_threshold'] == 0.1
def test_it_disables_sport_for_auth_user(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
is_active=False,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_1_cycling.id
assert data['data']['color'] is None
assert data['data']['is_active'] is False
assert data['data']['stopped_speed_threshold'] == 1
def test_it_updates_stopped_speed_threshold_for_auth_user(
self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/sports',
content_type='application/json',
data=json.dumps(
dict(
sport_id=sport_1_cycling.id,
stopped_speed_threshold=0.5,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'user sport preferences updated'
assert response.status_code == 200
assert data['data']['user_id'] == user_1.id
assert data['data']['sport_id'] == sport_1_cycling.id
assert data['data']['color'] is None
assert data['data']['is_active']
assert data['data']['stopped_speed_threshold'] == 0.5
class TestUserPicture(ApiTestCaseMixin): class TestUserPicture(ApiTestCaseMixin):
def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None: def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None:
client, auth_token = self.get_test_client_and_auth_token(app) client, auth_token = self.get_test_client_and_auth_token(app)

View File

@ -5,7 +5,7 @@ from unittest.mock import patch
from flask import Flask from flask import Flask
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.models import Sport, Workout
from ..api_test_case import ApiTestCaseMixin from ..api_test_case import ApiTestCaseMixin
@ -944,6 +944,22 @@ class TestDeleteUser(ApiTestCaseMixin):
assert response.status_code == 204 assert response.status_code == 204
def test_user_with_preferences_can_delete_its_own_account(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.delete(
'/api/users/test',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
assert response.status_code == 204
def test_user_with_picture_can_delete_its_own_account( def test_user_with_picture_can_delete_its_own_account(
self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str self, app: Flask, user_1: User, sport_1_cycling: Sport, gpx_file: str
) -> None: ) -> None:

View File

@ -1,6 +1,6 @@
from flask import Flask from flask import Flask
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.models import Sport, Workout
@ -59,3 +59,19 @@ class TestUserModel:
== workout_cycling_user_1.short_id == workout_cycling_user_1.short_id
) )
assert serialized_user['records'][0]['workout_date'] assert serialized_user['records'][0]['workout_date']
class TestUserSportModel:
def test_user_model(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
serialized_user_sport = user_sport_1_preference.serialize()
assert serialized_user_sport['user_id'] == user_1.id
assert serialized_user_sport['sport_id'] == sport_1_cycling.id
assert serialized_user_sport['color'] is None
assert serialized_user_sport['is_active']
assert serialized_user_sport['stopped_speed_threshold'] == 1

View File

@ -5,7 +5,7 @@ from flask import Flask
from gpxpy.gpx import MovingData from gpxpy.gpx import MovingData
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport from fittrackee.workouts.models import Sport
from fittrackee.workouts.utils import process_files from fittrackee.workouts.utils import process_files
@ -56,3 +56,33 @@ class TestStoppedSpeedThreshold:
stopped_speed_threshold=expected_threshold stopped_speed_threshold=expected_threshold
) )
gpx_track_segment_mock.assert_called_with(expected_threshold) gpx_track_segment_mock.assert_called_with(expected_threshold)
def test_it_calls_get_moving_data_with_threshold_depending_from_user_preference( # noqa
self,
app: Flask,
user_1: User,
gpx_file_storage: FileStorage,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
expected_threshold = 0.7
user_sport_1_preference.stopped_speed_threshold = expected_threshold
with patch(
'fittrackee.workouts.utils.get_new_file_path',
return_value='/tmp/fitTrackee/uploads/test.png',
), patch(
'gpxpy.gpx.GPXTrackSegment.get_moving_data',
return_value=moving_data,
) as gpx_track_segment_mock:
process_files(
auth_user_id=user_1.id,
folders=folders,
workout_data={'sport_id': sport_1_cycling.id},
workout_file=gpx_file_storage,
)
assert gpx_track_segment_mock.call_args_list[0] == call(
stopped_speed_threshold=expected_threshold
)
gpx_track_segment_mock.assert_called_with(expected_threshold)

View File

@ -2,7 +2,8 @@ import json
from flask import Flask from flask import Flask
from fittrackee.users.models import User from fittrackee import db
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.models import Sport, Workout
from ..api_test_case import ApiTestCaseMixin from ..api_test_case import ApiTestCaseMixin
@ -10,8 +11,10 @@ from ..api_test_case import ApiTestCaseMixin
expected_sport_1_cycling_result = { expected_sport_1_cycling_result = {
'id': 1, 'id': 1,
'label': 'Cycling', 'label': 'Cycling',
'img': None,
'is_active': True, 'is_active': True,
'is_active_for_user': True,
'color': None,
'stopped_speed_threshold': 1,
} }
expected_sport_1_cycling_admin_result = expected_sport_1_cycling_result.copy() expected_sport_1_cycling_admin_result = expected_sport_1_cycling_result.copy()
expected_sport_1_cycling_admin_result['has_workouts'] = False expected_sport_1_cycling_admin_result['has_workouts'] = False
@ -19,8 +22,10 @@ expected_sport_1_cycling_admin_result['has_workouts'] = False
expected_sport_2_running_result = { expected_sport_2_running_result = {
'id': 2, 'id': 2,
'label': 'Running', 'label': 'Running',
'img': None,
'is_active': True, 'is_active': True,
'is_active_for_user': True,
'color': None,
'stopped_speed_threshold': 0.1,
} }
expected_sport_2_running_admin_result = expected_sport_2_running_result.copy() expected_sport_2_running_admin_result = expected_sport_2_running_result.copy()
expected_sport_2_running_admin_result['has_workouts'] = False expected_sport_2_running_admin_result['has_workouts'] = False
@ -28,8 +33,10 @@ expected_sport_2_running_admin_result['has_workouts'] = False
expected_sport_1_cycling_inactive_result = { expected_sport_1_cycling_inactive_result = {
'id': 1, 'id': 1,
'label': 'Cycling', 'label': 'Cycling',
'img': None,
'is_active': False, 'is_active': False,
'is_active_for_user': False,
'color': None,
'stopped_speed_threshold': 1,
} }
expected_sport_1_cycling_inactive_admin_result = ( expected_sport_1_cycling_inactive_admin_result = (
expected_sport_1_cycling_inactive_result.copy() expected_sport_1_cycling_inactive_result.copy()
@ -111,6 +118,39 @@ class TestGetSports(ApiTestCaseMixin):
data['data']['sports'][1] == expected_sport_2_running_admin_result data['data']['sports'][1] == expected_sport_2_running_admin_result
) )
def test_it_gets_sports_with_auth_user_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
sport_2_running: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
user_admin_sport_1_preference.color = '#000000'
user_admin_sport_1_preference.stopped_speed_threshold = 0.5
user_admin_sport_1_preference.is_active = False
db.session.commit()
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.get(
'/api/sports',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 2
assert data['data']['sports'][0]['color'] == '#000000'
assert data['data']['sports'][0]['stopped_speed_threshold'] == 0.5
assert data['data']['sports'][0]['is_active_for_user'] is False
assert (
data['data']['sports'][1] == expected_sport_2_running_admin_result
)
class TestGetSport(ApiTestCaseMixin): class TestGetSport(ApiTestCaseMixin):
def test_it_gets_a_sport( def test_it_gets_a_sport(
@ -129,6 +169,26 @@ class TestGetSport(ApiTestCaseMixin):
assert len(data['data']['sports']) == 1 assert len(data['data']['sports']) == 1
assert data['data']['sports'][0] == expected_sport_1_cycling_result assert data['data']['sports'][0] == expected_sport_1_cycling_result
def test_it_gets_a_sport_with_preferences(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.get(
'/api/sports/1',
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0] == expected_sport_1_cycling_result
def test_it_returns_404_if_sport_does_not_exist( def test_it_returns_404_if_sport_does_not_exist(
self, app: Flask, user_1: User self, app: Flask, user_1: User
) -> None: ) -> None:
@ -205,6 +265,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status'] assert 'success' in data['status']
assert len(data['data']['sports']) == 1 assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is False assert data['data']['sports'][0]['has_workouts'] is False
def test_it_enables_a_sport( def test_it_enables_a_sport(
@ -227,6 +288,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status'] assert 'success' in data['status']
assert len(data['data']['sports']) == 1 assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is False assert data['data']['sports'][0]['has_workouts'] is False
def test_it_disables_a_sport_with_workouts( def test_it_disables_a_sport_with_workouts(
@ -252,6 +314,7 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status'] assert 'success' in data['status']
assert len(data['data']['sports']) == 1 assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is True assert data['data']['sports'][0]['has_workouts'] is True
def test_it_enables_a_sport_with_workouts( def test_it_enables_a_sport_with_workouts(
@ -278,8 +341,63 @@ class TestUpdateSport(ApiTestCaseMixin):
assert 'success' in data['status'] assert 'success' in data['status']
assert len(data['data']['sports']) == 1 assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is True assert data['data']['sports'][0]['has_workouts'] is True
def test_it_disables_a_sport_with_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=False)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['is_active_for_user'] is False
assert data['data']['sports'][0]['has_workouts'] is False
def test_it_enables_a_sport_with_preferences(
self,
app: Flask,
user_1_admin: User,
sport_1_cycling: Sport,
user_admin_sport_1_preference: UserSportPreference,
) -> None:
sport_1_cycling.is_active = False
client, auth_token = self.get_test_client_and_auth_token(
app, as_admin=True
)
response = client.patch(
'/api/sports/1',
content_type='application/json',
data=json.dumps(dict(is_active=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 200
assert 'success' in data['status']
assert len(data['data']['sports']) == 1
assert data['data']['sports'][0]['is_active'] is True
assert data['data']['sports'][0]['is_active_for_user'] is True
assert data['data']['sports'][0]['has_workouts'] is False
def test_returns_error_if_user_has_no_admin_rights( def test_returns_error_if_user_has_no_admin_rights(
self, app: Flask, user_1: User, sport_1_cycling: Sport self, app: Flask, user_1: User, sport_1_cycling: Sport
) -> None: ) -> None:

View File

@ -2,7 +2,8 @@ from typing import Dict, Optional
from flask import Flask from flask import Flask
from fittrackee.users.models import User from fittrackee import db
from fittrackee.users.models import User, UserSportPreference
from fittrackee.workouts.models import Sport, Workout from fittrackee.workouts.models import Sport, Workout
@ -15,10 +16,13 @@ class TestSportModel:
assert 'Cycling' == sport.label assert 'Cycling' == sport.label
assert '<Sport \'Cycling\'>' == str(sport) assert '<Sport \'Cycling\'>' == str(sport)
serialized_sport = sport.serialize(is_admin) serialized_sport = sport.serialize(is_admin=is_admin)
assert 1 == serialized_sport['id'] assert 1 == serialized_sport['id']
assert 'Cycling' == serialized_sport['label'] assert 'Cycling' == serialized_sport['label']
assert serialized_sport['is_active'] is True assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
return serialized_sport return serialized_sport
def test_sport_model(self, app: Flask, sport_1_cycling: Sport) -> None: def test_sport_model(self, app: Flask, sport_1_cycling: Sport) -> None:
@ -44,3 +48,87 @@ class TestSportModel:
) -> None: ) -> None:
serialized_sport = self.assert_sport_model(sport_1_cycling, True) serialized_sport = self.assert_sport_model(sport_1_cycling, True)
assert serialized_sport['has_workouts'] is True assert serialized_sport['has_workouts'] is True
class TestSportModelWithPreferences:
def test_sport_model_with_color_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.color = '#00000'
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] == '#00000'
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_sport_model_with_is_active_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.is_active = False
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is False
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_inactive_sport_model_with_is_active_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
sport_1_cycling.is_active = False
user_sport_1_preference.is_active = True
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is False
assert serialized_sport['is_active_for_user'] is False
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 1
assert 'has_workouts' not in serialized_sport
def test_sport_model_with_threshold_preference(
self,
app: Flask,
user_1: User,
sport_1_cycling: Sport,
user_sport_1_preference: UserSportPreference,
) -> None:
user_sport_1_preference.stopped_speed_threshold = 0.5
db.session.commit()
serialized_sport = sport_1_cycling.serialize(
sport_preferences=user_sport_1_preference.serialize()
)
assert serialized_sport['id'] == 1
assert serialized_sport['label'] == 'Cycling'
assert serialized_sport['is_active'] is True
assert serialized_sport['is_active_for_user'] is True
assert serialized_sport['color'] is None
assert serialized_sport['stopped_speed_threshold'] == 0.5
assert 'has_workouts' not in serialized_sport

View File

@ -1,5 +1,6 @@
import datetime import datetime
import os import os
import re
from typing import Dict, Tuple, Union from typing import Dict, Tuple, Union
import jwt import jwt
@ -10,6 +11,7 @@ from werkzeug.utils import secure_filename
from fittrackee import appLog, bcrypt, db from fittrackee import appLog, bcrypt, db
from fittrackee.responses import ( from fittrackee.responses import (
DataNotFoundErrorResponse,
ForbiddenErrorResponse, ForbiddenErrorResponse,
HttpResponse, HttpResponse,
InvalidPayloadErrorResponse, InvalidPayloadErrorResponse,
@ -19,15 +21,18 @@ from fittrackee.responses import (
) )
from fittrackee.tasks import reset_password_email from fittrackee.tasks import reset_password_email
from fittrackee.utils import get_readable_duration, verify_extension_and_size from fittrackee.utils import get_readable_duration, verify_extension_and_size
from fittrackee.workouts.models import Sport
from fittrackee.workouts.utils_files import get_absolute_file_path from fittrackee.workouts.utils_files import get_absolute_file_path
from .decorators import authenticate from .decorators import authenticate
from .models import User from .models import User, UserSportPreference
from .utils import check_passwords, register_controls from .utils import check_passwords, register_controls
from .utils_token import decode_user_token from .utils_token import decode_user_token
auth_blueprint = Blueprint('auth', __name__) auth_blueprint = Blueprint('auth', __name__)
HEX_COLOR_REGEX = regex = "^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$"
@auth_blueprint.route('/auth/register', methods=['POST']) @auth_blueprint.route('/auth/register', methods=['POST'])
def register_user() -> Union[Tuple[Dict, int], HttpResponse]: def register_user() -> Union[Tuple[Dict, int], HttpResponse]:
@ -677,6 +682,108 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
return handle_error_and_return_response(e, db=db) return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/sports', methods=['POST'])
@authenticate
def edit_user_sport_preferences(
auth_user_id: int,
) -> Union[Dict, HttpResponse]:
"""
edit authenticated user sport preferences
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit/sports HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"color": "#000000",
"is_active": true,
"sport_id": 1,
"stopped_speed_threshold": 1,
"user_id": 1
},
"message": "user sport preferences updated",
"status": "success"
}
:<json string color: valid hexadecimal color
:<json boolean is_active: is sport available when adding a workout
:<json float stopped_speed_threshold: stopped speed threshold used by gpxpy
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: user preferences updated
:statuscode 400:
- invalid payload
- invalid hexadecimal color
:statuscode 401:
- provide a valid auth token
- signature expired, please log in again
- invalid token, please log in again
:statuscode 500: error, please try again or contact the administrator
"""
post_data = request.get_json()
if (
not post_data
or 'sport_id' not in post_data
or len(post_data.keys()) == 1
):
return InvalidPayloadErrorResponse()
sport_id = post_data.get('sport_id')
sport = Sport.query.filter_by(id=sport_id).first()
if not sport:
return DataNotFoundErrorResponse('sports')
color = post_data.get('color')
is_active = post_data.get('is_active')
stopped_speed_threshold = post_data.get('stopped_speed_threshold')
try:
user_sport = UserSportPreference.query.filter_by(
user_id=auth_user_id,
sport_id=sport_id,
).first()
if not user_sport:
user_sport = UserSportPreference(
user_id=auth_user_id,
sport_id=sport_id,
stopped_speed_threshold=sport.stopped_speed_threshold,
)
db.session.add(user_sport)
db.session.flush()
if color:
if re.match(HEX_COLOR_REGEX, color) is None:
return InvalidPayloadErrorResponse('invalid hexadecimal color')
user_sport.color = color
if is_active is not None:
user_sport.is_active = is_active
if stopped_speed_threshold:
user_sport.stopped_speed_threshold = stopped_speed_threshold
db.session.commit()
return {
'status': 'success',
'message': 'user sport preferences updated',
'data': user_sport.serialize(),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/picture', methods=['POST']) @auth_blueprint.route('/auth/picture', methods=['POST'])
@authenticate @authenticate
def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]: def edit_picture(auth_user_id: int) -> Union[Dict, HttpResponse]:

View File

@ -143,3 +143,41 @@ class User(BaseModel):
'total_distance': float(total[0]), 'total_distance': float(total[0]),
'total_duration': str(total[1]), 'total_duration': str(total[1]),
} }
class UserSportPreference(BaseModel):
__tablename__ = 'users_sports_preferences'
user_id = db.Column(
db.Integer,
db.ForeignKey('users.id'),
primary_key=True,
)
sport_id = db.Column(
db.Integer,
db.ForeignKey('sports.id'),
primary_key=True,
)
color = db.Column(db.String(50), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False)
def __init__(
self,
user_id: int,
sport_id: int,
stopped_speed_threshold: float,
) -> None:
self.user_id = user_id
self.sport_id = sport_id
self.is_active = True
self.stopped_speed_threshold = stopped_speed_threshold
def serialize(self) -> Dict:
return {
'user_id': self.user_id,
'sport_id': self.sport_id,
'color': self.color,
'is_active': self.is_active,
'stopped_speed_threshold': self.stopped_speed_threshold,
}

View File

@ -14,10 +14,11 @@ from fittrackee.responses import (
UserNotFoundErrorResponse, UserNotFoundErrorResponse,
handle_error_and_return_response, handle_error_and_return_response,
) )
from fittrackee.workouts.models import Record, Workout, WorkoutSegment
from fittrackee.workouts.utils_files import get_absolute_file_path from fittrackee.workouts.utils_files import get_absolute_file_path
from .decorators import authenticate, authenticate_as_admin from .decorators import authenticate, authenticate_as_admin
from .models import User, Workout from .models import User, UserSportPreference
users_blueprint = Blueprint('users', __name__) users_blueprint = Blueprint('users', __name__)
@ -555,9 +556,15 @@ def delete_user(
'no other user has admin rights' 'no other user has admin rights'
) )
for workout in Workout.query.filter_by(user_id=user.id).all(): db.session.query(UserSportPreference).filter(
db.session.delete(workout) UserSportPreference.user_id == user.id
db.session.flush() ).delete()
db.session.query(Record).filter(Record.user_id == user.id).delete()
db.session.query(WorkoutSegment).filter(
WorkoutSegment.workout_id == Workout.id, Workout.user_id == user.id
).delete(synchronize_session=False)
db.session.query(Workout).filter(Workout.user_id == user.id).delete()
db.session.flush()
user_picture = user.picture user_picture = user.picture
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()

View File

@ -70,7 +70,6 @@ class Sport(BaseModel):
__tablename__ = 'sports' __tablename__ = 'sports'
id = db.Column(db.Integer, primary_key=True, autoincrement=True) id = db.Column(db.Integer, primary_key=True, autoincrement=True)
label = db.Column(db.String(50), unique=True, nullable=False) label = db.Column(db.String(50), unique=True, nullable=False)
img = db.Column(db.String(255), unique=True, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False) is_active = db.Column(db.Boolean, default=True, nullable=False)
stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False) stopped_speed_threshold = db.Column(db.Float, default=1.0, nullable=False)
workouts = db.relationship( workouts = db.relationship(
@ -86,12 +85,30 @@ class Sport(BaseModel):
def __init__(self, label: str) -> None: def __init__(self, label: str) -> None:
self.label = label self.label = label
def serialize(self, is_admin: Optional[bool] = False) -> Dict: def serialize(
self,
is_admin: Optional[bool] = False,
sport_preferences: Optional[Dict] = None,
) -> Dict:
serialized_sport = { serialized_sport = {
'id': self.id, 'id': self.id,
'label': self.label, 'label': self.label,
'img': self.img,
'is_active': self.is_active, 'is_active': self.is_active,
'is_active_for_user': (
self.is_active
if sport_preferences is None
else (sport_preferences['is_active'] and self.is_active)
),
'color': (
None
if sport_preferences is None
else sport_preferences['color']
),
'stopped_speed_threshold': (
self.stopped_speed_threshold
if sport_preferences is None
else sport_preferences['stopped_speed_threshold']
),
} }
if is_admin: if is_admin:
serialized_sport['has_workouts'] = len(self.workouts) > 0 serialized_sport['has_workouts'] = len(self.workouts) > 0

View File

@ -11,7 +11,7 @@ from fittrackee.responses import (
handle_error_and_return_response, handle_error_and_return_response,
) )
from fittrackee.users.decorators import authenticate, authenticate_as_admin from fittrackee.users.decorators import authenticate, authenticate_as_admin
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from .models import Sport from .models import Sport
@ -44,40 +44,52 @@ def get_sports(auth_user_id: int) -> Dict:
"data": { "data": {
"sports": [ "sports": [
{ {
"color": null,
"id": 1, "id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Sport)" "is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"id": 2, "id": 2,
"img": "/img/sports/cycling-transport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Transport)" "is_active_for_user": true,
"label": "Cycling (Transport)",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"id": 3, "id": 3,
"img": "/img/sports/hiking.png",
"is_active": true, "is_active": true,
"label": "Hiking" "is_active_for_user": true,
"label": "Hiking",
"stopped_speed_threshold": 0.1
}, },
{ {
"color": null,
"id": 4, "id": 4,
"img": "/img/sports/mountain-biking.png",
"is_active": true, "is_active": true,
"label": "Mountain Biking" "is_active_for_user": true,
"label": "Mountain Biking",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"id": 5, "id": 5,
"img": "/img/sports/running.png",
"is_active": true, "is_active": true,
"label": "Running" "is_active_for_user": true,
"label": "Running",
"stopped_speed_threshold": 0.1
}, },
{ {
"color": null,
"id": 6, "id": 6,
"img": "/img/sports/walking.png",
"is_active": true, "is_active": true,
"label": "Walking" "is_active_for_user": true,
"label": "Walking",
"stopped_speed_threshold": 0.1
} }
] ]
}, },
@ -95,46 +107,58 @@ def get_sports(auth_user_id: int) -> Dict:
"data": { "data": {
"sports": [ "sports": [
{ {
"color": null,
"has_workouts": true, "has_workouts": true,
"id": 1, "id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Sport)" "is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 2, "id": 2,
"img": "/img/sports/cycling-transport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Transport)" "is_active_for_user": true,
"label": "Cycling (Transport)",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 3, "id": 3,
"img": "/img/sports/hiking.png",
"is_active": true, "is_active": true,
"label": "Hiking" "is_active_for_user": true,
"label": "Hiking",
"stopped_speed_threshold": 0.1
}, },
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 4, "id": 4,
"img": "/img/sports/mountain-biking.png",
"is_active": true, "is_active": true,
"label": "Mountain Biking" "is_active_for_user": true,
"label": "Mountain Biking",
"stopped_speed_threshold": 1
}, },
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 5, "id": 5,
"img": "/img/sports/running.png",
"is_active": true, "is_active": true,
"label": "Running" "is_active_for_user": true,
"label": "Running",
"stopped_speed_threshold": 0.1
}, },
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 6, "id": 6,
"img": "/img/sports/walking.png",
"is_active": true, "is_active": true,
"label": "Walking" "is_active_for_user": true,
"label": "Walking",
"stopped_speed_threshold": 0.1
} }
] ]
}, },
@ -154,9 +178,22 @@ def get_sports(auth_user_id: int) -> Dict:
""" """
user = User.query.filter_by(id=int(auth_user_id)).first() user = User.query.filter_by(id=int(auth_user_id)).first()
sports = Sport.query.order_by(Sport.id).all() sports = Sport.query.order_by(Sport.id).all()
sports_data = []
for sport in sports:
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
sports_data.append(
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
)
return { return {
'status': 'success', 'status': 'success',
'data': {'sports': [sport.serialize(user.admin) for sport in sports]}, 'data': {'sports': sports_data},
} }
@ -186,10 +223,12 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
"data": { "data": {
"sports": [ "sports": [
{ {
"color": null,
"id": 1, "id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Sport)" "is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
} }
] ]
}, },
@ -207,11 +246,13 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
"data": { "data": {
"sports": [ "sports": [
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 1, "id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": true, "is_active": true,
"label": "Cycling (Sport)" "is_active_for_user": true,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
} }
] ]
}, },
@ -248,9 +289,21 @@ def get_sport(auth_user_id: int, sport_id: int) -> Union[Dict, HttpResponse]:
user = User.query.filter_by(id=int(auth_user_id)).first() user = User.query.filter_by(id=int(auth_user_id)).first()
sport = Sport.query.filter_by(id=sport_id).first() sport = Sport.query.filter_by(id=sport_id).first()
if sport: if sport:
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
return { return {
'status': 'success', 'status': 'success',
'data': {'sports': [sport.serialize(user.admin)]}, 'data': {
'sports': [
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
]
},
} }
return DataNotFoundErrorResponse('sports') return DataNotFoundErrorResponse('sports')
@ -284,11 +337,13 @@ def update_sport(
"data": { "data": {
"sports": [ "sports": [
{ {
"color": null,
"has_workouts": false, "has_workouts": false,
"id": 1, "id": 1,
"img": "/img/sports/cycling-sport.png",
"is_active": false, "is_active": false,
"label": "Cycling (Sport)" "is_active_for_user": false,
"label": "Cycling (Sport)",
"stopped_speed_threshold": 1
} }
] ]
}, },
@ -332,15 +387,28 @@ def update_sport(
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()
try: try:
user = User.query.filter_by(id=int(auth_user_id)).first()
sport = Sport.query.filter_by(id=sport_id).first() sport = Sport.query.filter_by(id=sport_id).first()
if not sport: if not sport:
return DataNotFoundErrorResponse('sports') return DataNotFoundErrorResponse('sports')
sport.is_active = sport_data.get('is_active') sport.is_active = sport_data.get('is_active')
db.session.commit() db.session.commit()
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
return { return {
'status': 'success', 'status': 'success',
'data': {'sports': [sport.serialize(True)]}, 'data': {
'sports': [
sport.serialize(
is_admin=user.admin,
sport_preferences=sport_preferences.serialize()
if sport_preferences
else None,
)
]
},
} }
except (exc.IntegrityError, exc.OperationalError, ValueError) as e: except (exc.IntegrityError, exc.OperationalError, ValueError) as e:

View File

@ -15,7 +15,7 @@ from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from fittrackee import db from fittrackee import db
from fittrackee.users.models import User from fittrackee.users.models import User, UserSportPreference
from .exceptions import WorkoutException from .exceptions import WorkoutException
from .models import Sport, Workout, WorkoutSegment from .models import Sport, Workout, WorkoutSegment
@ -400,6 +400,14 @@ def process_files(
f"Sport id: {workout_data.get('sport_id')} does not exist", f"Sport id: {workout_data.get('sport_id')} does not exist",
) )
user = User.query.filter_by(id=auth_user_id).first() user = User.query.filter_by(id=auth_user_id).first()
sport_preferences = UserSportPreference.query.filter_by(
user_id=user.id, sport_id=sport.id
).first()
stopped_speed_threshold = (
sport.stopped_speed_threshold
if sport_preferences is None
else sport_preferences.stopped_speed_threshold
)
common_params = { common_params = {
'user': user, 'user': user,
@ -418,14 +426,14 @@ def process_files(
process_one_gpx_file( process_one_gpx_file(
common_params, common_params,
filename, filename,
sport.stopped_speed_threshold, stopped_speed_threshold,
) )
] ]
else: else:
return process_zip_archive( return process_zip_archive(
common_params, common_params,
folders['extract_dir'], folders['extract_dir'],
sport.stopped_speed_threshold, stopped_speed_threshold,
) )

View File

@ -24,7 +24,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="sport in translatedSports" :key="sport.id"> <tr v-for="sport in translatedSports" :key="sport.id">
<td class="center-text"> <td class="text-center">
<span class="cell-heading">id</span> <span class="cell-heading">id</span>
{{ sport.id }} {{ sport.id }}
</td> </td>
@ -35,6 +35,7 @@
<SportImage <SportImage
:title="sport.translatedLabel" :title="sport.translatedLabel"
:sport-label="sport.label" :sport-label="sport.label"
:color="sport.color"
/> />
</td> </td>
<td class="sport-label"> <td class="sport-label">
@ -43,12 +44,12 @@
</span> </span>
{{ sport.translatedLabel }} {{ sport.translatedLabel }}
</td> </td>
<td class="center-text"> <td class="text-center">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('admin.SPORTS.TABLE.ACTIVE') }} {{ $t('admin.SPORTS.TABLE.ACTIVE') }}
</span> </span>
<i <i
:class="`fa fa${sport.is_active ? '-check' : ''}-square-o`" :class="`fa fa${sport.is_active ? '-check' : ''}`"
aria-hidden="true" aria-hidden="true"
/> />
</td> </td>
@ -127,9 +128,6 @@
font-style: italic; font-style: italic;
padding: 0 $default-padding; padding: 0 $default-padding;
} }
.text-left {
text-align: left;
}
.sport-action { .sport-action {
padding-left: $default-padding * 4; padding-left: $default-padding * 4;
} }

View File

@ -63,13 +63,13 @@
) )
}} }}
</td> </td>
<td class="center-text"> <td class="text-center">
<span class="cell-heading"> <span class="cell-heading">
{{ capitalize($t('workouts.WORKOUT', 0)) }} {{ capitalize($t('workouts.WORKOUT', 0)) }}
</span> </span>
{{ user.nb_workouts }} {{ user.nb_workouts }}
</td> </td>
<td class="center-text"> <td class="text-center">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('user.ADMIN') }} {{ $t('user.ADMIN') }}
</span> </span>
@ -78,7 +78,7 @@
aria-hidden="true" aria-hidden="true"
/> />
</td> </td>
<td class="center-text"> <td class="text-center">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('admin.ACTION') }} {{ $t('admin.ACTION') }}
</span> </span>

View File

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="sport-img" class="sport-img"
:style="{ fill: sportColors[sportLabel] }" :style="{ fill: color ? color : sportColors[sportLabel] }"
:title="title ? title : $t(`sports.${sportLabel}.LABEL`)" :title="title ? title : $t(`sports.${sportLabel}.LABEL`)"
> >
<CyclingSport v-if="sportLabel === 'Cycling (Sport)'" /> <CyclingSport v-if="sportLabel === 'Cycling (Sport)'" />
@ -37,12 +37,13 @@
interface Props { interface Props {
sportLabel: string sportLabel: string
color: string | null
title?: string title?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: '', title: '',
}) })
const { sportLabel, title } = toRefs(props) const { color, sportLabel, title } = toRefs(props)
const sportColors = inject('sportColors') const sportColors = inject('sportColors')
</script> </script>

View File

@ -5,7 +5,11 @@
$router.push({ name: 'Workout', params: { workoutId: workout.id } }) $router.push({ name: 'Workout', params: { workoutId: workout.id } })
" "
> >
<SportImage :sport-label="sportLabel" :title="workout.title" /> <SportImage
:sport-label="sportLabel"
:title="workout.title"
:color="sportColor"
/>
<sup> <sup>
<i <i
v-if="workout.records.length > 0" v-if="workout.records.length > 0"
@ -28,6 +32,7 @@
interface Props { interface Props {
workout: IWorkout workout: IWorkout
sportLabel: string sportLabel: string
sportColor: string | null
} }
const props = defineProps<Props>() const props = defineProps<Props>()

View File

@ -10,6 +10,7 @@
:key="index" :key="index"
:workout="workout" :workout="workout"
:sportLabel="getSportLabel(workout, sports)" :sportLabel="getSportLabel(workout, sports)"
:sportColor="getSportColor(workout, sports)"
/> />
</div> </div>
<div v-else class="donut-display"> <div v-else class="donut-display">
@ -41,7 +42,7 @@
import CalendarWorkoutsChart from '@/components/Dashboard/UserCalendar/CalendarWorkoutsChart.vue' import CalendarWorkoutsChart from '@/components/Dashboard/UserCalendar/CalendarWorkoutsChart.vue'
import { ISport } from '@/types/sports' import { ISport } from '@/types/sports'
import { IWorkout } from '@/types/workouts' import { IWorkout } from '@/types/workouts'
import { getSportLabel, sportIdColors } from '@/utils/sports' import { getSportColor, getSportLabel, sportIdColors } from '@/utils/sports'
import { getDonutDatasets } from '@/utils/workouts' import { getDonutDatasets } from '@/utils/workouts'
interface Props { interface Props {

View File

@ -2,7 +2,7 @@
<div class="records-card"> <div class="records-card">
<Card> <Card>
<template #title> <template #title>
<SportImage :sport-label="records.label" /> <SportImage :sport-label="records.label" :color="records.color" />
{{ sportTranslatedLabel }} {{ sportTranslatedLabel }}
</template> </template>
<template #content> <template #content>

View File

@ -4,7 +4,7 @@
v-for="sport in translatedSports" v-for="sport in translatedSports"
type="checkbox" type="checkbox"
:key="sport.id" :key="sport.id"
:style="{ color: sportColors[sport.label] }" :style="{ color: sport.color ? sport.color : sportColors[sport.label] }"
> >
<input <input
type="checkbox" type="checkbox"
@ -13,7 +13,7 @@
:checked="selectedSportIds.includes(sport.id)" :checked="selectedSportIds.includes(sport.id)"
@input="updateSelectedSportIds(sport.id)" @input="updateSelectedSportIds(sport.id)"
/> />
<SportImage :sport-label="sport.label" /> <SportImage :sport-label="sport.label" :color="sport.color" />
<span class="sport-label">{{ sport.translatedLabel }}</span> <span class="sport-label">{{ sport.translatedLabel }}</span>
</label> </label>
</div> </div>

View File

@ -47,7 +47,7 @@
import { format } from 'date-fns' import { format } from 'date-fns'
import { ComputedRef, Ref, computed, ref, toRefs, withDefaults } from 'vue' import { ComputedRef, Ref, computed, ref, toRefs, withDefaults } from 'vue'
import { AUTH_USER_STORE } from '@/store/constants' import { AUTH_USER_STORE, USERS_STORE } from '@/store/constants'
import { IUserProfile } from '@/types/user' import { IUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore' import { useStore } from '@/use/useStore'
@ -81,7 +81,7 @@
displayModal.value = value displayModal.value = value
} }
function deleteUserAccount(username: string) { function deleteUserAccount(username: string) {
store.dispatch(AUTH_USER_STORE.ACTIONS.DELETE_ACCOUNT, { username }) store.dispatch(USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT, { username })
} }
</script> </script>
@ -91,9 +91,5 @@
.user-bio { .user-bio {
white-space: pre-wrap; white-space: pre-wrap;
} }
.profile-buttons {
display: flex;
gap: $default-padding;
}
} }
</style> </style>

View File

@ -38,13 +38,3 @@
props.user.timezone ? props.user.timezone : 'Europe/Paris' props.user.timezone ? props.user.timezone : 'Europe/Paris'
) )
</script> </script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-preferences {
.profile-buttons {
display: flex;
gap: $default-padding;
}
}
</style>

View File

@ -22,7 +22,7 @@
const props = defineProps<Props>() const props = defineProps<Props>()
const { user, tab } = toRefs(props) const { user, tab } = toRefs(props)
const tabs = ['PROFILE', 'PREFERENCES'] const tabs = ['PROFILE', 'PREFERENCES', 'SPORTS']
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -34,7 +34,7 @@
const store = useStore() const store = useStore()
const { user, tab } = toRefs(props) const { user, tab } = toRefs(props)
const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES'] const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES', 'SPORTS']
const loading = computed( const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING] () => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
) )

View File

@ -38,7 +38,10 @@
case 'PICTURE': case 'PICTURE':
return '/profile/edit/picture' return '/profile/edit/picture'
case 'PREFERENCES': case 'PREFERENCES':
return `/profile${props.edition ? '/edit' : ''}/preferences` case 'SPORTS':
return `/profile${
props.edition ? '/edit' : ''
}/${tab.toLocaleLowerCase()}`
default: default:
case 'PROFILE': case 'PROFILE':
return `/profile${props.edition ? '/edit' : ''}` return `/profile${props.edition ? '/edit' : ''}`

View File

@ -0,0 +1,321 @@
<template>
<div id="user-sport-preferences">
<div class="responsive-table" v-if="sports.length > 0">
<div class="mobile-display">
<div v-if="isEdition" class="profile-buttons mobile-display">
<button
class="cancel"
@click.prevent="$router.push('/profile/sports')"
>
{{ $t('buttons.BACK') }}
</button>
</div>
<div v-else class="profile-buttons">
<button @click="$router.push('/profile/edit/sports')">
{{ $t('user.PROFILE.EDIT_SPORTS_PREFERENCES') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
</div>
</div>
<table>
<thead>
<tr>
<th>{{ $t('user.PROFILE.SPORT.COLOR') }}</th>
<th class="text-left">{{ $t('workouts.SPORT', 0) }}</th>
<th>{{ $t('workouts.WORKOUT', 0) }}</th>
<th>{{ $t('user.PROFILE.SPORT.IS_ACTIVE') }}</th>
<th>{{ $t('user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD') }}</th>
<th v-if="isEdition">{{ $t('user.PROFILE.SPORT.ACTION') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="sport in translatedSports" :key="sport.id">
<td>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.COLOR') }}
</span>
<input
v-if="isSportInEdition(sport.id)"
class="sport-color"
type="color"
:value="sportPayload.color"
@input="updateColor"
/>
<SportImage
v-else
:title="sport.translatedLabel"
:sport-label="sport.label"
:color="sport.color ? sport.color : sportColors[sport.label]"
/>
</td>
<td
class="sport-label"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.LABEL') }}
</span>
{{ sport.translatedLabel }}
<span class="disabled-message" v-if="!sport.is_active">
({{ $t('user.PROFILE.SPORT.DISABLED_BY_ADMIN') }})
</span>
<i
v-if="loading && isSportInEdition(sport.id)"
class="fa fa-refresh fa-spin fa-fw"
/>
<ErrorMessage
:message="errorMessages"
v-if="errorMessages && sportPayload.sport_id === sport.id"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('workouts.WORKOUT', 0) }}
</span>
<i
:class="`fa fa${
user.sports_list.includes(sport.id) ? '-check' : ''
}`"
aria-hidden="true"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.IS_ACTIVE') }}
</span>
<input
v-if="isSportInEdition(sport.id) && sport.is_active"
type="checkbox"
:checked="sport.is_active_for_user"
@change="updateIsActive"
/>
<i
v-else
:class="`fa fa${sport.is_active_for_user ? '-check' : ''}`"
aria-hidden="true"
/>
</td>
<td
class="text-center"
:class="{ 'disabled-sport': !sport.is_active }"
>
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.STOPPED_SPEED_THRESHOLD') }}
</span>
<input
class="threshold-input"
v-if="isSportInEdition(sport.id) && sport.is_active"
type="number"
min="0"
step="0.1"
:value="sportPayload.stopped_speed_threshold"
@input="updateThreshold"
/>
<span v-else>
{{ sport.stopped_speed_threshold }}
</span>
</td>
<td v-if="isEdition" class="action-buttons">
<span class="cell-heading">
{{ $t('user.PROFILE.SPORT.ACTION') }}
</span>
<button
v-if="sportPayload.sport_id === 0"
@click="updateSportInEdition(sport)"
>
{{ $t('buttons.EDIT') }}
</button>
<div v-if="isSportInEdition(sport.id)" class="edition-buttons">
<button :disabled="loading" @click="updateSport">
{{ $t('buttons.SUBMIT') }}
</button>
<button :disabled="loading" @click="updateSportInEdition(null)">
{{ $t('buttons.CANCEL') }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="isEdition" class="profile-buttons">
<button class="cancel" @click.prevent="$router.push('/profile/sports')">
{{ $t('buttons.BACK') }}
</button>
</div>
<div v-else class="profile-buttons">
<button @click="$router.push('/profile/edit/sports')">
{{ $t('user.PROFILE.EDIT_SPORTS_PREFERENCES') }}
</button>
<button @click="$router.push('/')">{{ $t('common.HOME') }}</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ComputedRef, computed, inject, reactive, toRefs, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AUTH_USER_STORE, ROOT_STORE, SPORTS_STORE } from '@/store/constants'
import { ISport, ITranslatedSport } from '@/types/sports'
import { IUserProfile, IUserSportPreferencesPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
import { translateSports } from '@/utils/sports'
interface Props {
user: IUserProfile
isEdition: boolean
}
const props = defineProps<Props>()
const store = useStore()
const { t } = useI18n()
const { isEdition, user } = toRefs(props)
const sportColors = inject('sportColors')
const sports: ComputedRef<ISport[]> = computed(
() => store.getters[SPORTS_STORE.GETTERS.SPORTS]
)
const translatedSports: ComputedRef<ITranslatedSport[]> = computed(() =>
translateSports(sports.value, t, true, user.value.sports_list)
)
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const sportPayload: IUserSportPreferencesPayload = reactive({
sport_id: 0,
color: null,
is_active: true,
stopped_speed_threshold: 1,
})
function updateSportInEdition(sport: ISport | null) {
if (sport !== null) {
sportPayload.sport_id = sport.id
sportPayload.color = sport.color ? sport.color : sportColors[sport.label]
sportPayload.is_active = sport.is_active_for_user
sportPayload.stopped_speed_threshold = sport.stopped_speed_threshold
} else {
resetSportPayload()
}
}
function isSportInEdition(sportId: number) {
return sportPayload.sport_id === sportId
}
function updateColor(event: Event & { target: HTMLInputElement }) {
sportPayload.color = event.target.value
}
function updateThreshold(event: Event & { target: HTMLInputElement }) {
sportPayload.stopped_speed_threshold = parseFloat(event.target.value)
}
function updateIsActive(event: Event & { target: HTMLInputElement }) {
sportPayload.is_active = event.target.checked
}
function resetSportPayload() {
sportPayload.sport_id = 0
sportPayload.color = null
sportPayload.is_active = true
sportPayload.stopped_speed_threshold = 1
store.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
}
function updateSport(event: Event) {
event.preventDefault()
store.dispatch(
AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES,
sportPayload
)
}
watch(
() => loading.value,
(newIsLoading) => {
if (!newIsLoading && !errorMessages.value) {
resetSportPayload()
}
}
)
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-sport-preferences {
.sport-img {
height: 35px;
width: 35px;
margin: 0 auto;
}
.sport-color {
border: none;
margin: 6px 1px 6px 0;
padding: 0;
width: 40px;
}
.sport-label {
width: 170px;
}
.disabled-sport {
font-style: italic;
color: var(--disabled-sport-color);
.disabled-message {
font-size: 0.9em;
}
.cell-heading {
font-style: normal;
}
}
.action-buttons {
width: 70px;
}
.edition-buttons {
display: flex;
flex-wrap: wrap;
gap: $default-padding * 0.5;
line-height: 1.3em;
button {
text-align: center;
min-width: 80px;
}
}
.threshold-input {
padding: $default-padding * 0.5;
width: 50px;
}
.mobile-display {
display: none;
}
div.error-message {
margin: 0;
}
@media screen and (max-width: $small-limit) {
.sport-label,
.action-buttons {
width: 45%;
}
.edition-buttons {
justify-content: center;
}
.mobile-display {
display: flex;
margin: $default-margin * 2 0 $default-margin;
}
}
@media screen and (max-width: $x-small-limit) {
.sport-label,
.action-buttons {
width: 100%;
}
}
}
</style>

View File

@ -75,7 +75,11 @@
" "
> >
<div class="img"> <div class="img">
<SportImage v-if="sport.label" :sport-label="sport.label" /> <SportImage
v-if="sport.label"
:sport-label="sport.label"
:color="sport.color"
/>
</div> </div>
<div class="data"> <div class="data">
<i class="fa fa-clock-o" aria-hidden="true" /> <i class="fa fa-clock-o" aria-hidden="true" />

View File

@ -17,7 +17,7 @@
<i class="fa fa-chevron-left" aria-hidden="true" /> <i class="fa fa-chevron-left" aria-hidden="true" />
</div> </div>
<div class="workout-card-title"> <div class="workout-card-title">
<SportImage :sport-label="sport.label" /> <SportImage :sport-label="sport.label" :color="sport.color" />
<div class="workout-title-date"> <div class="workout-title-date">
<div class="workout-title" v-if="workoutObject.type === 'WORKOUT'"> <div class="workout-title" v-if="workoutObject.type === 'WORKOUT'">
{{ workoutObject.title }} {{ workoutObject.title }}

View File

@ -46,7 +46,7 @@
v-model="workoutForm.sport_id" v-model="workoutForm.sport_id"
> >
<option <option
v-for="sport in translatedSports.filter((s) => s.is_active)" v-for="sport in translatedSports"
:value="sport.id" :value="sport.id"
:key="sport.id" :key="sport.id"
> >
@ -259,7 +259,12 @@
const { workout, isCreation, loading } = toRefs(props) const { workout, isCreation, loading } = toRefs(props)
const translatedSports: ComputedRef<ISport[]> = computed(() => const translatedSports: ComputedRef<ISport[]> = computed(() =>
translateSports(props.sports, t) translateSports(
props.sports,
t,
true,
workout.value.id ? [workout.value.sport_id] : null
)
) )
const appConfig: ComputedRef<TAppConfig> = computed( const appConfig: ComputedRef<TAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG] () => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]

View File

@ -49,9 +49,8 @@
sports.filter((s) => s.id === workout.sport_id)[0] sports.filter((s) => s.id === workout.sport_id)[0]
.translatedLabel .translatedLabel
" "
:sport-label=" :sport-label="getSportLabel(workout, sports)"
sports.filter((s) => s.id === workout.sport_id)[0].label :color="getSportColor(workout, sports)"
"
/> />
</td> </td>
<td <td
@ -163,6 +162,7 @@
import { useStore } from '@/use/useStore' import { useStore } from '@/use/useStore'
import { getQuery, sortList, workoutsPayloadKeys } from '@/utils/api' import { getQuery, sortList, workoutsPayloadKeys } from '@/utils/api'
import { getDateWithTZ } from '@/utils/dates' import { getDateWithTZ } from '@/utils/dates'
import { getSportColor, getSportLabel } from '@/utils/sports'
import { defaultOrder } from '@/utils/workouts' import { defaultOrder } from '@/utils/workouts'
interface Props { interface Props {

View File

@ -23,6 +23,7 @@
"BIRTH_DATE": "Birth date", "BIRTH_DATE": "Birth date",
"EDIT": "Edit profile", "EDIT": "Edit profile",
"EDIT_PREFERENCES": "Edit preferences", "EDIT_PREFERENCES": "Edit preferences",
"EDIT_SPORTS_PREFERENCES": "Edit sports preferences",
"FIRST_NAME": "First name", "FIRST_NAME": "First name",
"FIRST_DAY_OF_WEEK": "First day of week", "FIRST_DAY_OF_WEEK": "First day of week",
"LANGUAGE": "Language", "LANGUAGE": "Language",
@ -36,11 +37,21 @@
"PREFERENCES_EDITION": "Preferences edition", "PREFERENCES_EDITION": "Preferences edition",
"PROFILE_EDITION": "Profile edition", "PROFILE_EDITION": "Profile edition",
"REGISTRATION_DATE": "Registration date", "REGISTRATION_DATE": "Registration date",
"SPORTS_EDITION": "Sports preferences edition",
"SUNDAY": "Sunday", "SUNDAY": "Sunday",
"TABS": { "TABS": {
"PICTURE": "picture", "PICTURE": "picture",
"PREFERENCES": "preferences", "PREFERENCES": "preferences",
"PROFILE": "profile" "PROFILE": "profile",
"SPORTS": "sports"
},
"SPORT": {
"ACTION": "action",
"COLOR": "color",
"DISABLED_BY_ADMIN": "disabled by admin",
"IS_ACTIVE": "active",
"LABEL": "label",
"STOPPED_SPEED_THRESHOLD": "stopped speed threshold"
}, },
"TIMEZONE": "Timezone" "TIMEZONE": "Timezone"
}, },

View File

@ -23,6 +23,7 @@
"BIRTH_DATE": "Date de naissance", "BIRTH_DATE": "Date de naissance",
"EDIT": "Modifier le profil", "EDIT": "Modifier le profil",
"EDIT_PREFERENCES": "Modifier les préférences", "EDIT_PREFERENCES": "Modifier les préférences",
"EDIT_SPORTS_PREFERENCES": "Modifier les préférences des sports",
"FIRST_DAY_OF_WEEK": "Premier jour de la semaine", "FIRST_DAY_OF_WEEK": "Premier jour de la semaine",
"FIRST_NAME": "Prénom", "FIRST_NAME": "Prénom",
"LANGUAGE": "Langue", "LANGUAGE": "Langue",
@ -36,11 +37,21 @@
"PREFERENCES_EDITION": "Mise à jour des préférences", "PREFERENCES_EDITION": "Mise à jour des préférences",
"PROFILE_EDITION": "Mise à jour du profil", "PROFILE_EDITION": "Mise à jour du profil",
"REGISTRATION_DATE": "Date d'inscription", "REGISTRATION_DATE": "Date d'inscription",
"SPORTS_EDITION": "Mise à jour des préférences des sports",
"SUNDAY": "Dimanche", "SUNDAY": "Dimanche",
"TABS": { "TABS": {
"PICTURE": "image", "PICTURE": "image",
"PREFERENCES": "préférences", "PREFERENCES": "préférences",
"PROFILE": "profil" "PROFILE": "profil",
"SPORTS": "sports"
},
"SPORT": {
"ACTION": "action",
"COLOR": "couleur",
"DISABLED_BY_ADMIN": "désactivé par l'administrateur",
"IS_ACTIVE": "actif",
"LABEL": "label",
"STOPPED_SPEED_THRESHOLD": "seuil de vitesse arrêtée"
}, },
"TIMEZONE": "Fuseau horaire" "TIMEZONE": "Fuseau horaire"
}, },
@ -49,4 +60,4 @@
"RESET_PASSWORD": "Réinitialiser votre mot de passe", "RESET_PASSWORD": "Réinitialiser votre mot de passe",
"USER_PICTURE": "photo de l'utilisateur", "USER_PICTURE": "photo de l'utilisateur",
"USERNAME": "Nom d'utilisateur" "USERNAME": "Nom d'utilisateur"
} }

View File

@ -11,6 +11,7 @@ import ProfileEdition from '@/components/User/ProfileEdition/index.vue'
import UserInfosEdition from '@/components/User/ProfileEdition/UserInfosEdition.vue' import UserInfosEdition from '@/components/User/ProfileEdition/UserInfosEdition.vue'
import UserPictureEdition from '@/components/User/ProfileEdition/UserPictureEdition.vue' import UserPictureEdition from '@/components/User/ProfileEdition/UserPictureEdition.vue'
import UserPreferencesEdition from '@/components/User/ProfileEdition/UserPreferencesEdition.vue' import UserPreferencesEdition from '@/components/User/ProfileEdition/UserPreferencesEdition.vue'
import UserSportPreferences from '@/components/User/UserSportPreferences.vue'
import store from '@/store' import store from '@/store'
import { AUTH_USER_STORE } from '@/store/constants' import { AUTH_USER_STORE } from '@/store/constants'
@ -101,6 +102,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'UserPreferences', name: 'UserPreferences',
component: UserPreferences, component: UserPreferences,
}, },
{
path: 'sports',
name: 'UserSportPreferences',
component: UserSportPreferences,
props: { isEdition: false },
},
], ],
}, },
{ {
@ -126,6 +133,12 @@ const routes: Array<RouteRecordRaw> = [
name: 'UserPreferencesEdition', name: 'UserPreferencesEdition',
component: UserPreferencesEdition, component: UserPreferencesEdition,
}, },
{
path: 'sports',
name: 'UserSportPreferencesEdition',
component: UserSportPreferences,
props: { isEdition: true },
},
], ],
}, },
], ],

View File

@ -254,9 +254,12 @@ button {
} }
} }
.center-text { .text-center {
text-align: center; text-align: center;
} }
.text-left {
text-align: left;
}
.responsive-table { .responsive-table {
margin-bottom: 15px; margin-bottom: 15px;
@ -337,3 +340,14 @@ button {
} }
} }
} }
.profile-buttons {
display: flex;
gap: $default-padding;
}
.medium-sport-img {
height: 35px;
width: 35px;
margin: 0 auto;
}

View File

@ -53,6 +53,7 @@
--disabled-background-color: #e0e0e0; --disabled-background-color: #e0e0e0;
--disabled-color: #a3a3a3; --disabled-color: #a3a3a3;
--disabled-sport-color: #616161;
--scroll-button-bg-color: rgba(255, 255, 255, .7); --scroll-button-bg-color: rgba(255, 255, 255, .7);

View File

@ -17,6 +17,7 @@ import {
IAuthUserState, IAuthUserState,
} from '@/store/modules/authUser/types' } from '@/store/modules/authUser/types'
import { IRootState } from '@/store/modules/root/types' import { IRootState } from '@/store/modules/root/types'
import { deleteUserAccount } from '@/store/modules/users/actions'
import { import {
ILoginOrRegisterData, ILoginOrRegisterData,
IUserDeletionPayload, IUserDeletionPayload,
@ -25,6 +26,7 @@ import {
IUserPayload, IUserPayload,
IUserPicturePayload, IUserPicturePayload,
IUserPreferencesPayload, IUserPreferencesPayload,
IUserSportPreferencesPayload,
} from '@/types/user' } from '@/types/user'
import { handleError } from '@/utils' import { handleError } from '@/utils'
@ -172,6 +174,26 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false) context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
) )
}, },
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserSportPreferencesPayload
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, true)
authApi
.post('auth/profile/edit/sports', payload)
.then((res) => {
if (res.data.status === 'success') {
context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS)
} else {
handleError(context, null)
}
})
.catch((error) => {
handleError(context, error)
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
})
},
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE]( [AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IAuthUserState, IRootState>, context: ActionContext<IAuthUserState, IRootState>,
payload: IUserPicturePayload payload: IUserPicturePayload
@ -207,19 +229,7 @@ export const actions: ActionTree<IAuthUserState, IRootState> &
context: ActionContext<IAuthUserState, IRootState>, context: ActionContext<IAuthUserState, IRootState>,
payload: IUserDeletionPayload payload: IUserDeletionPayload
): void { ): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES) deleteUserAccount(context, payload)
authApi
.delete(`users/${payload.username}`)
.then((res) => {
if (res.status === 204) {
context
.dispatch(AUTH_USER_STORE.ACTIONS.LOGOUT)
.then(() => router.push('/'))
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
}, },
[AUTH_USER_STORE.ACTIONS.DELETE_PICTURE]( [AUTH_USER_STORE.ACTIONS.DELETE_PICTURE](
context: ActionContext<IAuthUserState, IRootState> context: ActionContext<IAuthUserState, IRootState>

View File

@ -10,6 +10,7 @@ export enum AuthUserActions {
UPDATE_USER_PICTURE = 'UPDATE_USER_PICTURE', UPDATE_USER_PICTURE = 'UPDATE_USER_PICTURE',
UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE', UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE',
UPDATE_USER_PREFERENCES = 'UPDATE_USER_PREFERENCES', UPDATE_USER_PREFERENCES = 'UPDATE_USER_PREFERENCES',
UPDATE_USER_SPORT_PREFERENCES = 'UPDATE_USER_SPORT_PREFERENCES',
} }
export enum AuthUserGetters { export enum AuthUserGetters {

View File

@ -16,6 +16,7 @@ import {
IUserPayload, IUserPayload,
IUserPicturePayload, IUserPicturePayload,
IUserPreferencesPayload, IUserPreferencesPayload,
IUserSportPreferencesPayload,
} from '@/types/user' } from '@/types/user'
export interface IAuthUserState { export interface IAuthUserState {
@ -52,6 +53,11 @@ export interface IAuthUserActions {
payload: IUserPreferencesPayload payload: IUserPreferencesPayload
): void ): void
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_SPORT_PREFERENCES](
context: ActionContext<IAuthUserState, IRootState>,
payload: IUserSportPreferencesPayload
): void
[AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE]( [AUTH_USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IAuthUserState, IRootState>, context: ActionContext<IAuthUserState, IRootState>,
payload: IUserPicturePayload payload: IUserPicturePayload

View File

@ -1,7 +1,7 @@
import { ActionContext, ActionTree } from 'vuex' import { ActionContext, ActionTree } from 'vuex'
import authApi from '@/api/authApi' import authApi from '@/api/authApi'
import { ROOT_STORE, SPORTS_STORE } from '@/store/constants' import { AUTH_USER_STORE, ROOT_STORE, SPORTS_STORE } from '@/store/constants'
import { IRootState } from '@/store/modules/root/types' import { IRootState } from '@/store/modules/root/types'
import { ISportsActions, ISportsState } from '@/store/modules/sports/types' import { ISportsActions, ISportsState } from '@/store/modules/sports/types'
import { ISportPayload } from '@/types/sports' import { ISportPayload } from '@/types/sports'
@ -20,6 +20,7 @@ export const actions: ActionTree<ISportsState, IRootState> & ISportsActions = {
SPORTS_STORE.MUTATIONS.SET_SPORTS, SPORTS_STORE.MUTATIONS.SET_SPORTS,
res.data.data.sports res.data.data.sports
) )
context.commit(AUTH_USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
} else { } else {
handleError(context, null) handleError(context, null)
} }

View File

@ -1,13 +1,40 @@
import { ActionContext, ActionTree } from 'vuex' import { ActionContext, ActionTree } from 'vuex'
import authApi from '@/api/authApi' import authApi from '@/api/authApi'
import { ROOT_STORE, USERS_STORE } from '@/store/constants' import router from '@/router'
import { AUTH_USER_STORE, ROOT_STORE, USERS_STORE } from '@/store/constants'
import { IAuthUserState } from '@/store/modules/authUser/types'
import { IRootState } from '@/store/modules/root/types' import { IRootState } from '@/store/modules/root/types'
import { IUsersActions, IUsersState } from '@/store/modules/users/types' import { IUsersActions, IUsersState } from '@/store/modules/users/types'
import { TPaginationPayload } from '@/types/api' import { TPaginationPayload } from '@/types/api'
import { IAdminUserPayload } from '@/types/user' import { IAdminUserPayload, IUserDeletionPayload } from '@/types/user'
import { handleError } from '@/utils' import { handleError } from '@/utils'
export const deleteUserAccount = (
context:
| ActionContext<IAuthUserState, IRootState>
| ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void => {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
authApi
.delete(`users/${payload.username}`)
.then((res) => {
if (res.status === 204) {
if (payload.fromAdmin) {
router.push('/admin/users')
} else {
context
.dispatch(AUTH_USER_STORE.ACTIONS.LOGOUT)
.then(() => router.push('/'))
}
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
}
export const actions: ActionTree<IUsersState, IRootState> & IUsersActions = { export const actions: ActionTree<IUsersState, IRootState> & IUsersActions = {
[USERS_STORE.ACTIONS.EMPTY_USER]( [USERS_STORE.ACTIONS.EMPTY_USER](
context: ActionContext<IUsersState, IRootState> context: ActionContext<IUsersState, IRootState>
@ -94,4 +121,13 @@ export const actions: ActionTree<IUsersState, IRootState> & IUsersActions = {
context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, false) context.commit(USERS_STORE.MUTATIONS.UPDATE_USERS_LOADING, false)
) )
}, },
[USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT](
context: ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void {
deleteUserAccount(context, {
username: payload.username,
fromAdmin: true,
})
},
} }

View File

@ -4,6 +4,7 @@ export enum UsersActions {
GET_USER = 'GET_USER', GET_USER = 'GET_USER',
GET_USERS = 'GET_USERS', GET_USERS = 'GET_USERS',
UPDATE_USER = 'UPDATE_USER', UPDATE_USER = 'UPDATE_USER',
DELETE_USER_ACCOUNT = 'DELETE_USER_ACCOUNT',
} }
export enum UsersGetters { export enum UsersGetters {

View File

@ -8,7 +8,11 @@ import {
import { USERS_STORE } from '@/store/constants' import { USERS_STORE } from '@/store/constants'
import { IRootState } from '@/store/modules/root/types' import { IRootState } from '@/store/modules/root/types'
import { IPagination, TPaginationPayload } from '@/types/api' import { IPagination, TPaginationPayload } from '@/types/api'
import { IAdminUserPayload, IUserProfile } from '@/types/user' import {
IAdminUserPayload,
IUserDeletionPayload,
IUserProfile,
} from '@/types/user'
export interface IUsersState { export interface IUsersState {
user: IUserProfile user: IUserProfile
@ -36,6 +40,10 @@ export interface IUsersActions {
context: ActionContext<IUsersState, IRootState>, context: ActionContext<IUsersState, IRootState>,
payload: IAdminUserPayload payload: IAdminUserPayload
): void ): void
[USERS_STORE.ACTIONS.DELETE_USER_ACCOUNT](
context: ActionContext<IUsersState, IRootState>,
payload: IUserDeletionPayload
): void
} }
export interface IUsersGetters { export interface IUsersGetters {

View File

@ -1,9 +1,12 @@
export interface ISport { export interface ISport {
color: string | null
has_workouts: boolean has_workouts: boolean
id: number id: number
img: string img: string
is_active: boolean is_active: boolean
is_active_for_user: boolean
label: string label: string
stopped_speed_threshold: number
} }
export interface ITranslatedSport extends ISport { export interface ITranslatedSport extends ISport {

View File

@ -45,6 +45,13 @@ export interface IUserPreferencesPayload {
weekm: boolean weekm: boolean
} }
export interface IUserSportPreferencesPayload {
sport_id: number
color: string | null
is_active: boolean
stopped_speed_threshold: number
}
export interface IUserPicturePayload { export interface IUserPicturePayload {
picture: File picture: File
} }
@ -61,6 +68,7 @@ export interface IUserPasswordResetPayload {
export interface IUserDeletionPayload { export interface IUserDeletionPayload {
username: string username: string
fromAdmin?: boolean
} }
export interface ILoginRegisterFormData { export interface ILoginRegisterFormData {

View File

@ -27,8 +27,9 @@ export interface IRecord {
} }
export interface IRecordsBySport { export interface IRecordsBySport {
[key: string]: string | Record<string, string | number>[] [key: string]: string | Record<string, string | number>[] | null
label: string label: string
color: string | null
records: Record<string, string | number>[] records: Record<string, string | number>[]
} }

View File

@ -44,6 +44,7 @@ export const getRecordsBySports = (
if (sportList[sport.translatedLabel] === void 0) { if (sportList[sport.translatedLabel] === void 0) {
sportList[sport.translatedLabel] = { sportList[sport.translatedLabel] = {
label: sport.label, label: sport.label,
color: sport.color,
records: [], records: [],
} }
} }

View File

@ -18,7 +18,10 @@ export const sportColors: Record<string, string> = {
export const sportIdColors = (sports: ISport[]): Record<number, string> => { export const sportIdColors = (sports: ISport[]): Record<number, string> => {
const colors: Record<number, string> = {} const colors: Record<number, string> = {}
sports.map((sport) => (colors[sport.id] = sportColors[sport.label])) sports.map(
(sport) =>
(colors[sport.id] = sport.color ? sport.color : sportColors[sport.label])
)
return colors return colors
} }
@ -35,10 +38,17 @@ const sortSports = (a: ITranslatedSport, b: ITranslatedSport): number => {
export const translateSports = ( export const translateSports = (
sports: ISport[], sports: ISport[],
t: CallableFunction, t: CallableFunction,
onlyActive = false onlyActive = false,
userSports: number[] | null = null
): ITranslatedSport[] => ): ITranslatedSport[] =>
sports sports
.filter((sport) => (onlyActive ? sport.is_active : true)) .filter((sport) =>
onlyActive
? userSports === null
? sport.is_active_for_user
: userSports.includes(sport.id) || sport.is_active
: true
)
.map((sport) => ({ .map((sport) => ({
...sport, ...sport,
translatedLabel: t(`sports.${sport.label}.LABEL`), translatedLabel: t(`sports.${sport.label}.LABEL`),
@ -50,3 +60,12 @@ export const getSportLabel = (workout: IWorkout, sports: ISport[]): string => {
.filter((sport) => sport.id === workout.sport_id) .filter((sport) => sport.id === workout.sport_id)
.map((sport) => sport.label)[0] .map((sport) => sport.label)[0]
} }
export const getSportColor = (
workout: IWorkout,
sports: ISport[]
): string | null => {
return sports
.filter((sport) => sport.id === workout.sport_id)
.map((sport) => sport.color)[0]
}

View File

@ -84,7 +84,7 @@ export const getDatasets = (displayedSports: ISport[]): TStatisticsDatasets => {
total_descent: [], total_descent: [],
} }
displayedSports.map((sport) => { displayedSports.map((sport) => {
const color = sportColors[sport.label] const color = sport.color ? sport.color : sportColors[sport.label]
datasets.nb_workouts.push(getStatisticsChartDataset(sport.label, color)) datasets.nb_workouts.push(getStatisticsChartDataset(sport.label, color))
datasets.total_distance.push(getStatisticsChartDataset(sport.label, color)) datasets.total_distance.push(getStatisticsChartDataset(sport.label, color))
datasets.total_duration.push(getStatisticsChartDataset(sport.label, color)) datasets.total_duration.push(getStatisticsChartDataset(sport.label, color))

View File

@ -1,6 +1,7 @@
<template> <template>
<div id="profile" class="container view" v-if="authUser.username"> <div id="profile" class="container view" v-if="authUser.username">
<router-view :user="authUser"></router-view> <router-view :user="authUser"></router-view>
<div id="bottom" />
</div> </div>
</template> </template>

View File

@ -5,25 +5,34 @@ import { translateSports } from '@/utils/sports'
const { t } = createI18n.global const { t } = createI18n.global
export const sports: ISport[] = [ export const sports: ISport[] = [
{ {
color: null,
has_workouts: false, has_workouts: false,
id: 1, id: 1,
img: '/img/sports/cycling-sport.png', img: '/img/sports/cycling-sport.png',
is_active: true, is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
}, },
{ {
has_workouts: false, color: '#000000',
has_workouts: true,
id: 2, id: 2,
img: '/img/sports/cycling-transport.png', img: '/img/sports/cycling-transport.png',
is_active: false, is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)', label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
}, },
{ {
color: null,
has_workouts: true, has_workouts: true,
id: 3, id: 3,
img: '/img/sports/hiking.png', img: '/img/sports/hiking.png',
is_active: true, is_active: true,
is_active_for_user: false,
label: 'Hiking', label: 'Hiking',
stopped_speed_threshold: 0.1,
}, },
] ]

View File

@ -157,6 +157,7 @@ describe('getRecordsBySports', () => {
}, },
expected: { expected: {
'Cycling (Sport)': { 'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
records: [ records: [
{ {
@ -206,6 +207,7 @@ describe('getRecordsBySports', () => {
}, },
expected: { expected: {
'Cycling (Sport)': { 'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
records: [ records: [
{ {
@ -225,6 +227,7 @@ describe('getRecordsBySports', () => {
], ],
}, },
'Cycling (Transport)': { 'Cycling (Transport)': {
color: '#000000',
label: 'Cycling (Transport)', label: 'Cycling (Transport)',
records: [ records: [
{ {

View File

@ -7,7 +7,7 @@ import { translateSports } from '@/utils/sports'
const { t, locale } = createI18n.global const { t, locale } = createI18n.global
describe('sortSports', () => { describe('translateSports', () => {
const testsParams = [ const testsParams = [
{ {
description: "returns sorted all translated sports (with 'en' locale)", description: "returns sorted all translated sports (with 'en' locale)",
@ -15,59 +15,65 @@ describe('sortSports', () => {
sports, sports,
locale: 'en', locale: 'en',
onlyActive: false, onlyActive: false,
userSports: null,
}, },
expected: [ expected: [
{ {
color: null,
has_workouts: false, has_workouts: false,
id: 1, id: 1,
img: '/img/sports/cycling-sport.png', img: '/img/sports/cycling-sport.png',
is_active: true, is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)', translatedLabel: 'Cycling (Sport)',
}, },
{ {
has_workouts: false, color: '#000000',
has_workouts: true,
id: 2, id: 2,
img: '/img/sports/cycling-transport.png', img: '/img/sports/cycling-transport.png',
is_active: false, is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)', label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)', translatedLabel: 'Cycling (Transport)',
}, },
{ {
color: null,
has_workouts: true, has_workouts: true,
id: 3, id: 3,
img: '/img/sports/hiking.png', img: '/img/sports/hiking.png',
is_active: true, is_active: true,
is_active_for_user: false,
label: 'Hiking', label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking', translatedLabel: 'Hiking',
}, },
], ],
}, },
{ {
description: description:
"returns sorted only active translated sports (with 'en' locales)", "returns sorted only translated sports, active for user (with 'en' locales)",
inputParams: { inputParams: {
sports, sports,
locale: 'en', locale: 'en',
onlyActive: true, onlyActive: true,
userSports: null,
}, },
expected: [ expected: [
{ {
color: null,
has_workouts: false, has_workouts: false,
id: 1, id: 1,
img: '/img/sports/cycling-sport.png', img: '/img/sports/cycling-sport.png',
is_active: true, is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)', translatedLabel: 'Cycling (Sport)',
}, },
{
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
label: 'Hiking',
translatedLabel: 'Hiking',
},
], ],
}, },
{ {
@ -76,6 +82,7 @@ describe('sortSports', () => {
sports: [], sports: [],
locale: 'en', locale: 'en',
onlyActive: false, onlyActive: false,
userSports: null,
}, },
expected: [], expected: [],
}, },
@ -85,57 +92,63 @@ describe('sortSports', () => {
sports, sports,
locale: 'fr', locale: 'fr',
onlyActive: false, onlyActive: false,
userSports: [],
}, },
expected: [ expected: [
{ {
color: null,
has_workouts: true, has_workouts: true,
id: 3, id: 3,
img: '/img/sports/hiking.png', img: '/img/sports/hiking.png',
is_active: true, is_active: true,
is_active_for_user: false,
label: 'Hiking', label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée', translatedLabel: 'Randonnée',
}, },
{ {
color: null,
has_workouts: false, has_workouts: false,
id: 1, id: 1,
img: '/img/sports/cycling-sport.png', img: '/img/sports/cycling-sport.png',
is_active: true, is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)', translatedLabel: 'Vélo (Sport)',
}, },
{ {
has_workouts: false, color: '#000000',
has_workouts: true,
id: 2, id: 2,
img: '/img/sports/cycling-transport.png', img: '/img/sports/cycling-transport.png',
is_active: false, is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)', label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)', translatedLabel: 'Vélo (Transport)',
}, },
], ],
}, },
{ {
description: description:
"returns sorted only active translated sports (with 'fr' locales)", "returns sorted only translated sports, active for user (with 'fr' locales)",
inputParams: { inputParams: {
sports, sports,
locale: 'fr', locale: 'fr',
onlyActive: true, onlyActive: true,
userSports: null,
}, },
expected: [ expected: [
{ {
has_workouts: true, color: null,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
label: 'Hiking',
translatedLabel: 'Randonnée',
},
{
has_workouts: false, has_workouts: false,
id: 1, id: 1,
img: '/img/sports/cycling-sport.png', img: '/img/sports/cycling-sport.png',
is_active: true, is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)', label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)', translatedLabel: 'Vélo (Sport)',
}, },
], ],
@ -146,6 +159,209 @@ describe('sortSports', () => {
sports: [], sports: [],
locale: 'fr', locale: 'fr',
onlyActive: false, onlyActive: false,
userSports: null,
},
expected: [],
},
{
description:
"returns sorted all translated sports, even with user sports list provided (with 'en' locale)",
inputParams: {
sports,
locale: 'en',
onlyActive: false,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)',
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking',
},
],
},
{
description:
"returns sorted only translated sports active on application and sports with user workouts (with 'en' locales)",
inputParams: {
sports,
locale: 'en',
onlyActive: true,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Cycling (Transport)',
},
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Hiking',
},
],
},
{
description:
"returns empty array, with user sports list provided (with 'en' locale)",
inputParams: {
sports: [],
locale: 'en',
onlyActive: false,
userSports: null,
},
expected: [],
},
{
description:
"returns sorted all translated sports, even with user sports list provided (with 'fr' locale)",
inputParams: {
sports,
locale: 'fr',
onlyActive: false,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)',
},
],
},
{
description:
"returns sorted only translated sports active on application and sports with user workouts (with 'fr' locales)",
inputParams: {
sports,
locale: 'fr',
onlyActive: true,
userSports: [2],
},
expected: [
{
color: null,
has_workouts: true,
id: 3,
img: '/img/sports/hiking.png',
is_active: true,
is_active_for_user: false,
label: 'Hiking',
stopped_speed_threshold: 0.1,
translatedLabel: 'Randonnée',
},
{
color: null,
has_workouts: false,
id: 1,
img: '/img/sports/cycling-sport.png',
is_active: true,
is_active_for_user: true,
label: 'Cycling (Sport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Sport)',
},
{
color: '#000000',
has_workouts: true,
id: 2,
img: '/img/sports/cycling-transport.png',
is_active: false,
is_active_for_user: false,
label: 'Cycling (Transport)',
stopped_speed_threshold: 1,
translatedLabel: 'Vélo (Transport)',
},
],
},
{
description:
"returns empty array, with user sports list provided (with 'fr' locale)",
inputParams: {
sports: [],
locale: 'fr',
onlyActive: false,
userSports: [2],
}, },
expected: [], expected: [],
}, },
@ -157,7 +373,8 @@ describe('sortSports', () => {
translateSports( translateSports(
testParams.inputParams.sports, testParams.inputParams.sports,
t, t,
testParams.inputParams.onlyActive testParams.inputParams.onlyActive,
testParams.inputParams.userSports
), ),
testParams.expected testParams.expected
) )

Some files were not shown because too many files have changed in this diff Show More