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
* [#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
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#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
* [#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)

View File

@ -6,6 +6,7 @@
#### 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
* [#90](https://github.com/SamR1/FitTrackee/issues/90) - Add user sports preferences
* [#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
* [#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)

View File

@ -34,8 +34,9 @@ Administration
Account & preferences
^^^^^^^^^^^^^^^^^^^^^
- 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 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*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
@ -72,7 +73,7 @@ Workouts
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.
- 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.
- Workout edition and deletion. User can add a note.
- 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;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;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;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>
@ -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;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;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;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>
@ -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;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;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;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>

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;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;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;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>
@ -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;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;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;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>
@ -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;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;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;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>

View File

@ -277,6 +277,7 @@
<section id="new-features">
<h4>New Features<a class="headerlink" href="#new-features" title="Permalink to this headline"></a></h4>
<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/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>
@ -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/93">#84/#93</a> - Add elevation data and new sports</p></li>
</ul>
<p>In this release 5 issue were closed.</p>
<p>In this release 6 issue were closed.</p>
</section>
</section>
<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>
<ul class="simple">
<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 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">
<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>
@ -236,7 +237,7 @@
</div>
<ul class="simple">
<li><p>Dashboard with month calendar displaying workouts and record. The week can start on Sunday or Monday (which can be changed in the user preferences). The calendar displays up to 100 workouts.</p></li>
<li><p>Workout creation by uploading a gpx file. A workout can even be created without gpx (the user must enter date, time, duration and distance).</p></li>
<li><p>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>Workout edition and deletion. User can add a note.</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
^^^^^^^^^^^^^^^^^^^^^
- 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 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*):
- change sport color (used for sport image and charts)
- can override stopped speed threshold (for next uploaded gpx files)
@ -72,7 +73,7 @@ Workouts
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.
- 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.
- Workout edition and deletion. User can add a note.
- User statistics

View File

@ -1,6 +1,7 @@
from typing import Dict
from flask import current_app
from sqlalchemy import exc
from sqlalchemy.engine.base import Connection
from sqlalchemy.event import listens_for
from sqlalchemy.ext.declarative import DeclarativeMeta
@ -25,7 +26,15 @@ class AppConfig(BaseModel):
@property
def is_registration_enabled(self) -> bool:
nb_users = User.query.count()
try:
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
@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"
},
{
"revision": "926210f132992651a9543d9c76da25ba",
"revision": "a83d95cb780cb551f9c6e0257addee7a",
"url": "/index.html"
},
{
@ -80,8 +80,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/admin.babfd43e.css"
},
{
"revision": "4f95d958d90a2ac1b9a0",
"url": "/static/css/app.e1e7e23c.css"
"revision": "580dbac1a3cc1ff6f809",
"url": "/static/css/app.2b8c39ab.css"
},
{
"revision": "82c1118c918377daaa71a320ab8eea42",
@ -92,11 +92,11 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/leaflet.css"
},
{
"revision": "00c35b353719122c16cd",
"url": "/static/css/main.7229c1ab.css"
"revision": "3e2dd5ce7fd86f47e0e5",
"url": "/static/css/main.f9856c63.css"
},
{
"revision": "11b770a11a1cd8dae5f4",
"revision": "ac1280c03a31a5894834",
"url": "/static/css/main~workouts.0edb3403.css"
},
{
@ -108,8 +108,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/css/reset.46776e72.css"
},
{
"revision": "c78ff76a4bb0919c4b94",
"url": "/static/css/workouts.1b0a7916.css"
"revision": "03d9a79c5f845c47ef9c",
"url": "/static/css/workouts.84cbed34.css"
},
{
"revision": "e719f9244c69e28e7d00e725ca1e280e",
@ -196,8 +196,8 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/admin.2f1d393d.js"
},
{
"revision": "4f95d958d90a2ac1b9a0",
"url": "/static/js/app.0f3b3ab5.js"
"revision": "580dbac1a3cc1ff6f809",
"url": "/static/js/app.28d0829a.js"
},
{
"revision": "bd7d183c9f68e5f4027d",
@ -220,16 +220,16 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/chunk-2d22523a.4b710d99.js"
},
{
"revision": "1631aa1204c2ef00fa57",
"url": "/static/js/chunk-vendors.71654064.js"
"revision": "c04fcf32d84e5ec5cb38",
"url": "/static/js/chunk-vendors.caa4fc1c.js"
},
{
"revision": "00c35b353719122c16cd",
"url": "/static/js/main.db9cee98.js"
"revision": "3e2dd5ce7fd86f47e0e5",
"url": "/static/js/main.23f4d3a6.js"
},
{
"revision": "11b770a11a1cd8dae5f4",
"url": "/static/js/main~workouts.a74990d7.js"
"revision": "ac1280c03a31a5894834",
"url": "/static/js/main~workouts.6afa0411.js"
},
{
"revision": "058a877bc4b9cbf8929f",
@ -240,7 +240,7 @@ self.__precacheManifest = (self.__precacheManifest || []).concat([
"url": "/static/js/reset.518e646f.js"
},
{
"revision": "c78ff76a4bb0919c4b94",
"url": "/static/js/workouts.d69cf48a.js"
"revision": "03d9a79c5f845c47ef9c",
"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(
"/precache-manifest.c1f31e9729586ecf3c442890704f31cc.js"
"/precache-manifest.d81ab1e239beb2ec33c92fe076422816.js"
);
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 data['data']['timezone'] is None
assert data['data']['weekm'] is False
assert data['data']['imperial_units'] is False
assert data['data']['language'] is None
assert data['data']['nb_sports'] == 0
assert data['data']['nb_workouts'] == 0
@ -517,6 +518,7 @@ class TestUserProfile(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date']
assert data['data']['bio'] == 'just a random guy'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'somewhere'
assert data['data']['timezone'] == 'America/New_York'
assert data['data']['weekm'] is False
@ -553,6 +555,7 @@ class TestUserProfile(ApiTestCaseMixin):
assert data['data']['created_at']
assert not data['data']['admin']
assert data['data']['timezone'] is None
assert data['data']['imperial_units'] is False
assert data['data']['nb_sports'] == 2
assert data['data']['nb_workouts'] == 2
assert len(data['data']['records']) == 6
@ -605,6 +608,7 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] is None
assert data['data']['weekm'] is False
@ -648,6 +652,7 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] == 'Doe'
assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['imperial_units'] is False
assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] is None
assert data['data']['weekm'] is False
@ -767,6 +772,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
timezone='America/New_York',
weekm=True,
language='fr',
imperial_units=True,
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
@ -784,6 +790,7 @@ class TestUserPreferencesUpdate(ApiTestCaseMixin):
assert data['data']['last_name'] is None
assert data['data']['birth_date'] is None
assert data['data']['bio'] is None
assert data['data']['imperial_units']
assert data['data']['location'] is None
assert data['data']['timezone'] == 'America/New_York'
assert data['data']['weekm'] is True

View File

@ -36,6 +36,7 @@ class TestGetUser(ApiTestCaseMixin):
assert user['last_name'] is None
assert user['birth_date'] is None
assert user['bio'] is None
assert user['imperial_units'] is False
assert user['location'] is None
assert user['timezone'] is None
assert user['weekm'] is False
@ -77,6 +78,7 @@ class TestGetUser(ApiTestCaseMixin):
assert user['last_name'] is None
assert user['birth_date'] is None
assert user['bio'] is None
assert user['imperial_units'] is False
assert user['location'] is None
assert user['timezone'] is None
assert user['weekm'] is False
@ -129,6 +131,7 @@ class TestGetUsers(ApiTestCaseMixin):
assert 'test@test.com' in data['data']['users'][0]['email']
assert 'toto@toto.com' in data['data']['users'][1]['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]['weekm'] is False
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]['total_distance'] == 0
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]['weekm'] is False
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]['total_distance'] == 0
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]['weekm'] is True
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 'toto@toto.com' in data['data']['users'][1]['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]['weekm'] is False
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]['total_distance'] == 22.0
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]['weekm'] is False
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]['total_distance'] == 15
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]['weekm'] is True
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['first_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['location'] 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",
"email": "sam@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": 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",
"email": "sam@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": 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",
"email": "sam@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": null,
"location": null,
@ -653,6 +656,7 @@ def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
# get post data
post_data = request.get_json()
user_mandatory_data = {
'imperial_units',
'language',
'timezone',
'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:
return InvalidPayloadErrorResponse()
imperial_units = post_data.get('imperial_units')
language = post_data.get('language')
timezone = post_data.get('timezone')
weekm = post_data.get('weekm')
try:
user = User.query.filter_by(id=auth_user_id).first()
user.imperial_units = imperial_units
user.language = language
user.timezone = timezone
user.weekm = weekm

View File

@ -40,6 +40,7 @@ class User(BaseModel):
'Record', lazy=True, backref=db.backref('user', lazy='joined')
)
language = db.Column(db.String(50), nullable=True)
imperial_units = db.Column(db.Boolean, default=False, nullable=False)
def __repr__(self) -> str:
return f'<User {self.username!r}>'
@ -142,6 +143,7 @@ class User(BaseModel):
],
'total_distance': float(total[0]),
'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",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": null,
"location": null,
@ -246,6 +247,7 @@ def get_single_user(
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": null,
"location": null,
@ -400,6 +402,7 @@ def update_user(
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "admin@example.com",
"first_name": null,
"imperial_units": false,
"language": "en",
"last_name": 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,
required: true,
},
useImperialUnits: {
type: Boolean,
required: true,
},
},
setup(props) {
const { t } = useI18n()
@ -80,7 +84,12 @@
ticks: {
maxTicksLimit: 6,
callback: function (value) {
return formatTooltipValue(props.displayedData, +value, false)
return formatTooltipValue(
props.displayedData,
+value,
props.useImperialUnits,
false
)
},
},
afterFit: function (scale: LayoutItem) {
@ -108,7 +117,12 @@
.reduce((total, value) => getSum(total, value), 0)
return context.datasetIndex ===
props.displayedSportIds.length - 1 && total > 0
? formatTooltipValue(props.displayedData, total, false)
? formatTooltipValue(
props.displayedData,
total,
props.useImperialUnits,
false
)
: null
},
},
@ -132,7 +146,8 @@
if (context.parsed.y !== null) {
label += formatTooltipValue(
props.displayedData,
context.parsed.y
context.parsed.y,
props.useImperialUnits
)
}
return label
@ -144,7 +159,11 @@
})
return (
`${t('common.TOTAL')}: ` +
formatTooltipValue(props.displayedData, sum)
formatTooltipValue(
props.displayedData,
sum,
props.useImperialUnits
)
)
},
},

View File

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

View File

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

View File

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

View File

@ -7,8 +7,8 @@
/>
<StatCard
icon="road"
:value="Number(user.total_distance).toFixed(2)"
:text="$t('workouts.KM')"
:value="totalDistance"
:text="unitTo === 'mi' ? 'miles' : unitTo"
/>
<StatCard
icon="clock-o"
@ -28,7 +28,9 @@
import { useI18n } from 'vue-i18n'
import StatCard from '@/components/Common/StatCard.vue'
import { TUnit } from '@/types/units'
import { IUserProfile } from '@/types/user'
import { convertDistance, units } from '@/utils/units'
interface Props {
user: IUserProfile
}
@ -41,6 +43,13 @@
() => props.user.total_duration
)
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>) {
const duration = total_duration.value.match(/day/g)

View File

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

View File

@ -7,6 +7,14 @@
<dd>{{ timezone }}</dd>
<dt>{{ $t('user.PROFILE.FIRST_DAY_OF_WEEK') }}:</dt>
<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>
<div class="profile-buttons">
<button @click="$router.push('/profile/edit/preferences')">

View File

@ -35,6 +35,22 @@
</option>
</select>
</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">
<button class="confirm" type="submit">
{{ $t('buttons.SUBMIT') }}
@ -68,6 +84,7 @@
const store = useStore()
const userForm: IUserPreferencesPayload = reactive({
imperial_units: false,
language: '',
timezone: 'Europe/Paris',
weekm: false,
@ -82,6 +99,16 @@
value: false,
},
]
const imperialUnits = [
{
label: 'IMPERIAL',
value: true,
},
{
label: 'METRIC',
value: false,
},
]
const loading = computed(
() => store.getters[AUTH_USER_STORE.GETTERS.USER_LOADING]
)
@ -96,6 +123,7 @@
})
function updateUserForm(user: IUserProfile) {
userForm.imperial_units = user.imperial_units ? user.imperial_units : false
userForm.language = user.language ? user.language : 'en'
userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris'
userForm.weekm = user.weekm ? user.weekm : false

View File

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

View File

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

View File

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

View File

@ -89,8 +89,26 @@
:title="$t(`workouts.WEATHER.WIND`)"
/>
</td>
<td>{{ Number(workoutObject.weatherStart.wind).toFixed(1) }}m/s</td>
<td>{{ Number(workoutObject.weatherEnd.wind).toFixed(1) }}m/s</td>
<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>
</tbody>
</table>
@ -104,10 +122,11 @@
interface Props {
workoutObject: IWorkoutObject
useImperialUnits: boolean
}
const props = defineProps<Props>()
const { workoutObject } = toRefs(props)
const { useImperialUnits, workoutObject } = toRefs(props)
</script>
<style lang="scss" scoped>

View File

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

View File

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

View File

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

View File

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

View File

@ -45,8 +45,9 @@
{{ $t('workouts.SPORT', 1) }}
</span>
<SportImage
v-if="sports.length > 0"
:title="
sports.filter((s) => s.id === workout.sport_id)[0]
sports.find((s) => s.id === workout.sport_id)
.translatedLabel
"
:sport-label="getSportLabel(workout, sports)"
@ -93,7 +94,11 @@
<span class="cell-heading">
{{ $t('workouts.DISTANCE') }}
</span>
{{ Number(workout.distance).toFixed(2) }} km
<Distance
:distance="workout.distance"
unitFrom="km"
:useImperialUnits="user.imperial_units"
/>
</td>
<td class="text-right">
<span class="cell-heading">
@ -105,25 +110,45 @@
<span class="cell-heading">
{{ $t('workouts.AVE_SPEED') }}
</span>
{{ workout.ave_speed }} km/h
<Distance
:distance="workout.ave_speed"
unitFrom="km"
:speed="true"
:useImperialUnits="user.imperial_units"
/>
</td>
<td class="text-right">
<span class="cell-heading">
{{ $t('workouts.MAX_SPEED') }}
</span>
{{ workout.max_speed }} km/h
<Distance
:distance="workout.max_speed"
unitFrom="km"
:speed="true"
:useImperialUnits="user.imperial_units"
/>
</td>
<td class="text-right">
<span class="cell-heading">
{{ $t('workouts.ASCENT') }}
</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 class="text-right">
<span class="cell-heading">
{{ $t('workouts.DESCENT') }}
</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>
</tr>
</tbody>
@ -163,6 +188,7 @@
import { getQuery, sortList, workoutsPayloadKeys } from '@/utils/api'
import { getDateWithTZ } from '@/utils/dates'
import { getSportColor, getSportLabel } from '@/utils/sports'
import { convertDistance } from '@/utils/units'
import { defaultOrder } from '@/utils/workouts'
interface Props {
@ -196,7 +222,10 @@
})
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) {
const newQuery: LocationQuery = Object.assign({}, route.query)
@ -224,6 +253,18 @@
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) {
hoverWorkoutId.value = workoutId
}

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@
"FROM": "à partir de",
"GPX_FILE": "fichier .gpx",
"HIDE_FILTERS": "masquer les filtres",
"KM": "km",
"LATEST_WORKOUTS": "Séances récentes",
"LOAD_MORE_WORKOUT": "Charger les séances suivantes",
"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
email: string
first_name: string | null
imperial_units: boolean
language: string | null
last_name: string | null
location: string | null
@ -40,6 +41,7 @@ export interface IAdminUserPayload {
}
export interface IUserPreferencesPayload {
imperial_units: boolean
language: string
timezone: string
weekm: boolean

View File

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

View File

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

View File

@ -1,19 +1,23 @@
import { TStatisticsDatasetKeys } from '@/types/statistics'
import { formatDuration } from '@/utils/duration'
import { units } from '@/utils/units'
export const formatTooltipValue = (
displayedData: TStatisticsDatasetKeys,
value: number,
useImperialUnits: boolean,
formatWithUnits = true
): string => {
const unitFrom = 'km'
const unitTo = useImperialUnits ? units[unitFrom].defaultTarget : unitFrom
switch (displayedData) {
case 'total_duration':
return formatDuration(value, formatWithUnits)
case 'total_distance':
return value.toFixed(2) + ' km'
return `${value.toFixed(2)} ${unitTo}`
case 'total_ascent':
case 'total_descent':
return (value / 1000).toFixed(2) + ' km'
return `${(value / 1000).toFixed(2)} ${unitTo}`
default:
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,
TWorkoutDatasets,
} from '@/types/workouts'
import { convertStatsDistance } from '@/utils/units'
export const getDatasets = (
chartData: IWorkoutApiChartData[],
t: CallableFunction
t: CallableFunction,
useImperialUnits: boolean
): IWorkoutChartData => {
const datasets: TWorkoutDatasets = {
speed: {
@ -36,8 +38,12 @@ export const getDatasets = (
chartData.map((data) => {
distance_labels.push(data.distance)
duration_labels.push(data.duration)
datasets.speed.data.push(data.speed)
datasets.elevation.data.push(data.elevation)
datasets.speed.data.push(
convertStatsDistance('km', data.speed, useImperialUnits)
)
datasets.elevation.data.push(
convertStatsDistance('m', data.elevation, useImperialUnits)
)
coordinates.push({ latitude: data.latitude, longitude: data.longitude })
})

View File

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

View File

@ -100,7 +100,113 @@ describe('formatRecord', () => {
assert.deepEqual(
formatRecord(
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
)
@ -121,7 +227,8 @@ describe('formatRecord (invalid record type)', () => {
workout_date: 'Sun, 07 Jul 2019 22:00:00 GMT',
workout_id: 'hvYBqYBRa7wwXpaStWR4V2',
},
'Europe/Paris'
'Europe/Paris',
false
)
).to.throw(
'Invalid record type, expected: "AS", "FD", "LD", "MD", got: "M"'
@ -248,7 +355,138 @@ describe('getRecordsBySports', () => {
getRecordsBySports(
testParams.input.records,
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
// @ts-ignore

View File

@ -316,7 +316,7 @@ describe('formatStats', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [], inputStats),
formatStats(inputParams, false, sports, [], inputStats, false),
expected
)
})
@ -369,7 +369,7 @@ describe('formatStats', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [2], inputStats),
formatStats(inputParams, false, sports, [2], inputStats, false),
expected
)
})
@ -427,7 +427,7 @@ describe('formatStats', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [], inputStats),
formatStats(inputParams, false, sports, [], inputStats, false),
expected
)
})
@ -515,7 +515,7 @@ describe('formatStats', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats),
formatStats(inputParams, false, sports, [1], inputStats, false),
expected
)
})
@ -605,7 +605,7 @@ describe('formatStats (duration)', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats),
formatStats(inputParams, false, sports, [1], inputStats, false),
expected
)
})
@ -692,7 +692,7 @@ describe('formatStats (duration)', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats),
formatStats(inputParams, false, sports, [1], inputStats, false),
expected
)
})
@ -780,7 +780,7 @@ describe('formatStats (duration)', () => {
},
}
assert.deepEqual(
formatStats(inputParams, false, sports, [1], inputStats),
formatStats(inputParams, false, sports, [1], inputStats, false),
expected
)
})
@ -868,7 +868,95 @@ describe('formatStats (duration)', () => {
},
}
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
)
})

View File

@ -42,7 +42,56 @@ describe('formatTooltipValue', () => {
assert.equal(
formatTooltipValue(
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
)
@ -90,6 +139,7 @@ describe('formatTooltipValue (formatWithUnits = false)', () => {
formatTooltipValue(
testParams.inputDisplayedData,
testParams.inputValue,
false,
false
),
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: {
charData: [],
locale: 'fr',
useImperialUnits: false,
},
expected: {
distance_labels: [],
@ -72,6 +73,7 @@ describe('getDatasets', () => {
},
],
locale: 'en',
useImperialUnits: false,
},
expected: {
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) => {
it(testParams.description, () => {
locale.value = testParams.inputParams.locale
assert.deepEqual(
getDatasets(testParams.inputParams.charData, t),
getDatasets(
testParams.inputParams.charData,
t,
testParams.inputParams.useImperialUnits
),
testParams.expected
)
})