Merge pull request #111 from SamR1/imperial-units

Display workouts with imperial units
This commit is contained in:
Sam 2021-11-14 20:25:58 +01:00 committed by GitHub
commit c836c0da7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
80 changed files with 1196 additions and 150 deletions

View File

@ -6,6 +6,7 @@
#### New Features #### New Features
* [#99](https://github.com/SamR1/FitTrackee/issues/99) - Display workout with imperial units
* [#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 * [#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
@ -23,7 +24,7 @@
* [#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. In this release 6 issue were closed.
## Version 0.4.9 (2021/07/16) ## Version 0.4.9 (2021/07/16)

View File

@ -6,6 +6,7 @@
#### New Features #### New Features
* [#99](https://github.com/SamR1/FitTrackee/issues/99) - Display workout with imperial units
* [#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 * [#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
@ -23,7 +24,7 @@
* [#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. In this release 6 issue were closed.
## Version 0.4.9 (2021/07/16) ## Version 0.4.9 (2021/07/16)

View File

@ -34,8 +34,9 @@ Administration
Account & preferences 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 set language, timezone and first day of week. - A user can set language, timezone and first day of week.
- A user can reset his password (*new in 0.3.0*)
- A user can choose between metric system and imperial system for distance, elevation and speed display (*new in 0.5.0*)
- A user can set sport preferences (*new in 0.5.0*): - A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts) - change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files) - can override stopped speed threshold (for next uploaded gpx files)
@ -72,7 +73,7 @@ Workouts
It can be overridden in user preferences. It can be overridden in user preferences.
- 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. - 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). - Workout creation by uploading a gpx file (related data are stored in database with metric system). 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. - 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. - Workout edition and deletion. User can add a note.
- User statistics - User statistics

View File

@ -319,6 +319,7 @@
<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;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;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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
@ -419,6 +420,7 @@
<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;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;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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
@ -537,6 +539,7 @@
<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;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;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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>

View File

@ -160,6 +160,7 @@
<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;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;admin@example.com&quot;</span><span class="p">,</span> <span class="nt">&quot;email&quot;</span><span class="p">:</span> <span class="s2">&quot;admin@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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
@ -297,6 +298,7 @@
<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;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;admin@example.com&quot;</span><span class="p">,</span> <span class="nt">&quot;email&quot;</span><span class="p">:</span> <span class="s2">&quot;admin@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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
@ -442,6 +444,7 @@
<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;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;admin@example.com&quot;</span><span class="p">,</span> <span class="nt">&quot;email&quot;</span><span class="p">:</span> <span class="s2">&quot;admin@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;first_name&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nt">&quot;imperial_units&quot;</span><span class="p">:</span> <span class="kc">false</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;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;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;location&quot;</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>

View File

@ -277,6 +277,7 @@
<section id="new-features"> <section id="new-features">
<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/99">#99</a> - Display workout with imperial units</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/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/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>
@ -301,7 +302,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> <p>In this release 6 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

@ -178,8 +178,9 @@
<h3>Account &amp; preferences<a class="headerlink" href="#account-preferences" 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 set language, timezone and first day of week.</p></li> <li><p>A user can set language, timezone and first day of week.</p></li>
<li><p>A user can reset his password (<em>new in 0.3.0</em>)</p></li>
<li><p>A user can choose between metric system and imperial system for distance, elevation and speed display (<em>new in 0.5.0</em>)</p></li>
<li><dl class="simple"> <li><dl class="simple">
<dt>A user can set sport preferences (<em>new in 0.5.0</em>):</dt><dd><ul> <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>change sport color (used for sport image and charts)</p></li>
@ -236,7 +237,7 @@
</div> </div>
<ul class="simple"> <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>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>Workout creation by uploading a gpx file (related data are stored in database with metric system). 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>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>Workout edition and deletion. User can add a note.</p></li>
<li><p>User statistics</p></li> <li><p>User statistics</p></li>

File diff suppressed because one or more lines are too long

View File

@ -34,8 +34,9 @@ Administration
Account & preferences 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 set language, timezone and first day of week. - A user can set language, timezone and first day of week.
- A user can reset his password (*new in 0.3.0*)
- A user can choose between metric system and imperial system for distance, elevation and speed display (*new in 0.5.0*)
- A user can set sport preferences (*new in 0.5.0*): - A user can set sport preferences (*new in 0.5.0*):
- change sport color (used for sport image and charts) - change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files) - can override stopped speed threshold (for next uploaded gpx files)
@ -72,7 +73,7 @@ Workouts
It can be overridden in user preferences. It can be overridden in user preferences.
- 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. - 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). - Workout creation by uploading a gpx file (related data are stored in database with metric system). 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. - 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. - Workout edition and deletion. User can add a note.
- User statistics - User statistics

View File

@ -1,6 +1,7 @@
from typing import Dict from typing import Dict
from flask import current_app from flask import current_app
from sqlalchemy import exc
from sqlalchemy.engine.base import Connection from sqlalchemy.engine.base import Connection
from sqlalchemy.event import listens_for from sqlalchemy.event import listens_for
from sqlalchemy.ext.declarative import DeclarativeMeta from sqlalchemy.ext.declarative import DeclarativeMeta
@ -25,7 +26,15 @@ class AppConfig(BaseModel):
@property @property
def is_registration_enabled(self) -> bool: def is_registration_enabled(self) -> bool:
try:
nb_users = User.query.count() nb_users = User.query.count()
except exc.ProgrammingError as e:
# workaround for user model related migrations
if 'psycopg2.errors.UndefinedColumn' in str(e):
result = db.engine.execute("SELECT COUNT(*) FROM users;")
nb_users = result.fetchone()[0]
else:
raise e
return self.max_users == 0 or nb_users < self.max_users return self.max_users == 0 or nb_users < self.max_users
@property @property

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.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> <!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.f9856c63.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.84cbed34.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.23f4d3a6.js" rel="prefetch"><link href="/static/js/main~workouts.6afa0411.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.ca9449b1.js" rel="prefetch"><link href="/static/css/app.2b8c39ab.css" rel="preload" as="style"><link href="/static/js/app.28d0829a.js" rel="preload" as="script"><link href="/static/js/chunk-vendors.caa4fc1c.js" rel="preload" as="script"><link href="/static/css/app.2b8c39ab.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.caa4fc1c.js"></script><script src="/static/js/app.28d0829a.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": "926210f132992651a9543d9c76da25ba", "revision": "a83d95cb780cb551f9c6e0257addee7a",
"url": "/index.html" "url": "/index.html"
}, },
{ {
@ -80,8 +80,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/admin.babfd43e.css" "url": "/static/css/admin.babfd43e.css"
}, },
{ {
"revision": "4f95d958d90a2ac1b9a0", "revision": "580dbac1a3cc1ff6f809",
"url": "/static/css/app.e1e7e23c.css" "url": "/static/css/app.2b8c39ab.css"
}, },
{ {
"revision": "82c1118c918377daaa71a320ab8eea42", "revision": "82c1118c918377daaa71a320ab8eea42",
@ -92,11 +92,11 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/leaflet.css" "url": "/static/css/leaflet.css"
}, },
{ {
"revision": "00c35b353719122c16cd", "revision": "3e2dd5ce7fd86f47e0e5",
"url": "/static/css/main.7229c1ab.css" "url": "/static/css/main.f9856c63.css"
}, },
{ {
"revision": "11b770a11a1cd8dae5f4", "revision": "ac1280c03a31a5894834",
"url": "/static/css/main~workouts.0edb3403.css" "url": "/static/css/main~workouts.0edb3403.css"
}, },
{ {
@ -108,8 +108,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/reset.46776e72.css" "url": "/static/css/reset.46776e72.css"
}, },
{ {
"revision": "c78ff76a4bb0919c4b94", "revision": "03d9a79c5f845c47ef9c",
"url": "/static/css/workouts.1b0a7916.css" "url": "/static/css/workouts.84cbed34.css"
}, },
{ {
"revision": "e719f9244c69e28e7d00e725ca1e280e", "revision": "e719f9244c69e28e7d00e725ca1e280e",
@ -196,8 +196,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/admin.2f1d393d.js" "url": "/static/js/admin.2f1d393d.js"
}, },
{ {
"revision": "4f95d958d90a2ac1b9a0", "revision": "580dbac1a3cc1ff6f809",
"url": "/static/js/app.0f3b3ab5.js" "url": "/static/js/app.28d0829a.js"
}, },
{ {
"revision": "bd7d183c9f68e5f4027d", "revision": "bd7d183c9f68e5f4027d",
@ -220,16 +220,16 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/chunk-2d22523a.4b710d99.js" "url": "/static/js/chunk-2d22523a.4b710d99.js"
}, },
{ {
"revision": "1631aa1204c2ef00fa57", "revision": "c04fcf32d84e5ec5cb38",
"url": "/static/js/chunk-vendors.71654064.js" "url": "/static/js/chunk-vendors.caa4fc1c.js"
}, },
{ {
"revision": "00c35b353719122c16cd", "revision": "3e2dd5ce7fd86f47e0e5",
"url": "/static/js/main.db9cee98.js" "url": "/static/js/main.23f4d3a6.js"
}, },
{ {
"revision": "11b770a11a1cd8dae5f4", "revision": "ac1280c03a31a5894834",
"url": "/static/js/main~workouts.a74990d7.js" "url": "/static/js/main~workouts.6afa0411.js"
}, },
{ {
"revision": "058a877bc4b9cbf8929f", "revision": "058a877bc4b9cbf8929f",
@ -240,7 +240,7 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/reset.518e646f.js" "url": "/static/js/reset.518e646f.js"
}, },
{ {
"revision": "c78ff76a4bb0919c4b94", "revision": "03d9a79c5f845c47ef9c",
"url": "/static/js/workouts.d69cf48a.js" "url": "/static/js/workouts.ca9449b1.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.c1f31e9729586ecf3c442890704f31cc.js" "/precache-manifest.d81ab1e239beb2ec33c92fe076422816.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,31 @@
"""add imperial units preferences
Revision ID: 07188ca7620a
Revises: 080acc8ee956
Create Date: 2021-11-13 19:11:17.753567
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '07188ca7620a'
down_revision = '080acc8ee956'
branch_labels = None
depends_on = None
def upgrade():
op.add_column(
'users',
sa.Column('imperial_units', sa.Boolean(), nullable=True),
)
op.execute("UPDATE users SET imperial_units = false")
op.alter_column('users', 'imperial_units', nullable=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'imperial_units')
# ### end Alembic commands ###

View File

@ -487,6 +487,7 @@ class TestUserProfile(ApiTestCaseMixin):
assert not data['data']['admin'] assert not data['data']['admin']
assert data['data']['timezone'] is None assert data['data']['timezone'] is None
assert data['data']['weekm'] is False assert data['data']['weekm'] is False
assert data['data']['imperial_units'] is False
assert data['data']['language'] is None assert data['data']['language'] is None
assert data['data']['nb_sports'] == 0 assert data['data']['nb_sports'] == 0
assert data['data']['nb_workouts'] == 0 assert data['data']['nb_workouts'] == 0
@ -517,6 +518,7 @@ class TestUserProfile(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe' assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date'] assert data['data']['birth_date']
assert data['data']['bio'] == 'just a random guy' assert data['data']['bio'] == 'just a random guy'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'somewhere' assert data['data']['location'] == 'somewhere'
assert data['data']['timezone'] == 'America/New_York' assert data['data']['timezone'] == 'America/New_York'
assert data['data']['weekm'] is False assert data['data']['weekm'] is False
@ -553,6 +555,7 @@ class TestUserProfile(ApiTestCaseMixin):
assert data['data']['created_at'] assert data['data']['created_at']
assert not data['data']['admin'] assert not data['data']['admin']
assert data['data']['timezone'] is None assert data['data']['timezone'] is None
assert data['data']['imperial_units'] is False
assert data['data']['nb_sports'] == 2 assert data['data']['nb_sports'] == 2
assert data['data']['nb_workouts'] == 2 assert data['data']['nb_workouts'] == 2
assert len(data['data']['records']) == 6 assert len(data['data']['records']) == 6
@ -605,6 +608,7 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe' assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date'] assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell' assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'Somewhere' assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] is None assert data['data']['timezone'] is None
assert data['data']['weekm'] is False assert data['data']['weekm'] is False
@ -648,6 +652,7 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe' assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date'] assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell' assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'Somewhere' assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] is None assert data['data']['timezone'] is None
assert data['data']['weekm'] is False assert data['data']['weekm'] is False
@ -767,6 +772,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
timezone='America/New_York', timezone='America/New_York',
weekm=True, weekm=True,
language='fr', language='fr',
imperial_units=True,
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
@ -784,6 +790,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] is None assert data['data']['last_name'] is None
assert data['data']['birth_date'] is None assert data['data']['birth_date'] is None
assert data['data']['bio'] is None assert data['data']['bio'] is None
assert data['data']['imperial_units']
assert data['data']['location'] is None assert data['data']['location'] is None
assert data['data']['timezone'] == 'America/New_York' assert data['data']['timezone'] == 'America/New_York'
assert data['data']['weekm'] is True assert data['data']['weekm'] is True

View File

@ -36,6 +36,7 @@ class TestGetUser(ApiTestCaseMixin):
assert user['last_name'] is None assert user['last_name'] is None
assert user['birth_date'] is None assert user['birth_date'] is None
assert user['bio'] is None assert user['bio'] is None
assert user['imperial_units'] is False
assert user['location'] is None assert user['location'] is None
assert user['timezone'] is None assert user['timezone'] is None
assert user['weekm'] is False assert user['weekm'] is False
@ -77,6 +78,7 @@ class TestGetUser(ApiTestCaseMixin):
assert user['last_name'] is None assert user['last_name'] is None
assert user['birth_date'] is None assert user['birth_date'] is None
assert user['bio'] is None assert user['bio'] is None
assert user['imperial_units'] is False
assert user['location'] is None assert user['location'] is None
assert user['timezone'] is None assert user['timezone'] is None
assert user['weekm'] is False assert user['weekm'] is False
@ -129,6 +131,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert 'test@test.com' in data['data']['users'][0]['email'] assert 'test@test.com' in data['data']['users'][0]['email']
assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'toto@toto.com' in data['data']['users'][1]['email']
assert 'sam@test.com' in data['data']['users'][2]['email'] assert 'sam@test.com' in data['data']['users'][2]['email']
assert data['data']['users'][0]['imperial_units'] is False
assert data['data']['users'][0]['timezone'] is None assert data['data']['users'][0]['timezone'] is None
assert data['data']['users'][0]['weekm'] is False assert data['data']['users'][0]['weekm'] is False
assert data['data']['users'][0]['language'] is None assert data['data']['users'][0]['language'] is None
@ -138,6 +141,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert data['data']['users'][0]['sports_list'] == [] assert data['data']['users'][0]['sports_list'] == []
assert data['data']['users'][0]['total_distance'] == 0 assert data['data']['users'][0]['total_distance'] == 0
assert data['data']['users'][0]['total_duration'] == '0:00:00' assert data['data']['users'][0]['total_duration'] == '0:00:00'
assert data['data']['users'][1]['imperial_units'] is False
assert data['data']['users'][1]['timezone'] is None assert data['data']['users'][1]['timezone'] is None
assert data['data']['users'][1]['weekm'] is False assert data['data']['users'][1]['weekm'] is False
assert data['data']['users'][1]['language'] is None assert data['data']['users'][1]['language'] is None
@ -147,6 +151,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert data['data']['users'][1]['sports_list'] == [] assert data['data']['users'][1]['sports_list'] == []
assert data['data']['users'][1]['total_distance'] == 0 assert data['data']['users'][1]['total_distance'] == 0
assert data['data']['users'][1]['total_duration'] == '0:00:00' assert data['data']['users'][1]['total_duration'] == '0:00:00'
assert data['data']['users'][2]['imperial_units'] is False
assert data['data']['users'][2]['timezone'] is None assert data['data']['users'][2]['timezone'] is None
assert data['data']['users'][2]['weekm'] is True assert data['data']['users'][2]['weekm'] is True
assert data['data']['users'][2]['language'] is None assert data['data']['users'][2]['language'] is None
@ -196,6 +201,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert 'test@test.com' in data['data']['users'][0]['email'] assert 'test@test.com' in data['data']['users'][0]['email']
assert 'toto@toto.com' in data['data']['users'][1]['email'] assert 'toto@toto.com' in data['data']['users'][1]['email']
assert 'sam@test.com' in data['data']['users'][2]['email'] assert 'sam@test.com' in data['data']['users'][2]['email']
assert data['data']['users'][0]['imperial_units'] is False
assert data['data']['users'][0]['timezone'] is None assert data['data']['users'][0]['timezone'] is None
assert data['data']['users'][0]['weekm'] is False assert data['data']['users'][0]['weekm'] is False
assert data['data']['users'][0]['nb_sports'] == 2 assert data['data']['users'][0]['nb_sports'] == 2
@ -204,6 +210,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert data['data']['users'][0]['sports_list'] == [1, 2] assert data['data']['users'][0]['sports_list'] == [1, 2]
assert data['data']['users'][0]['total_distance'] == 22.0 assert data['data']['users'][0]['total_distance'] == 22.0
assert data['data']['users'][0]['total_duration'] == '2:40:00' assert data['data']['users'][0]['total_duration'] == '2:40:00'
assert data['data']['users'][1]['imperial_units'] is False
assert data['data']['users'][1]['timezone'] is None assert data['data']['users'][1]['timezone'] is None
assert data['data']['users'][1]['weekm'] is False assert data['data']['users'][1]['weekm'] is False
assert data['data']['users'][1]['nb_sports'] == 1 assert data['data']['users'][1]['nb_sports'] == 1
@ -212,6 +219,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert data['data']['users'][1]['sports_list'] == [1] assert data['data']['users'][1]['sports_list'] == [1]
assert data['data']['users'][1]['total_distance'] == 15 assert data['data']['users'][1]['total_distance'] == 15
assert data['data']['users'][1]['total_duration'] == '1:00:00' assert data['data']['users'][1]['total_duration'] == '1:00:00'
assert data['data']['users'][2]['imperial_units'] is False
assert data['data']['users'][2]['timezone'] is None assert data['data']['users'][2]['timezone'] is None
assert data['data']['users'][2]['weekm'] is True assert data['data']['users'][2]['weekm'] is True
assert data['data']['users'][2]['nb_sports'] == 0 assert data['data']['users'][2]['nb_sports'] == 0

View File

@ -14,6 +14,7 @@ class TestUserModel:
assert serialized_user['admin'] is False assert serialized_user['admin'] is False
assert serialized_user['first_name'] is None assert serialized_user['first_name'] is None
assert serialized_user['last_name'] is None assert serialized_user['last_name'] is None
assert serialized_user['imperial_units'] is False
assert serialized_user['bio'] is None assert serialized_user['bio'] is None
assert serialized_user['location'] is None assert serialized_user['location'] is None
assert serialized_user['birth_date'] is None assert serialized_user['birth_date'] is None

View File

@ -313,6 +313,7 @@ def get_authenticated_user_profile(
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -412,6 +413,7 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -574,6 +576,7 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "sam@example.com", "email": "sam@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -653,6 +656,7 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
# get post data # get post data
post_data = request.get_json() post_data = request.get_json()
user_mandatory_data = { user_mandatory_data = {
'imperial_units',
'language', 'language',
'timezone', 'timezone',
'weekm', 'weekm',
@ -660,12 +664,14 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
if not post_data or not post_data.keys() >= user_mandatory_data: if not post_data or not post_data.keys() >= user_mandatory_data:
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()
imperial_units = post_data.get('imperial_units')
language = post_data.get('language') language = post_data.get('language')
timezone = post_data.get('timezone') timezone = post_data.get('timezone')
weekm = post_data.get('weekm') weekm = post_data.get('weekm')
try: try:
user = User.query.filter_by(id=auth_user_id).first() user = User.query.filter_by(id=auth_user_id).first()
user.imperial_units = imperial_units
user.language = language user.language = language
user.timezone = timezone user.timezone = timezone
user.weekm = weekm user.weekm = weekm

View File

@ -40,6 +40,7 @@ class User(BaseModel):
'Record', lazy=True, backref=db.backref('user', lazy='joined') 'Record', lazy=True, backref=db.backref('user', lazy='joined')
) )
language = db.Column(db.String(50), nullable=True) language = db.Column(db.String(50), nullable=True)
imperial_units = db.Column(db.Boolean, default=False, nullable=False)
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<User {self.username!r}>' return f'<User {self.username!r}>'
@ -142,6 +143,7 @@ class User(BaseModel):
], ],
'total_distance': float(total[0]), 'total_distance': float(total[0]),
'total_duration': str(total[1]), 'total_duration': str(total[1]),
'imperial_units': self.imperial_units,
} }

View File

@ -64,6 +64,7 @@ def get_users(auth_user_id: int) -> Dict:
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -246,6 +247,7 @@ def get_single_user(
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,
@ -400,6 +402,7 @@ def update_user(
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT", "created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com", "email": "admin@example.com",
"first_name": null, "first_name": null,
"imperial_units": false,
"language": "en", "language": "en",
"last_name": null, "last_name": null,
"location": null, "location": null,

View File

@ -0,0 +1,62 @@
<template>
<span class="distance" :class="{ strong }">{{ convertedDistance }}</span>
{{ ' ' }}
<span v-if="displayUnit" class="unit" :class="{ strong }">
{{ unitTo }}{{ speed ? '/h' : '' }}
</span>
</template>
<script setup lang="ts">
import { ComputedRef, computed, toRefs, withDefaults } from 'vue'
import { TUnit } from '@/types/units'
import { units, convertDistance } from '@/utils/units'
interface Props {
distance: number
unitFrom: TUnit
useImperialUnits: boolean
digits?: number
displayUnit?: boolean
speed?: boolean
strong?: boolean
}
const props = withDefaults(defineProps<Props>(), {
digits: 2,
displayUnit: true,
speed: false,
strong: false,
})
const {
digits,
displayUnit,
distance,
speed,
strong,
unitFrom,
useImperialUnits,
} = toRefs(props)
const unitTo: ComputedRef<TUnit> = computed(() =>
useImperialUnits.value
? units[unitFrom.value].defaultTarget
: unitFrom.value
)
const convertedDistance = computed(() =>
useImperialUnits.value
? convertDistance(
distance.value,
unitFrom.value,
unitTo.value,
digits.value
)
: parseFloat(distance.value.toFixed(digits.value))
)
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
.strong {
font-weight: bold;
}
</style>

View File

@ -40,6 +40,10 @@
type: Boolean, type: Boolean,
required: true, required: true,
}, },
useImperialUnits: {
type: Boolean,
required: true,
},
}, },
setup(props) { setup(props) {
const { t } = useI18n() const { t } = useI18n()
@ -80,7 +84,12 @@
ticks: { ticks: {
maxTicksLimit: 6, maxTicksLimit: 6,
callback: function (value) { callback: function (value) {
return formatTooltipValue(props.displayedData, +value, false) return formatTooltipValue(
props.displayedData,
+value,
props.useImperialUnits,
false
)
}, },
}, },
afterFit: function (scale: LayoutItem) { afterFit: function (scale: LayoutItem) {
@ -108,7 +117,12 @@
.reduce((total, value) => getSum(total, value), 0) .reduce((total, value) => getSum(total, value), 0)
return context.datasetIndex === return context.datasetIndex ===
props.displayedSportIds.length - 1 && total > 0 props.displayedSportIds.length - 1 && total > 0
? formatTooltipValue(props.displayedData, total, false) ? formatTooltipValue(
props.displayedData,
total,
props.useImperialUnits,
false
)
: null : null
}, },
}, },
@ -132,7 +146,8 @@
if (context.parsed.y !== null) { if (context.parsed.y !== null) {
label += formatTooltipValue( label += formatTooltipValue(
props.displayedData, props.displayedData,
context.parsed.y context.parsed.y,
props.useImperialUnits
) )
} }
return label return label
@ -144,7 +159,11 @@
}) })
return ( return (
`${t('common.TOTAL')}: ` + `${t('common.TOTAL')}: ` +
formatTooltipValue(props.displayedData, sum) formatTooltipValue(
props.displayedData,
sum,
props.useImperialUnits
)
) )
}, },
}, },

View File

@ -58,6 +58,7 @@
:displayedData="displayedData" :displayedData="displayedData"
:displayedSportIds="displayedSportIds" :displayedSportIds="displayedSportIds"
:fullStats="fullStats" :fullStats="fullStats"
:useImperialUnits="user.imperial_units"
/> />
</div> </div>
</div> </div>
@ -134,7 +135,8 @@
props.user.weekm, props.user.weekm,
props.sports, props.sports,
props.displayedSportIds, props.displayedSportIds,
statistics.value statistics.value,
props.user.imperial_units
) )
) )

View File

@ -5,6 +5,7 @@
<WorkoutCard <WorkoutCard
v-for="index in [...Array(initWorkoutsCount).keys()]" v-for="index in [...Array(initWorkoutsCount).keys()]"
:user="user" :user="user"
:useImperialUnits="user.imperial_units"
:key="index" :key="index"
/> />
</div> </div>
@ -18,6 +19,7 @@
: null : null
" "
:user="user" :user="user"
:useImperialUnits="user.imperial_units"
:key="workout.id" :key="workout.id"
/> />
<NoWorkouts v-if="workouts.length === 0" /> <NoWorkouts v-if="workouts.length === 0" />

View File

@ -13,6 +13,7 @@
:sportTranslatedLabel="sportTranslatedLabel" :sportTranslatedLabel="sportTranslatedLabel"
:records="recordsBySport[sportTranslatedLabel]" :records="recordsBySport[sportTranslatedLabel]"
:key="sportTranslatedLabel" :key="sportTranslatedLabel"
:useImperialUnits="user.imperial_units"
/> />
</div> </div>
</div> </div>
@ -40,7 +41,8 @@
getRecordsBySports( getRecordsBySports(
props.user.records, props.user.records,
translateSports(props.sports, t), translateSports(props.sports, t),
props.user.timezone props.user.timezone,
props.user.imperial_units
) )
) )
</script> </script>

View File

@ -7,8 +7,8 @@
/> />
<StatCard <StatCard
icon="road" icon="road"
:value="Number(user.total_distance).toFixed(2)" :value="totalDistance"
:text="$t('workouts.KM')" :text="unitTo === 'mi' ? 'miles' : unitTo"
/> />
<StatCard <StatCard
icon="clock-o" icon="clock-o"
@ -28,7 +28,9 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import StatCard from '@/components/Common/StatCard.vue' import StatCard from '@/components/Common/StatCard.vue'
import { TUnit } from '@/types/units'
import { IUserProfile } from '@/types/user' import { IUserProfile } from '@/types/user'
import { convertDistance, units } from '@/utils/units'
interface Props { interface Props {
user: IUserProfile user: IUserProfile
} }
@ -41,6 +43,13 @@
() => props.user.total_duration () => props.user.total_duration
) )
const totalDuration = computed(() => get_duration(userTotalDuration)) const totalDuration = computed(() => get_duration(userTotalDuration))
const defaultUnitFrom: TUnit = 'km'
const unitTo: TUnit = user.value.imperial_units
? units[defaultUnitFrom].defaultTarget
: defaultUnitFrom
const totalDistance = user.value.imperial_units
? convertDistance(user.value.total_distance, defaultUnitFrom, unitTo, 2)
: parseFloat(user.value.total_distance.toFixed(2))
function get_duration(total_duration: ComputedRef<string>) { function get_duration(total_duration: ComputedRef<string>) {
const duration = total_duration.value.match(/day/g) const duration = total_duration.value.match(/day/g)

View File

@ -11,10 +11,16 @@
</span> </span>
</div> </div>
<div class="user-stat"> <div class="user-stat">
<span class="stat-number">{{ <Distance
Number(user.total_distance).toFixed(0) :distance="user.total_distance"
}}</span> unitFrom="km"
<span class="stat-label">km</span> :digits="0"
:displayUnit="false"
:useImperialUnits="user.imperial_units"
/>
<span class="stat-label">
{{ user.imperial_units ? 'miles' : 'km' }}
</span>
</div> </div>
<div class="user-stat hide-small"> <div class="user-stat hide-small">
<span class="stat-number">{{ user.nb_sports }}</span> <span class="stat-number">{{ user.nb_sports }}</span>
@ -72,6 +78,7 @@
.stat-label { .stat-label {
padding: 0 $default-padding * 0.5; padding: 0 $default-padding * 0.5;
} }
::v-deep(.distance),
.stat-number { .stat-number {
font-weight: bold; font-weight: bold;
font-size: 1.5em; font-size: 1.5em;
@ -87,6 +94,7 @@
.user-stats { .user-stats {
gap: $default-padding * 2; gap: $default-padding * 2;
.user-stat { .user-stat {
::v-deep(.distance),
.stat-number { .stat-number {
font-weight: bold; font-weight: bold;
font-size: 1.2em; font-size: 1.2em;

View File

@ -7,6 +7,14 @@
<dd>{{ timezone }}</dd> <dd>{{ timezone }}</dd>
<dt>{{ $t('user.PROFILE.FIRST_DAY_OF_WEEK') }}:</dt> <dt>{{ $t('user.PROFILE.FIRST_DAY_OF_WEEK') }}:</dt>
<dd>{{ $t(`user.PROFILE.${fistDayOfWeek}`) }}</dd> <dd>{{ $t(`user.PROFILE.${fistDayOfWeek}`) }}</dd>
<dt>{{ $t('user.PROFILE.UNITS.LABEL') }}:</dt>
<dd>
{{
$t(
`user.PROFILE.UNITS.${user.imperial_units ? 'IMPERIAL' : 'METRIC'}`
)
}}
</dd>
</dl> </dl>
<div class="profile-buttons"> <div class="profile-buttons">
<button @click="$router.push('/profile/edit/preferences')"> <button @click="$router.push('/profile/edit/preferences')">

View File

@ -35,6 +35,22 @@
</option> </option>
</select> </select>
</label> </label>
<label class="form-items">
{{ $t('user.PROFILE.UNITS.LABEL') }}
<select
id="imperial_units"
v-model="userForm.imperial_units"
:disabled="loading"
>
<option
v-for="unit in imperialUnits"
:value="unit.value"
:key="unit.value"
>
{{ $t(`user.PROFILE.UNITS.${unit.label}`) }}
</option>
</select>
</label>
<div class="form-buttons"> <div class="form-buttons">
<button class="confirm" type="submit"> <button class="confirm" type="submit">
{{ $t('buttons.SUBMIT') }} {{ $t('buttons.SUBMIT') }}
@ -68,6 +84,7 @@
const store = useStore() const store = useStore()
const userForm: IUserPreferencesPayload = reactive({ const userForm: IUserPreferencesPayload = reactive({
imperial_units: false,
language: '', language: '',
timezone: 'Europe/Paris', timezone: 'Europe/Paris',
weekm: false, weekm: false,
@ -82,6 +99,16 @@
value: false, value: false,
}, },
] ]
const imperialUnits = [
{
label: 'IMPERIAL',
value: true,
},
{
label: 'METRIC',
value: false,
},
]
const loading = computed( const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING] () => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
) )
@ -96,6 +123,7 @@
}) })
function updateUserForm(user: IUserProfile) { function updateUserForm(user: IUserProfile) {
userForm.imperial_units = user.imperial_units ? user.imperial_units : false
userForm.language = user.language ? user.language : 'en' userForm.language = user.language ? user.language : 'en'
userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris' userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris'
userForm.weekm = user.weekm ? user.weekm : false userForm.weekm = user.weekm ? user.weekm : false

View File

@ -87,7 +87,13 @@
</div> </div>
<div class="data"> <div class="data">
<i class="fa fa-road" aria-hidden="true" /> <i class="fa fa-road" aria-hidden="true" />
<span v-if="workout">{{ workout.distance }} km</span> <Distance
v-if="workout.id"
:distance="workout.distance"
:digits="3"
unitFrom="km"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
<div class="data elevation" v-if="workout && workout.with_gpx"> <div class="data elevation" v-if="workout && workout.with_gpx">
<img <img
@ -96,15 +102,37 @@
:alt="$t('workouts.ELEVATION')" :alt="$t('workouts.ELEVATION')"
/> />
<div class="data-values"> <div class="data-values">
<span>{{ workout.min_alt }}/</span> <Distance
<span>{{ workout.max_alt }} m </span> v-if="workout.id"
:distance="workout.min_alt"
unitFrom="m"
:displayUnit="false"
:useImperialUnits="useImperialUnits"
/>/
<Distance
v-if="workout.id"
:distance="workout.max_alt"
unitFrom="m"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
</div> </div>
<div class="data altitude" v-if="workout && workout.with_gpx"> <div class="data altitude" v-if="workout && workout.with_gpx">
<i class="fa fa-location-arrow" aria-hidden="true" /> <i class="fa fa-location-arrow" aria-hidden="true" />
<div class="data-values"> <div class="data-values">
<span>+ {{ workout.ascent }}/</span> +<Distance
<span>- {{ workout.descent }} m </span> v-if="workout.id"
:distance="workout.ascent"
unitFrom="m"
:displayUnit="false"
:useImperialUnits="useImperialUnits"
/>/-
<Distance
v-if="workout.id"
:distance="workout.descent"
unitFrom="m"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
</div> </div>
</div> </div>
@ -127,6 +155,7 @@
interface Props { interface Props {
user: IUserProfile user: IUserProfile
useImperialUnits: boolean
workout?: IWorkout workout?: IWorkout
sport?: ISport sport?: ISport
} }
@ -137,7 +166,7 @@
const store = useStore() const store = useStore()
const { user, workout, sport } = toRefs(props) const { user, workout, sport, useImperialUnits } = toRefs(props)
const locale: ComputedRef<Locale> = computed( const locale: ComputedRef<Locale> = computed(
() => store.getters[ROOT_STORE.GETTERS.LOCALE] () => store.getters[ROOT_STORE.GETTERS.LOCALE]
) )

View File

@ -55,12 +55,14 @@
import { LineChart, useLineChart } from 'vue-chart-3' import { LineChart, useLineChart } from 'vue-chart-3'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { TUnit } from '@/types/units'
import { IUserProfile } from '@/types/user' import { IUserProfile } from '@/types/user'
import { import {
IWorkoutChartData, IWorkoutChartData,
IWorkoutData, IWorkoutData,
TCoordinates, TCoordinates,
} from '@/types/workouts' } from '@/types/workouts'
import { units } from '@/utils/units'
import { getDatasets } from '@/utils/workouts' import { getDatasets } from '@/utils/workouts'
interface Props { interface Props {
@ -76,8 +78,10 @@
let displayDistance = ref(true) let displayDistance = ref(true)
let beginElevationAtZero = ref(true) let beginElevationAtZero = ref(true)
const datasets: ComputedRef<IWorkoutChartData> = computed(() => const datasets: ComputedRef<IWorkoutChartData> = computed(() =>
getDatasets(props.workoutData.chartData, t) getDatasets(props.workoutData.chartData, t, props.authUser.imperial_units)
) )
const fromKmUnit = getUnitTo('km')
const fromMUnit = getUnitTo('m')
let chartData: ComputedRef<ChartData<'line'>> = computed(() => ({ let chartData: ComputedRef<ChartData<'line'>> = computed(() => ({
labels: displayDistance.value labels: displayDistance.value
? datasets.value.distance_labels ? datasets.value.distance_labels
@ -119,7 +123,7 @@
title: { title: {
display: true, display: true,
text: displayDistance.value text: displayDistance.value
? t('workouts.DISTANCE') + ' (km)' ? t('workouts.DISTANCE') + ` (${fromKmUnit})`
: t('workouts.DURATION'), : t('workouts.DURATION'),
}, },
}, },
@ -130,7 +134,7 @@
position: 'left', position: 'left',
title: { title: {
display: true, display: true,
text: t('workouts.SPEED') + ' (km/h)', text: t('workouts.SPEED') + ` (${fromKmUnit}/h)`,
}, },
}, },
yElevation: { yElevation: {
@ -141,7 +145,7 @@
position: 'right', position: 'right',
title: { title: {
display: true, display: true,
text: t('workouts.ELEVATION') + ' (m)', text: t('workouts.ELEVATION') + ` (${fromMUnit})`,
}, },
}, },
}, },
@ -164,8 +168,8 @@
label: function (context) { label: function (context) {
const label = ` ${context.dataset.label}: ${context.formattedValue}` const label = ` ${context.dataset.label}: ${context.formattedValue}`
return context.dataset.yAxisID === 'yElevation' return context.dataset.yAxisID === 'yElevation'
? label + ' m' ? label + ` ${fromMUnit}`
: label + ' km/h' : label + ` ${fromKmUnit}/h`
}, },
title: function (tooltipItems) { title: function (tooltipItems) {
if (tooltipItems.length > 0) { if (tooltipItems.length > 0) {
@ -174,7 +178,9 @@
return tooltipItems.length === 0 return tooltipItems.length === 0
? '' ? ''
: displayDistance.value : displayDistance.value
? `${t('workouts.DISTANCE')}: ${tooltipItems[0].label} km` ? `${t('workouts.DISTANCE')}: ${
tooltipItems[0].label
} ${fromKmUnit}`
: `${t('workouts.DURATION')}: ${formatDuration( : `${t('workouts.DURATION')}: ${formatDuration(
tooltipItems[0].label.replace(',', '') tooltipItems[0].label.replace(',', '')
)}` )}`
@ -200,6 +206,11 @@
function emitEmptyCoordinates() { function emitEmptyCoordinates() {
emitCoordinates({ latitude: null, longitude: null }) emitCoordinates({ latitude: null, longitude: null })
} }
function getUnitTo(unitFrom: TUnit): TUnit {
return props.authUser.imperial_units
? units[unitFrom].defaultTarget
: unitFrom
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -2,27 +2,48 @@
<div id="workout-info"> <div id="workout-info">
<div class="workout-data"> <div class="workout-data">
<i class="fa fa-clock-o" aria-hidden="true" /> <i class="fa fa-clock-o" aria-hidden="true" />
{{ $t('workouts.DURATION') }}: <span>{{ workoutObject.moving }}</span> <span class="label"> {{ $t('workouts.DURATION') }} </span>:
<span class="value">{{ workoutObject.moving }}</span>
<WorkoutRecord :workoutObject="workoutObject" recordType="LD" /> <WorkoutRecord :workoutObject="workoutObject" recordType="LD" />
<div v-if="withPause"> <div v-if="withPause">
({{ $t('workouts.PAUSES') }}: <span>{{ workoutObject.pauses }}</span> - ({{ $t('workouts.PAUSES') }}:
<span class="value">{{ workoutObject.pauses }}</span> -
{{ $t('workouts.TOTAL_DURATION') }}: {{ $t('workouts.TOTAL_DURATION') }}:
<span>{{ workoutObject.duration }})</span> <span class="value">{{ workoutObject.duration }})</span>
</div> </div>
</div> </div>
<div class="workout-data"> <div class="workout-data">
<i class="fa fa-road" aria-hidden="true" /> <i class="fa fa-road" aria-hidden="true" />
{{ $t('workouts.DISTANCE') }}: <span class="label"> {{ $t('workouts.DISTANCE') }} </span>:
<span>{{ workoutObject.distance }} km</span> <Distance
:distance="workoutObject.distance"
:digits="3"
unitFrom="km"
:strong="true"
:useImperialUnits="useImperialUnits"
/>
<WorkoutRecord :workoutObject="workoutObject" recordType="FD" /> <WorkoutRecord :workoutObject="workoutObject" recordType="FD" />
</div> </div>
<div class="workout-data"> <div class="workout-data">
<i class="fa fa-tachometer" aria-hidden="true" /> <i class="fa fa-tachometer" aria-hidden="true" />
{{ $t('workouts.AVERAGE_SPEED') }}: <span class="label">{{ $t('workouts.AVERAGE_SPEED') }}</span
<span>{{ workoutObject.aveSpeed }} km/h</span >:
><WorkoutRecord :workoutObject="workoutObject" recordType="AS" /><br /> <Distance
{{ $t('workouts.MAX_SPEED') }}: :distance="workoutObject.aveSpeed"
<span>{{ workoutObject.maxSpeed }} km/h</span> unitFrom="km"
:speed="true"
:strong="true"
:useImperialUnits="useImperialUnits"
/>
<WorkoutRecord :workoutObject="workoutObject" recordType="AS" /><br />
<span class="label"> {{ $t('workouts.MAX_SPEED') }} </span>:
<Distance
:distance="workoutObject.maxSpeed"
unitFrom="km"
:speed="true"
:strong="true"
:useImperialUnits="useImperialUnits"
/>
<WorkoutRecord :workoutObject="workoutObject" recordType="MS" /> <WorkoutRecord :workoutObject="workoutObject" recordType="MS" />
</div> </div>
<div <div
@ -34,21 +55,48 @@
src="/img/workouts/mountains.svg" src="/img/workouts/mountains.svg"
:alt="$t('workouts.ELEVATION')" :alt="$t('workouts.ELEVATION')"
/> />
{{ $t('workouts.MIN_ALTITUDE') }}: <span class="label">{{ $t('workouts.MIN_ALTITUDE') }}</span
<span>{{ workoutObject.minAlt }} m</span><br /> >:
{{ $t('workouts.MAX_ALTITUDE') }}: <Distance
<span>{{ workoutObject.maxAlt }} m</span> :distance="workoutObject.minAlt"
unitFrom="m"
:strong="true"
:useImperialUnits="useImperialUnits"
/><br />
<span class="label">{{ $t('workouts.MAX_ALTITUDE') }}</span
>:
<Distance
:distance="workoutObject.maxAlt"
unitFrom="m"
:strong="true"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
<div <div
class="workout-data" class="workout-data"
v-if="workoutObject.ascent !== null && workoutObject.descent !== null" v-if="workoutObject.ascent !== null && workoutObject.descent !== null"
> >
<i class="fa fa-location-arrow" aria-hidden="true" /> <i class="fa fa-location-arrow" aria-hidden="true" />
{{ $t('workouts.ASCENT') }}: <span>{{ workoutObject.ascent }} m</span <span class="label">{{ $t('workouts.ASCENT') }}</span
><br /> >:
{{ $t('workouts.DESCENT') }}: <span>{{ workoutObject.descent }} m</span> <Distance
:distance="workoutObject.ascent"
unitFrom="m"
:strong="true"
:useImperialUnits="useImperialUnits"
/><br />
<span class="label"> {{ $t('workouts.DESCENT') }} </span>:
<Distance
:distance="workoutObject.descent"
unitFrom="m"
:strong="true"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
<WorkoutWeather :workoutObject="workoutObject" /> <WorkoutWeather
:workoutObject="workoutObject"
:useImperialUnits="useImperialUnits"
/>
</div> </div>
</template> </template>
@ -61,10 +109,11 @@
interface Props { interface Props {
workoutObject: IWorkoutObject workoutObject: IWorkoutObject
useImperialUnits: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const { workoutObject } = toRefs(props) const { workoutObject, useImperialUnits } = toRefs(props)
const withPause = computed( const withPause = computed(
() => () =>
props.workoutObject.pauses !== '0:00:00' && props.workoutObject.pauses !== '0:00:00' &&
@ -80,11 +129,17 @@
padding: $default-padding $default-padding * 2; padding: $default-padding $default-padding * 2;
width: 100%; width: 100%;
.workout-data { .fa,
text-transform: capitalize; .mountains {
padding: $default-padding * 0.5 0; padding-right: $default-padding * 0.5;
}
span { .workout-data {
padding: $default-padding * 0.5 0;
.label {
text-transform: capitalize;
}
.value {
font-weight: bold; font-weight: bold;
text-transform: lowercase; text-transform: lowercase;
} }

View File

@ -89,8 +89,26 @@
:title="$t(`workouts.WEATHER.WIND`)" :title="$t(`workouts.WEATHER.WIND`)"
/> />
</td> </td>
<td>{{ Number(workoutObject.weatherStart.wind).toFixed(1) }}m/s</td> <td>
<td>{{ Number(workoutObject.weatherEnd.wind).toFixed(1) }}m/s</td> <Distance
:distance="workoutObject.weatherStart.wind"
unitFrom="m"
:digits="1"
:displayUnit="false"
:useImperialUnits="useImperialUnits"
/>
{{ useImperialUnits ? 'ft' : 'm' }}/s
</td>
<td>
<Distance
:distance="workoutObject.weatherEnd.wind"
unitFrom="m"
:digits="1"
:displayUnit="false"
:useImperialUnits="useImperialUnits"
/>
{{ useImperialUnits ? 'ft' : 'm' }}/s
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -104,10 +122,11 @@
interface Props { interface Props {
workoutObject: IWorkoutObject workoutObject: IWorkoutObject
useImperialUnits: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const { workoutObject } = toRefs(props) const { useImperialUnits, workoutObject } = toRefs(props)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -20,7 +20,10 @@
:workoutData="workoutData" :workoutData="workoutData"
:markerCoordinates="markerCoordinates" :markerCoordinates="markerCoordinates"
/> />
<WorkoutData :workoutObject="workoutObject" /> <WorkoutData
:workoutObject="workoutObject"
:useImperialUnits="authUser.imperial_units"
/>
</template> </template>
</Card> </Card>
</div> </div>
@ -68,7 +71,7 @@
const route = useRoute() const route = useRoute()
const store = useStore() const store = useStore()
const { markerCoordinates, workoutData } = toRefs(props) const { authUser, markerCoordinates, workoutData } = toRefs(props)
const workout: ComputedRef<IWorkout> = computed( const workout: ComputedRef<IWorkout> = computed(
() => props.workoutData.workout () => props.workoutData.workout
) )

View File

@ -137,7 +137,7 @@
class="workout-duration" class="workout-duration"
type="text" type="text"
placeholder="HH" placeholder="HH"
pattern="^([0-9]*[0-9])$" pattern="^([0-1]?[0-9]|2[0-3])$"
required required
@invalid="invalidateForm" @invalid="invalidateForm"
:disabled="loading" :disabled="loading"
@ -173,12 +173,16 @@
</div> </div>
</div> </div>
<div class="form-item"> <div class="form-item">
<label>{{ $t('workouts.DISTANCE') }} (km):</label> <label>
{{ $t('workouts.DISTANCE') }} ({{
authUser.imperial_units ? 'mi' : 'km'
}}):
</label>
<input <input
name="workout-distance" name="workout-distance"
type="number" type="number"
min="0" min="0"
step="0.01" step="0.001"
required required
@invalid="invalidateForm" @invalid="invalidateForm"
:disabled="loading" :disabled="loading"
@ -239,6 +243,7 @@
import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates' import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates'
import { getReadableFileSize } from '@/utils/files' import { getReadableFileSize } from '@/utils/files'
import { translateSports } from '@/utils/sports' import { translateSports } from '@/utils/sports'
import { convertDistance } from '@/utils/units'
interface Props { interface Props {
authUser: IUserProfile authUser: IUserProfile
@ -257,7 +262,7 @@
const store = useStore() const store = useStore()
const router = useRouter() const router = useRouter()
const { workout, isCreation, loading } = toRefs(props) const { authUser, workout, isCreation, loading } = toRefs(props)
const translatedSports: ComputedRef<ISport[]> = computed(() => const translatedSports: ComputedRef<ISport[]> = computed(() =>
translateSports( translateSports(
props.sports, props.sports,
@ -324,7 +329,11 @@
'yyyy-MM-dd' 'yyyy-MM-dd'
) )
const duration = workout.duration.split(':') const duration = workout.duration.split(':')
workoutForm.workoutDistance = `${workout.distance}` workoutForm.workoutDistance = `${
authUser.value.imperial_units
? convertDistance(workout.distance, 'km', 'mi', 2)
: parseFloat(workout.distance.toFixed(2))
}`
workoutForm.workoutDate = workoutDateTime.workout_date workoutForm.workoutDate = workoutDateTime.workout_date
workoutForm.workoutTime = workoutDateTime.workout_time workoutForm.workoutTime = workoutDateTime.workout_time
workoutForm.workoutDurationHour = duration[0] workoutForm.workoutDurationHour = duration[0]
@ -334,7 +343,9 @@
} }
function formatPayload(payload: IWorkoutForm) { function formatPayload(payload: IWorkoutForm) {
payload.title = workoutForm.title payload.title = workoutForm.title
payload.distance = +workoutForm.workoutDistance payload.distance = authUser.value.imperial_units
? convertDistance(+workoutForm.workoutDistance, 'mi', 'km', 3)
: +workoutForm.workoutDistance
payload.duration = payload.duration =
+workoutForm.workoutDurationHour * 3600 + +workoutForm.workoutDurationHour * 3600 +
+workoutForm.workoutDurationMinutes * 60 + +workoutForm.workoutDurationMinutes * 60 +

View File

@ -15,8 +15,12 @@
}" }"
>{{ $t('workouts.SEGMENT', 1) }} {{ index + 1 }}</router-link >{{ $t('workouts.SEGMENT', 1) }} {{ index + 1 }}</router-link
> >
({{ $t('workouts.DISTANCE') }}: {{ segment.distance }} km, ({{ $t('workouts.DISTANCE') }}:
{{ $t('workouts.DURATION') }}: {{ segment.duration }}) <Distance
:distance="segment.distance"
unitFrom="km"
:useImperialUnits="useImperialUnits"
/>, {{ $t('workouts.DURATION') }}: {{ segment.duration }})
</li> </li>
</ul> </ul>
</template> </template>
@ -31,10 +35,11 @@
interface Props { interface Props {
segments: IWorkoutSegment[] segments: IWorkoutSegment[]
useImperialUnits: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const { segments } = toRefs(props) const { segments, useImperialUnits } = toRefs(props)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -47,7 +47,7 @@
<div class="form-items-group"> <div class="form-items-group">
<div class="form-item"> <div class="form-item">
<label> {{ $t('workouts.DISTANCE') }} (km): </label> <label> {{ $t('workouts.DISTANCE') }} ({{ toUnit }}): </label>
<div class="form-inputs-group"> <div class="form-inputs-group">
<input <input
name="distance_from" name="distance_from"
@ -72,7 +72,7 @@
<div class="form-items-group"> <div class="form-items-group">
<div class="form-item"> <div class="form-item">
<label> {{ $t('workouts.DURATION') }} (km): </label> <label> {{ $t('workouts.DURATION') }} ({{ toUnit }}): </label>
<div class="form-inputs-group"> <div class="form-inputs-group">
<input <input
name="duration_from" name="duration_from"
@ -97,7 +97,7 @@
<div class="form-items-group"> <div class="form-items-group">
<div class="form-item"> <div class="form-item">
<label> {{ $t('workouts.AVE_SPEED') }} (km): </label> <label> {{ $t('workouts.AVE_SPEED') }} ({{ toUnit }}): </label>
<div class="form-inputs-group"> <div class="form-inputs-group">
<input <input
min="0" min="0"
@ -122,7 +122,7 @@
<div class="form-items-group"> <div class="form-items-group">
<div class="form-item"> <div class="form-item">
<label> {{ $t('workouts.MAX_SPEED') }} (km): </label> <label> {{ $t('workouts.MAX_SPEED') }} ({{ toUnit }}): </label>
<div class="form-inputs-group"> <div class="form-inputs-group">
<input <input
@ -167,6 +167,7 @@
import { ISport } from '@/types/sports' import { ISport } from '@/types/sports'
import { IUserProfile } from '@/types/user' import { IUserProfile } from '@/types/user'
import { translateSports } from '@/utils/sports' import { translateSports } from '@/utils/sports'
import { units } from '@/utils/units'
interface Props { interface Props {
authUser: IUserProfile authUser: IUserProfile
@ -181,6 +182,10 @@
const router = useRouter() const router = useRouter()
const { authUser } = toRefs(props) const { authUser } = toRefs(props)
const toUnit = authUser.value.imperial_units
? units['km'].defaultTarget
: 'km'
const translatedSports: ComputedRef<ISport[]> = computed(() => const translatedSports: ComputedRef<ISport[]> = computed(() =>
translateSports(props.sports, t) translateSports(props.sports, t)
) )

View File

@ -45,8 +45,9 @@
{{ $t('workouts.SPORT', 1) }} {{ $t('workouts.SPORT', 1) }}
</span> </span>
<SportImage <SportImage
v-if="sports.length > 0"
:title=" :title="
sports.filter((s) => s.id === workout.sport_id)[0] sports.find((s) => s.id === workout.sport_id)
.translatedLabel .translatedLabel
" "
:sport-label="getSportLabel(workout, sports)" :sport-label="getSportLabel(workout, sports)"
@ -93,7 +94,11 @@
<span class="cell-heading"> <span class="cell-heading">
{{ $t('workouts.DISTANCE') }} {{ $t('workouts.DISTANCE') }}
</span> </span>
{{ Number(workout.distance).toFixed(2) }} km <Distance
:distance="workout.distance"
unitFrom="km"
:useImperialUnits="user.imperial_units"
/>
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="cell-heading"> <span class="cell-heading">
@ -105,25 +110,45 @@
<span class="cell-heading"> <span class="cell-heading">
{{ $t('workouts.AVE_SPEED') }} {{ $t('workouts.AVE_SPEED') }}
</span> </span>
{{ workout.ave_speed }} km/h <Distance
:distance="workout.ave_speed"
unitFrom="km"
:speed="true"
:useImperialUnits="user.imperial_units"
/>
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('workouts.MAX_SPEED') }} {{ $t('workouts.MAX_SPEED') }}
</span> </span>
{{ workout.max_speed }} km/h <Distance
:distance="workout.max_speed"
unitFrom="km"
:speed="true"
:useImperialUnits="user.imperial_units"
/>
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('workouts.ASCENT') }} {{ $t('workouts.ASCENT') }}
</span> </span>
<span v-if="workout.with_gpx">{{ workout.ascent }} m</span> <Distance
v-if="workout.with_gpx"
:distance="workout.ascent"
unitFrom="m"
:useImperialUnits="user.imperial_units"
/>
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="cell-heading"> <span class="cell-heading">
{{ $t('workouts.DESCENT') }} {{ $t('workouts.DESCENT') }}
</span> </span>
<span v-if="workout.with_gpx">{{ workout.descent }} m</span> <Distance
v-if="workout.with_gpx"
:distance="workout.descent"
unitFrom="m"
:useImperialUnits="user.imperial_units"
/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -163,6 +188,7 @@
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 { getSportColor, getSportLabel } from '@/utils/sports'
import { convertDistance } from '@/utils/units'
import { defaultOrder } from '@/utils/workouts' import { defaultOrder } from '@/utils/workouts'
interface Props { interface Props {
@ -196,7 +222,10 @@
}) })
function loadWorkouts(payload: TWorkoutsPayload) { function loadWorkouts(payload: TWorkoutsPayload) {
store.dispatch(WORKOUTS_STORE.ACTIONS.GET_USER_WORKOUTS, payload) store.dispatch(
WORKOUTS_STORE.ACTIONS.GET_USER_WORKOUTS,
user.value.imperial_units ? getConvertedPayload(payload) : payload
)
} }
function reloadWorkouts(queryParam: string, queryValue: string) { function reloadWorkouts(queryParam: string, queryValue: string) {
const newQuery: LocationQuery = Object.assign({}, route.query) const newQuery: LocationQuery = Object.assign({}, route.query)
@ -224,6 +253,18 @@
return query return query
} }
function getConvertedPayload(payload: TWorkoutsPayload): TWorkoutsPayload {
const convertedPayload: TWorkoutsPayload = {
...payload,
}
Object.entries(convertedPayload).map((entry) => {
if (entry[0].match('speed|distance')) {
convertedPayload[entry[0]] = convertDistance(+entry[1], 'mi', 'km')
}
})
return convertedPayload
}
function onHover(workoutId: string | null) { function onHover(workoutId: string | null) {
hoverWorkoutId.value = workoutId hoverWorkoutId.value = workoutId
} }

View File

@ -1,6 +1,7 @@
import AlertMessage from '@/components/Common/AlertMessage.vue' import AlertMessage from '@/components/Common/AlertMessage.vue'
import Card from '@/components/Common/Card.vue' import Card from '@/components/Common/Card.vue'
import CustomTextArea from '@/components/Common/CustomTextArea.vue' import CustomTextArea from '@/components/Common/CustomTextArea.vue'
import Distance from '@/components/Common/Distance.vue'
import Dropdown from '@/components/Common/Dropdown.vue' import Dropdown from '@/components/Common/Dropdown.vue'
import ErrorMessage from '@/components/Common/ErrorMessage.vue' import ErrorMessage from '@/components/Common/ErrorMessage.vue'
import SportImage from '@/components/Common/Images/SportImage/index.vue' import SportImage from '@/components/Common/Images/SportImage/index.vue'
@ -11,6 +12,7 @@ export const customComponents = [
{ target: AlertMessage, name: 'AlertMessage' }, { target: AlertMessage, name: 'AlertMessage' },
{ target: Card, name: 'Card' }, { target: Card, name: 'Card' },
{ target: CustomTextArea, name: 'CustomTextArea' }, { target: CustomTextArea, name: 'CustomTextArea' },
{ target: Distance, name: 'Distance' },
{ target: Dropdown, name: 'Dropdown' }, { target: Dropdown, name: 'Dropdown' },
{ target: ErrorMessage, name: 'ErrorMessage' }, { target: ErrorMessage, name: 'ErrorMessage' },
{ target: Loader, name: 'Loader' }, { target: Loader, name: 'Loader' },

View File

@ -53,6 +53,11 @@
"LABEL": "label", "LABEL": "label",
"STOPPED_SPEED_THRESHOLD": "stopped speed threshold" "STOPPED_SPEED_THRESHOLD": "stopped speed threshold"
}, },
"UNITS": {
"LABEL": "Units for distance",
"IMPERIAL": "Imperial system (ft, mi)",
"METRIC": "Metric system (m, km)"
},
"TIMEZONE": "Timezone" "TIMEZONE": "Timezone"
}, },
"REGISTER": "Register", "REGISTER": "Register",

View File

@ -16,7 +16,6 @@
"FROM": "from", "FROM": "from",
"GPX_FILE": ".gpx file", "GPX_FILE": ".gpx file",
"HIDE_FILTERS": "hide filters", "HIDE_FILTERS": "hide filters",
"KM": "km",
"LATEST_WORKOUTS": "Latest workouts", "LATEST_WORKOUTS": "Latest workouts",
"LOAD_MORE_WORKOUT": "Load more workouts", "LOAD_MORE_WORKOUT": "Load more workouts",
"MAX_ALTITUDE": "max. altitude", "MAX_ALTITUDE": "max. altitude",

View File

@ -45,6 +45,11 @@
"PROFILE": "profil", "PROFILE": "profil",
"SPORTS": "sports" "SPORTS": "sports"
}, },
"UNITS": {
"LABEL": "Unités pour les distances ",
"IMPERIAL": "Système impérial (ft, mi)",
"METRIC": "Système métrique (m, km)"
},
"SPORT": { "SPORT": {
"ACTION": "action", "ACTION": "action",
"COLOR": "couleur", "COLOR": "couleur",

View File

@ -16,7 +16,6 @@
"FROM": "à partir de", "FROM": "à partir de",
"GPX_FILE": "fichier .gpx", "GPX_FILE": "fichier .gpx",
"HIDE_FILTERS": "masquer les filtres", "HIDE_FILTERS": "masquer les filtres",
"KM": "km",
"LATEST_WORKOUTS": "Séances récentes", "LATEST_WORKOUTS": "Séances récentes",
"LOAD_MORE_WORKOUT": "Charger les séances suivantes", "LOAD_MORE_WORKOUT": "Charger les séances suivantes",
"MAX_ALTITUDE": "altitude max", "MAX_ALTITUDE": "altitude max",

View File

@ -0,0 +1,14 @@
export type TUnitSystem = 'imperial' | 'metric'
export type TUnit = 'ft' | 'mi' | 'm' | 'km'
export type TFactor = {
[k in string]: Record<string, number>
}
export interface IUnit {
unit: TUnit
system: TUnitSystem
multiplier: number
defaultTarget: TUnit
}

View File

@ -9,6 +9,7 @@ export interface IUserProfile {
created_at: string created_at: string
email: string email: string
first_name: string | null first_name: string | null
imperial_units: boolean
language: string | null language: string | null
last_name: string | null last_name: string | null
location: string | null location: string | null
@ -40,6 +41,7 @@ export interface IAdminUserPayload {
} }
export interface IUserPreferencesPayload { export interface IUserPreferencesPayload {
imperial_units: boolean
language: string language: string
timezone: string timezone: string
weekm: boolean weekm: boolean

View File

@ -1,19 +1,31 @@
import { ITranslatedSport } from '@/types/sports' import { ITranslatedSport } from '@/types/sports'
import { TUnit } from '@/types/units'
import { IRecord, IRecordsBySports } from '@/types/workouts' import { IRecord, IRecordsBySports } from '@/types/workouts'
import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates' import { formatWorkoutDate, getDateWithTZ } from '@/utils/dates'
import { convertDistance, units } from '@/utils/units'
export const formatRecord = ( export const formatRecord = (
record: IRecord, record: IRecord,
tz: string tz: string,
useImperialUnits: boolean
): Record<string, string | number> => { ): Record<string, string | number> => {
const unitFrom: TUnit = 'km'
const unitTo: TUnit = useImperialUnits
? units[unitFrom].defaultTarget
: unitFrom
let value let value
switch (record.record_type) { switch (record.record_type) {
case 'AS': case 'AS':
case 'MS': case 'MS':
value = `${record.value} km/h` value = `${convertDistance(
+record.value,
unitFrom,
unitTo,
2
)} ${unitTo}/h`
break break
case 'FD': case 'FD':
value = `${record.value} km` value = `${convertDistance(+record.value, unitFrom, unitTo, 3)} ${unitTo}`
break break
case 'LD': case 'LD':
value = record.value value = record.value
@ -36,7 +48,8 @@ export const formatRecord = (
export const getRecordsBySports = ( export const getRecordsBySports = (
records: IRecord[], records: IRecord[],
translatedSports: ITranslatedSport[], translatedSports: ITranslatedSport[],
tz: string tz: string,
useImperialUnits: boolean
): IRecordsBySports => ): IRecordsBySports =>
records.reduce((sportList: IRecordsBySports, record) => { records.reduce((sportList: IRecordsBySports, record) => {
const sport = translatedSports.find((s) => s.id === record.sport_id) const sport = translatedSports.find((s) => s.id === record.sport_id)
@ -48,7 +61,9 @@ export const getRecordsBySports = (
records: [], records: [],
} }
} }
sportList[sport.translatedLabel].records.push(formatRecord(record, tz)) sportList[sport.translatedLabel].records.push(
formatRecord(record, tz, useImperialUnits)
)
} }
return sportList return sportList
}, {}) }, {})

View File

@ -25,6 +25,7 @@ import {
} from '@/types/statistics' } from '@/types/statistics'
import { incrementDate, getStartDate } from '@/utils/dates' import { incrementDate, getStartDate } from '@/utils/dates'
import { sportColors } from '@/utils/sports' import { sportColors } from '@/utils/sports'
import { convertStatsDistance } from '@/utils/units'
const dateFormats: Record<string, Record<string, string>> = { const dateFormats: Record<string, Record<string, string>> = {
week: { week: {
@ -94,12 +95,34 @@ export const getDatasets = (displayedSports: ISport[]): TStatisticsDatasets => {
return datasets return datasets
} }
export const convertStatsValue = (
datasetKey: TStatisticsDatasetKeys,
value: number,
useImperialUnits: boolean
): number => {
switch (datasetKey) {
case 'total_distance':
case 'total_ascent':
case 'total_descent':
return convertStatsDistance(
datasetKey === 'total_distance' ? 'km' : 'm',
value,
useImperialUnits
)
default:
case 'nb_workouts':
case 'total_duration':
return value
}
}
export const formatStats = ( export const formatStats = (
params: IStatisticsDateParams, params: IStatisticsDateParams,
weekStartingMonday: boolean, weekStartingMonday: boolean,
sports: ISport[], sports: ISport[],
displayedSportsId: number[], displayedSportsId: number[],
apiStats: TStatisticsFromApi apiStats: TStatisticsFromApi,
useImperialUnits: boolean
): IStatisticsChartData => { ): IStatisticsChartData => {
const dayKeys = getDateKeys(params, weekStartingMonday) const dayKeys = getDateKeys(params, weekStartingMonday)
const dateFormat = dateFormats[params.duration] const dateFormat = dateFormats[params.duration]
@ -123,7 +146,11 @@ export const formatStats = (
apiStats !== {} && apiStats !== {} &&
date in apiStats && date in apiStats &&
sportsId[dataset.label] in apiStats[date] sportsId[dataset.label] in apiStats[date]
? apiStats[date][sportsId[dataset.label]][datasetKey] ? convertStatsValue(
datasetKey,
apiStats[date][sportsId[dataset.label]][datasetKey],
useImperialUnits
)
: 0 : 0
) )
}) })

View File

@ -1,19 +1,23 @@
import { TStatisticsDatasetKeys } from '@/types/statistics' import { TStatisticsDatasetKeys } from '@/types/statistics'
import { formatDuration } from '@/utils/duration' import { formatDuration } from '@/utils/duration'
import { units } from '@/utils/units'
export const formatTooltipValue = ( export const formatTooltipValue = (
displayedData: TStatisticsDatasetKeys, displayedData: TStatisticsDatasetKeys,
value: number, value: number,
useImperialUnits: boolean,
formatWithUnits = true formatWithUnits = true
): string => { ): string => {
const unitFrom = 'km'
const unitTo = useImperialUnits ? units[unitFrom].defaultTarget : unitFrom
switch (displayedData) { switch (displayedData) {
case 'total_duration': case 'total_duration':
return formatDuration(value, formatWithUnits) return formatDuration(value, formatWithUnits)
case 'total_distance': case 'total_distance':
return value.toFixed(2) + ' km' return `${value.toFixed(2)} ${unitTo}`
case 'total_ascent': case 'total_ascent':
case 'total_descent': case 'total_descent':
return (value / 1000).toFixed(2) + ' km' return `${(value / 1000).toFixed(2)} ${unitTo}`
default: default:
return value.toString() return value.toString()
} }

View File

@ -0,0 +1,65 @@
import { IUnit, TFactor, TUnit } from '@/types/units'
export const units: Record<string, IUnit> = {
ft: {
unit: 'ft',
system: 'imperial',
multiplier: 1,
defaultTarget: 'm',
},
mi: {
unit: 'mi',
system: 'imperial',
multiplier: 5280,
defaultTarget: 'km',
},
m: {
unit: 'm',
system: 'metric',
multiplier: 1,
defaultTarget: 'ft',
},
km: {
unit: 'm',
system: 'metric',
multiplier: 1000,
defaultTarget: 'mi',
},
}
const factors: TFactor = {
metric: {
imperial: 3.280839895,
metric: 1,
},
imperial: {
metric: 1 / 3.280839895,
imperial: 1,
},
}
export const convertDistance = (
distance: number,
from: TUnit,
to: TUnit,
digits: number | null = 3
): number => {
const unitFrom = units[from]
const unitTo = units[to]
const convertedDistance =
(distance * unitFrom.multiplier * factors[unitFrom.system][unitTo.system]) /
unitTo.multiplier
if (digits !== null) {
return parseFloat(convertedDistance.toFixed(digits))
}
return convertedDistance
}
export const convertStatsDistance = (
unitFrom: TUnit,
value: number,
useImperialUnits: boolean
): number => {
const unitTo = useImperialUnits ? units[unitFrom].defaultTarget : unitFrom
return useImperialUnits ? convertDistance(value, unitFrom, unitTo, 2) : value
}

View File

@ -5,10 +5,12 @@ import {
TCoordinates, TCoordinates,
TWorkoutDatasets, TWorkoutDatasets,
} from '@/types/workouts' } from '@/types/workouts'
import { convertStatsDistance } from '@/utils/units'
export const getDatasets = ( export const getDatasets = (
chartData: IWorkoutApiChartData[], chartData: IWorkoutApiChartData[],
t: CallableFunction t: CallableFunction,
useImperialUnits: boolean
): IWorkoutChartData => { ): IWorkoutChartData => {
const datasets: TWorkoutDatasets = { const datasets: TWorkoutDatasets = {
speed: { speed: {
@ -36,8 +38,12 @@ export const getDatasets = (
chartData.map((data) => { chartData.map((data) => {
distance_labels.push(data.distance) distance_labels.push(data.distance)
duration_labels.push(data.duration) duration_labels.push(data.duration)
datasets.speed.data.push(data.speed) datasets.speed.data.push(
datasets.elevation.data.push(data.elevation) convertStatsDistance('km', data.speed, useImperialUnits)
)
datasets.elevation.data.push(
convertStatsDistance('m', data.elevation, useImperialUnits)
)
coordinates.push({ latitude: data.latitude, longitude: data.longitude }) coordinates.push({ latitude: data.latitude, longitude: data.longitude })
}) })

View File

@ -22,6 +22,7 @@
<WorkoutSegments <WorkoutSegments
v-if="!displaySegment && workoutData.workout.segments.length > 1" v-if="!displaySegment && workoutData.workout.segments.length > 1"
:segments="workoutData.workout.segments" :segments="workoutData.workout.segments"
:useImperialUnits="authUser.imperial_units"
/> />
<WorkoutNotes <WorkoutNotes
v-if="!displaySegment" v-if="!displaySegment"

View File

@ -100,7 +100,113 @@ describe('formatRecord', () => {
assert.deepEqual( assert.deepEqual(
formatRecord( formatRecord(
testParams.inputParams.record, testParams.inputParams.record,
testParams.inputParams.timezone testParams.inputParams.timezone,
false
),
testParams.expected
)
})
})
})
describe('formatRecord after conversion', () => {
const testsParams = [
{
description: "return formatted record for 'Average speed'",
inputParams: {
record: {
id: 9,
record_type: 'AS',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 08:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
timezone: 'Europe/Paris',
},
expected: {
id: 9,
record_type: 'AS',
value: '11.18 mi/h',
workout_date: '2019/07/07',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
},
{
description: "return formatted record for 'Farest distance'",
inputParams: {
record: {
id: 10,
record_type: 'FD',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
timezone: 'Europe/Paris',
},
expected: {
id: 10,
record_type: 'FD',
value: '11.185 mi',
workout_date: '2019/07/08',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
},
{
description: "return formatted record for 'Longest duration'",
inputParams: {
record: {
id: 11,
record_type: 'LD',
sport_id: 1,
user: 'admin',
value: '1:01:00',
workout_date: 'Sun, 07 Jul 2019 08:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
timezone: 'Europe/Paris',
},
expected: {
id: 11,
record_type: 'LD',
value: '1:01:00',
workout_date: '2019/07/07',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
},
{
description: "return formatted record for 'Max. speed'",
inputParams: {
record: {
id: 12,
record_type: 'MS',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
timezone: 'Europe/Paris',
},
expected: {
id: 12,
record_type: 'MS',
value: '11.18 mi/h',
workout_date: '2019/07/08',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
},
]
testsParams.map((testParams) => {
it(testParams.description, () => {
assert.deepEqual(
formatRecord(
testParams.inputParams.record,
testParams.inputParams.timezone,
true
), ),
testParams.expected testParams.expected
) )
@ -121,7 +227,8 @@ describe('formatRecord (invalid record type)', () => {
workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT', workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2', workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
}, },
'Europe/Paris' 'Europe/Paris',
false
) )
).to.throw( ).to.throw(
'Invalid record type, expected: "AS", "FD", "LD", "MD", got: "M"' 'Invalid record type, expected: "AS", "FD", "LD", "MD", got: "M"'
@ -248,7 +355,138 @@ describe('getRecordsBySports', () => {
getRecordsBySports( getRecordsBySports(
testParams.input.records, testParams.input.records,
translatedSports, translatedSports,
testParams.input.tz testParams.input.tz,
false
),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
testParams.expected
)
})
)
})
describe('getRecordsBySports after conversion', () => {
const testsParams = [
{
description: 'returns empty object if no records',
input: {
records: [],
tz: 'Europe/Paris',
},
expected: {},
},
{
description: 'returns record grouped by Sport',
input: {
records: [
{
id: 9,
record_type: 'AS',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 08:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
],
tz: 'Europe/Paris',
},
expected: {
'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)',
records: [
{
id: 9,
record_type: 'AS',
value: '11.18 mi/h',
workout_date: '2019/07/07',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
],
},
},
},
{
description: 'returns record grouped by Sport',
input: {
records: [
{
id: 9,
record_type: 'AS',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 08:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
{
id: 10,
record_type: 'FD',
sport_id: 2,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT',
workout_id: 'n6JcLPQt3QtZWFfiSnYm4C',
},
{
id: 12,
record_type: 'MS',
sport_id: 1,
user: 'admin',
value: 18,
workout_date: 'Sun, 07 Jul 2019 08:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
],
tz: 'Europe/Paris',
},
expected: {
'Cycling (Sport)': {
color: null,
label: 'Cycling (Sport)',
records: [
{
id: 9,
record_type: 'AS',
value: '11.18 mi/h',
workout_date: '2019/07/07',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
{
id: 12,
record_type: 'MS',
value: '11.18 mi/h',
workout_date: '2019/07/07',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
],
},
'Cycling (Transport)': {
color: '#000000',
label: 'Cycling (Transport)',
records: [
{
id: 10,
record_type: 'FD',
value: '11.185 mi',
workout_date: '2019/07/08',
workout_id: 'n6JcLPQt3QtZWFfiSnYm4C',
},
],
},
},
},
]
testsParams.map((testParams) =>
it(testParams.description, () => {
assert.deepEqual(
getRecordsBySports(
testParams.input.records,
translatedSports,
testParams.input.tz,
true
), ),
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore

View File

@ -316,7 +316,7 @@ describe('formatStats', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [], inputStats), formatStats(inputParams, false, sports, [], inputStats, false),
expected expected
) )
}) })
@ -369,7 +369,7 @@ describe('formatStats', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [2], inputStats), formatStats(inputParams, false, sports, [2], inputStats, false),
expected expected
) )
}) })
@ -427,7 +427,7 @@ describe('formatStats', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [], inputStats), formatStats(inputParams, false, sports, [], inputStats, false),
expected expected
) )
}) })
@ -515,7 +515,7 @@ describe('formatStats', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats), formatStats(inputParams, false, sports, [1], inputStats, false),
expected expected
) )
}) })
@ -605,7 +605,7 @@ describe('formatStats (duration)', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats), formatStats(inputParams, false, sports, [1], inputStats, false),
expected expected
) )
}) })
@ -692,7 +692,7 @@ describe('formatStats (duration)', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats), formatStats(inputParams, false, sports, [1], inputStats, false),
expected expected
) )
}) })
@ -780,7 +780,7 @@ describe('formatStats (duration)', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats), formatStats(inputParams, false, sports, [1], inputStats, false),
expected expected
) )
}) })
@ -868,7 +868,95 @@ describe('formatStats (duration)', () => {
}, },
} }
assert.deepEqual( assert.deepEqual(
formatStats(inputParams, true, sports, [1], inputStats), formatStats(inputParams, true, sports, [1], inputStats, false),
expected
)
})
it('returns datasets after conversion to imperial units', () => {
const inputStats: TStatisticsFromApi = {
'2021-10-03': {
1: {
nb_workouts: 1,
total_distance: 10,
total_duration: 3000,
total_ascent: 150,
total_descent: 100,
},
},
'2021-10-10': {
1: {
nb_workouts: 1,
total_distance: 15,
total_duration: 3500,
total_ascent: 250,
total_descent: 150,
},
2: {
nb_workouts: 2,
total_distance: 20,
total_duration: 3000,
total_ascent: 150,
total_descent: 200,
},
},
'2021-10-17': {
3: {
nb_workouts: 2,
total_distance: 20,
total_duration: 3000,
total_ascent: 100,
total_descent: 100,
},
},
}
const inputParams = {
duration: 'week',
start: new Date('October 03, 2021 00:00:00'),
end: new Date('October 23, 2021 23:59:59.999'),
}
const expected: IStatisticsChartData = {
labels: ['03/10/2021', '10/10/2021', '17/10/2021'],
datasets: {
nb_workouts: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [1, 1, 0],
},
],
total_distance: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [6.21, 9.32, 0],
},
],
total_duration: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [3000, 3500, 0],
},
],
total_ascent: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [492.13, 820.21, 0],
},
],
total_descent: [
{
label: 'Cycling (Sport)',
backgroundColor: ['#4c9792'],
data: [328.08, 492.13, 0],
},
],
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats, true),
expected expected
) )
}) })

View File

@ -42,7 +42,56 @@ describe('formatTooltipValue', () => {
assert.equal( assert.equal(
formatTooltipValue( formatTooltipValue(
testParams.inputDisplayedData, testParams.inputDisplayedData,
testParams.inputValue testParams.inputValue,
false
),
testParams.expectedResult
)
})
})
})
describe('formatTooltipValue after conversion to imperial units', () => {
const testsParams = [
{
description: 'returns 30 if input is workouts count',
inputDisplayedData: datasetKeys[0], // 'nb_workouts'
inputValue: 30,
expectedResult: '30',
},
{
description: 'returns 00m:03s if input is total duration',
inputDisplayedData: datasetKeys[1], // 'total_duration'
inputValue: 30,
expectedResult: '00m 30s',
},
{
description: 'returns 30 mi if input is total distance',
inputDisplayedData: datasetKeys[2], // 'total_distance'
inputValue: 30,
expectedResult: '30.00 mi',
},
{
description: 'returns 0.03 mi if input is total ascent',
inputDisplayedData: datasetKeys[3], // 'total_distance'
inputValue: 30,
expectedResult: '0.03 mi',
},
{
description: 'returns 0.03 mi if input is total descent',
inputDisplayedData: datasetKeys[4], // 'total_distance'
inputValue: 30,
expectedResult: '0.03 mi',
},
]
testsParams.map((testParams) => {
it(testParams.description, () => {
assert.equal(
formatTooltipValue(
testParams.inputDisplayedData,
testParams.inputValue,
true
), ),
testParams.expectedResult testParams.expectedResult
) )
@ -90,6 +139,7 @@ describe('formatTooltipValue (formatWithUnits = false)', () => {
formatTooltipValue( formatTooltipValue(
testParams.inputDisplayedData, testParams.inputDisplayedData,
testParams.inputValue, testParams.inputValue,
false,
false false
), ),
testParams.expectedResult testParams.expectedResult

View File

@ -0,0 +1,58 @@
import { assert } from 'chai'
import { TUnit } from '@/types/units'
import { convertDistance } from '@/utils/units'
describe('convertDistance', () => {
const testsParams: [number, TUnit, TUnit, number][] = [
[0, 'm', 'ft', 0],
[5, 'm', 'ft', 16.404],
[5, 'm', 'mi', 0.003],
[5, 'm', 'm', 5.0],
[5, 'm', 'km', 0.005],
[5, 'km', 'ft', 16404.199],
[5, 'km', 'mi', 3.107],
[5, 'km', 'm', 5000.0],
[5, 'km', 'km', 5.0],
[5, 'ft', 'ft', 5.0],
[5, 'ft', 'mi', 0.001],
[5, 'ft', 'm', 1.524],
[5, 'ft', 'km', 0.002],
[5, 'mi', 'ft', 26400.0],
[5, 'mi', 'mi', 5.0],
[5, 'mi', 'm', 8046.72],
[5, 'mi', 'km', 8.047],
]
testsParams.map((testParams) => {
it(`convert ${testParams[0]}${testParams[1]} in ${testParams[2]}}`, () => {
assert.equal(
convertDistance(testParams[0], testParams[1], testParams[2]),
testParams[3]
)
})
})
})
describe('convertDistance w/ digits', () => {
const testsParams: [number, TUnit, TUnit, number | null, number][] = [
[5, 'km', 'mi', null, 3.106855961174243],
[5, 'km', 'mi', 0, 3],
[5, 'km', 'mi', 1, 3.1],
[5, 'km', 'mi', 2, 3.11],
]
testsParams.map((testParams) => {
it(`convert ${testParams[0]}${testParams[1]} in ${testParams[2]}}`, () => {
assert.equal(
convertDistance(
testParams[0],
testParams[1],
testParams[2],
testParams[3]
),
testParams[4]
)
})
})
})

View File

@ -13,6 +13,7 @@ describe('getDatasets', () => {
inputParams: { inputParams: {
charData: [], charData: [],
locale: 'fr', locale: 'fr',
useImperialUnits: false,
}, },
expected: { expected: {
distance_labels: [], distance_labels: [],
@ -72,6 +73,7 @@ describe('getDatasets', () => {
}, },
], ],
locale: 'en', locale: 'en',
useImperialUnits: false,
}, },
expected: { expected: {
distance_labels: [0, 0, 0.01], distance_labels: [0, 0, 0.01],
@ -102,12 +104,80 @@ describe('getDatasets', () => {
], ],
}, },
}, },
{
description: 'returns datasets w/ units conversion',
inputParams: {
charData: [
{
distance: 0,
duration: 0,
elevation: 83.6,
latitude: 48.845574,
longitude: 2.373723,
speed: 2.89,
time: 'Sun, 12 Sep 2021 13:29:24 GMT',
},
{
distance: 0,
duration: 1,
elevation: 83.7,
latitude: 48.845578,
longitude: 2.373732,
speed: 1.56,
time: 'Sun, 12 Sep 2021 13:29:25 GMT',
},
{
distance: 0.01,
duration: 96,
elevation: 84.3,
latitude: 48.845591,
longitude: 2.373811,
speed: 14.73,
time: 'Sun, 12 Sep 2021 13:31:00 GMT',
},
],
locale: 'en',
useImperialUnits: true,
},
expected: {
distance_labels: [0, 0, 0.01],
duration_labels: [0, 1, 96],
datasets: {
speed: {
label: 'speed',
backgroundColor: ['#FFFFFF'],
borderColor: ['#8884d8'],
borderWidth: 2,
data: [1.8, 0.97, 9.15],
yAxisID: 'ySpeed',
},
elevation: {
label: 'elevation',
backgroundColor: ['#e5e5e5'],
borderColor: ['#cccccc'],
borderWidth: 1,
fill: true,
data: [274.28, 274.61, 276.57],
yAxisID: 'yElevation',
},
},
coordinates: [
{ latitude: 48.845574, longitude: 2.373723 },
{ latitude: 48.845578, longitude: 2.373732 },
{ latitude: 48.845591, longitude: 2.373811 },
],
},
},
] ]
testparams.map((testParams) => { testparams.map((testParams) => {
it(testParams.description, () => { it(testParams.description, () => {
locale.value = testParams.inputParams.locale locale.value = testParams.inputParams.locale
assert.deepEqual( assert.deepEqual(
getDatasets(testParams.inputParams.charData, t), getDatasets(
testParams.inputParams.charData,
t,
testParams.inputParams.useImperialUnits
),
testParams.expected testParams.expected
) )
}) })