Index: .boring
===================================================================
--- .boring	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ .boring	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -138,2 +138,3 @@
 ow.egg-info/*
 ow/static/components
+ow/static/maps
Index: bin/screenshot_map
===================================================================
--- bin/screenshot_map	(revision c6219edee5849aa768521f4bea83d02216cd7366)
+++ bin/screenshot_map	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -0,0 +1,29 @@
+#!/bin/sh
+#
+# Make a screenshot of a workout map
+#
+
+uid=$1
+wid=$2
+dest=$3
+dummy=$4
+
+# make a dummy screenshot image first, that way if chrome fails, we still have an image
+cp ${dummy} screenshot.png
+
+# Make the screenshot with chrome headless
+`which chrome` --headless --disable-gpu --window-size="1300x436" --screenshot http://localhost:9999/${uid}/${wid}/map --virtual-time-budget=10000
+
+# crop it
+# `which convert` screenshot.png -chop 100x0 ${dest}
+
+# resize, 70% of the original image is enough.
+# we do this instead of setting a lower resolution/window size on the browser
+# because with smaller window size, the resulting images appear corrupted
+# randomly (like if the screenshot was taking before the map was finished
+# loading tiles)
+# `which convert` ${dest} -resize 70% ${dest}
+
+# remove leftover
+# rm screenshot.png
+mv screenshot.png ${dest}
Index: ow/load_workouts.py
===================================================================
--- ow/load_workouts.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
+++ ow/load_workouts.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -0,0 +1,49 @@
+import os
+
+from fitparse.utils import FitHeaderError
+
+from ow.utilities import create_blob
+from ow.models.workout import Workout
+
+
+def get_files(path, extension):
+    """
+    Given a path and a file extension, return a list of full-path strings
+    pointing to files found with such extension
+    """
+    files = []
+    for f in os.listdir(path):
+        if f.endswith(extension):
+            files.append(os.path.join(path, f))
+    return files
+
+
+def load_workouts(user, paths, extension='fit'):
+    """
+    Load workouts from the given path, looking for files with the given
+    extension (only fit and gpx files supported so far)
+    """
+    for path in paths:
+        print('Loading from path:', path)
+        files = get_files(path, extension)
+        for file_path in files:
+            if extension == 'fit':
+                print(' Loading file:', file_path)
+                with open(file_path, 'rb') as f_obj:
+                    fit_blob = create_blob(
+                        f_obj.read(), file_extension=extension, binary=True)
+            elif extension == 'gpx':
+                print(' Loading file:', file_path)
+                with open(file_path, 'r') as f_obj:
+                    fit_blob = create_blob(
+                        f_obj.read(), file_extension=extension)
+            workout = Workout()
+            workout.tracking_file = fit_blob
+            workout.tracking_filetype = extension
+            try:
+                workout.load_from_file()
+            except FitHeaderError:
+                print('  ERROR LOADING WORKOUT  ')
+            else:
+                # add the workout only if no errors happened
+                user.add_workout(workout)
Index: ow/models/workout.py
===================================================================
--- ow/models/workout.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/models/workout.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -1,3 +1,3 @@
-
+import os
 from datetime import datetime, timedelta, timezone
 from decimal import Decimal
@@ -12,4 +12,6 @@
     copy_blob,
     create_blob,
+    mps_to_kmph,
+    save_map_screenshot
 )
 
@@ -50,4 +52,5 @@
         self.duration = kw.get('duration', None)  # a timedelta object
         self.distance = kw.get('distance', None)  # kilometers, Decimal
+        self.speed = kw.get('speed', {})
         self.hr_min = kw.get('hr_min', None)  # bpm, Decimal
         self.hr_max = kw.get('hr_max', None)  # bpm, Decimal
@@ -211,7 +214,7 @@
         path = None
         if self.tracking_file:
-            path = self.tracking_file._p_blob_uncommitted
+            path = self.tracking_file._uncommitted()
             if path is None:
-                path = self.tracking_file._p_blob_committed
+                path = self.tracking_file.committed()
         return path
 
@@ -228,7 +231,7 @@
         path = None
         if self.fit_file:
-            path = self.fit_file._p_blob_uncommitted
+            path = self.fit_file._uncommitted()
             if path is None:
-                path = self.fit_file._p_blob_committed
+                path = self.fit_file.committed()
         return path
 
@@ -348,6 +351,11 @@
         4. Grab some basic info from the fit file and store it in the Workout
         """
-        # backup the fit file
-        self.fit_file = copy_blob(self.tracking_file)
+
+        # we can call load_from_fit afterwards for updates. In such case, check
+        # if the tracking file is a fit file uploaded to override the previous
+        # one. If not, just reuse the existing fit file
+        if self.tracking_filetype == 'fit':
+            # backup the fit file
+            self.fit_file = copy_blob(self.tracking_file)
 
         # create an instance of our Fit class
@@ -378,4 +386,10 @@
             self.title = fit.name
 
+        if fit.data['max_speed']:
+            self.speed['max'] = mps_to_kmph(fit.data['max_speed'])
+
+        if fit.data['avg_speed']:
+            self.speed['avg'] = mps_to_kmph(fit.data['avg_speed'])
+
         if fit.data['avg_hr']:
             self.hr_avg = Decimal(fit.data['avg_hr'])
@@ -406,2 +420,26 @@
     def has_fit(self):
         return self.fit_file is not None
+
+    @property
+    def map_screenshot(self):
+        """
+        Return the static path to the screenshot image of the map for
+        this workout (works only for workouts with gps tracking)
+        """
+        if not self.has_gpx:
+            return None
+
+        current_path = os.path.abspath(os.path.dirname(__file__))
+        screenshot_path = os.path.join(
+            current_path, '../static/maps',
+            str(self.owner.uid), str(self.workout_id)) + '.png'
+
+        if not os.path.exists(screenshot_path):
+            # screenshot does not exist, generate it
+            save_map_screenshot(self)
+
+        # the value returned is relative to the static files served
+        # by the app, so we can use request.static_url() with it
+        static_path = os.path.join('static/maps', str(self.owner.uid),
+                                   str(self.workout_id))
+        return 'ow:' + static_path + '.png'
Index: ow/static/css/main.css
===================================================================
--- ow/static/css/main.css	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/static/css/main.css	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -752,4 +752,8 @@
 }
 
+.workout-map img {
+    width: 100%;
+}
+
 .owo-del a:hover {
     color:red
Index: ow/static/js/ow.js
===================================================================
--- ow/static/js/ow.js	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/static/js/ow.js	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -16,4 +16,5 @@
 
     // parameters provided when creating an "instance" of a map
+    var map_id = spec.map_id;
     var latitude = spec.latitude;
     var longitude = spec.longitude;
@@ -23,8 +24,10 @@
     var end_icon = spec.end_icon;
     var shadow = spec.shadow;
+    var elevation = spec.elevation;
+    var zoom_control = spec.zoom_control;
 
     // OpenStreetMap urls and references
-    var openstreetmap_url = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
-    var openstreetmap_attr = 'Map data &copy; <a href="http://www.osm.org">OpenStreetMap</a>'
+    var openstreetmap_url = 'http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
+    var openstreetmap_attr = 'Map data &copy; <a href="http://www.osm.org">OpenStreetMap</a>';
 
     // Some constants reused through the code
@@ -36,5 +39,5 @@
     var create_map = function create_map(latitude, longitude, zoom) {
         /* Create a Leaflet map, set center point and add tiles */
-        map = L.map('map');
+        map = L.map(map_id, {zoomControl: zoom_control});
         map.setView([latitude, longitude], zoom);
         var tile_layer = L.tileLayer(openstreetmap_url, {
@@ -90,8 +93,16 @@
             },
         });
-        gpx.on("addline",function(e){
-            elevation.addData(e.line);
-            // ow_charts.addData(e.line);
-        });
+
+        gpx.on('loaded', function(e) {
+	    map.fitBounds(e.target.getBounds());
+	});
+
+        if (elevation) {
+            gpx.on("addline",function(e){
+                elevation.addData(e.line);
+                // ow_charts.addData(e.line);
+            });
+        };
+
         gpx.addTo(map);
     };
@@ -100,5 +111,7 @@
         // create the map, add elevation, load gpx
         create_map(latitude, longitude, zoom);
-        add_elevation_chart();
+        if (elevation) {
+            add_elevation_chart();
+        }
         // add_ow_charts();
         load_gpx(gpx_url);
Index: ow/templates/dashboard.pt
===================================================================
--- ow/templates/dashboard.pt	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/templates/dashboard.pt	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -90,4 +90,11 @@
             <div class="workout-intro" tal:content="workout.notes"></div>
 
+            <div class="workout-map" tal:condition="workout.has_gpx">
+                <a href="" tal:attributes="href request.resource_url(workout)">
+                    <img src="" tal:attributes="src request.static_url(workout.map_screenshot);
+                              alt workout.title; title workout.title">
+                </a>
+            </div>
+
             <ul class="workout-options">
               <li class="owo-edit"><a href="" i18n:translate="" tal:attributes="href request.resource_url(workout, 'edit')"><span>edit</span></a></li>
Index: ow/templates/workout-map.pt
===================================================================
--- ow/templates/workout-map.pt	(revision c6219edee5849aa768521f4bea83d02216cd7366)
+++ ow/templates/workout-map.pt	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml"
+      xml:lang="en"
+      xmlns:tal="http://xml.zope.org/namespaces/tal"
+      xmlns:metal="http://xml.zope.org/namespaces/metal"
+      xmlns:i18n="http://xml.zope.org/namespaces/i18n"
+      i18n:domain="OpenWorkouts"
+      tal:attributes="lang request.locale_name">
+
+  <head>
+
+    <meta charset="utf-8">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <meta name="description"
+          content="OpenWorkouts - tracking your workouts openly">
+    <meta name="author" content="The OpenWorkouts Project">
+    <title>OpenWorkouts - tracking your workouts openly</title>
+
+    <link rel="stylesheet"
+          href="${request.static_url('ow:static/css/main.css')}" />
+    <link rel="stylesheet"
+          href="${request.static_url('ow:static/css/openworkouts.css')}" />
+    <link rel="stylesheet" tal:condition="context.has_gpx"
+          href="${request.static_url('ow:static/components/Leaflet/leaflet.css')}" />
+
+    <style>
+     html, body, #full-map {
+	 height:100%;
+	 width:100%;
+	 padding:0px;
+	 margin:0px;
+     }
+    </style>
+
+  </head>
+
+  <body class="full-map">
+    <!--! Track map -->
+    <div id="full-map" tal:condition="context.has_gpx"></div>
+
+    <script src="${request.static_url('ow:static/components/Leaflet/leaflet.js')}"
+            tal:condition="context.has_gpx"></script>
+
+    <script src="${request.static_url('ow:static/components/leaflet-gpx/gpx.js')}"
+            tal:condition="context.has_gpx"></script>
+
+    <script src="${request.static_url('ow:static/js/ow.js')}"
+            tal:condition="context.has_gpx"></script>
+
+    <script type="text/javascript" tal:condition="context.has_gpx">
+     var workout_map = owjs.map({
+         map_id: 'full-map',
+         latitude: ${start_point['latitude']},
+         longitude: ${start_point['longitude']},
+         zoom: 11,
+         gpx_url: '${request.resource_url(context, 'gpx')}',
+         elevation: false,
+         zoom_control: false
+     });
+     workout_map.render();
+
+    </script>
+  </body>
+
+</html>
Index: ow/templates/workout.pt
===================================================================
--- ow/templates/workout.pt	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/templates/workout.pt	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -87,4 +87,11 @@
           <tal:c tal:content="context.rounded_distance"></tal:c> km
         </li>
+        <li tal:condition="context.speed">
+          <tal:t i18n:translate="">Speed:</tal:t>
+          <tal:t i18n:translate="">Avg.</tal:t>
+          <tal:c tal:content="round(context.speed['avg'], 1)"></tal:c> km/h |
+          <tal:t i18n:translate="">Max.</tal:t>
+          <tal:c tal:content="round(context.speed['max'], 1)"></tal:c> km/h
+        </li>
         <li tal:condition="hr">
           <tal:t i18n:translate="">Heart Rate:</tal:t>
@@ -145,4 +152,5 @@
     <script type="text/javascript" tal:condition="context.has_gpx">
      var workout_map = owjs.map({
+         map_id: 'map',
          latitude: ${start_point['latitude']},
          longitude: ${start_point['longitude']},
@@ -152,4 +160,6 @@
          end_icon: '${request.static_url('ow:static/components/leaflet-gpx/pin-icon-end.png')}',
          shadow: '${request.static_url('ow:static/components/leaflet-gpx/pin-shadow.png')}',
+         elevation: true,
+         zoom_control: true
      });
      workout_map.render();
Index: ow/tests/helpers.py
===================================================================
--- ow/tests/helpers.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
+++ ow/tests/helpers.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -0,0 +1,3 @@
+def join(*args, **kwargs):
+    """ Faked join method, for mocking purposes """
+    return '/' + '/'.join([arg for arg in args])
Index: ow/tests/models/test_workout.py
===================================================================
--- ow/tests/models/test_workout.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/tests/models/test_workout.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -12,4 +12,6 @@
 from ow.models.root import OpenWorkouts
 from ow.utilities import create_blob
+
+from ow.tests.helpers import join
 
 
@@ -210,9 +212,9 @@
         # workout still not saved to the db
         workout.tracking_file = Mock()
-        workout.tracking_file._p_blob_uncommitted = '/tmp/blobtempfile'
-        workout.tracking_file._p_blob_committed = None
+        workout.tracking_file._uncommitted.return_value = '/tmp/blobtempfile'
+        workout.tracking_file.committed.return_value = None
         assert workout.tracking_file_path == '/tmp/blobtempfile'
-        workout.tracking_file._p_blob_uncommitted = None
-        workout.tracking_file._p_blob_committed = '/var/db/blobs/blobfile'
+        workout.tracking_file._uncommitted.return_value = None
+        workout.tracking_file.committed.return_value = '/var/db/blobs/blobfile'
         assert workout.tracking_file_path == '/var/db/blobs/blobfile'
 
@@ -223,9 +225,9 @@
         # workout still not saved to the db
         workout.fit_file = Mock()
-        workout.fit_file._p_blob_uncommitted = '/tmp/blobtempfile'
-        workout.fit_file._p_blob_committed = None
+        workout.fit_file._uncommitted.return_value = '/tmp/blobtempfile'
+        workout.fit_file.committed.return_value = None
         assert workout.fit_file_path == '/tmp/blobtempfile'
-        workout.fit_file._p_blob_uncommitted = None
-        workout.fit_file._p_blob_committed = '/var/db/blobs/blobfile'
+        workout.fit_file._uncommitted.return_value = None
+        workout.fit_file.committed.return_value = '/var/db/blobs/blobfile'
         assert workout.fit_file_path == '/var/db/blobs/blobfile'
 
@@ -514,21 +516,102 @@
         workout = root['john']['1']
         # without tracking file
-        assert workout.has_tracking_file is False
+        assert not workout.has_tracking_file
         # with tracking file
         workout.tracking_file = 'faked tracking file'
-        assert workout.has_tracking_file is True
+        assert workout.has_tracking_file
 
     def test_has_gpx(self, root):
         workout = root['john']['1']
         # without tracking file
-        assert workout.has_gpx is False
+        assert not workout.has_gpx
         workout.tracking_filetype = 'fit'
-        assert workout.has_gpx is False
+        assert not workout.has_gpx
         # with non-gpx tracking file
         workout.tracking_file = 'faked tracking file'
         workout.tracking_filetype = 'fit'
-        assert workout.has_gpx is False
+        assert not workout.has_gpx
         # with gpx tracking file
         workout.tracking_file = 'faked tracking file'
         workout.tracking_filetype = 'gpx'
-        assert workout.has_gpx is True
+        assert workout.has_gpx
+
+    def test_has_fit(self, root):
+        workout = root['john']['1']
+        # without tracking file
+        assert not workout.has_fit
+        # tracking_file is a fit, this should not happen, as uploading a fit
+        # puts the fit file into .fit_file and generates a gpx for
+        # .tracking_file
+        workout.tracking_file = 'faked tracking file'
+        workout.tracking_filetype = 'fit'
+        assert not workout.has_fit
+        # now, having a fit file returns true
+        workout.fit_file = 'faked fit file'
+        assert workout.has_fit
+        # no matter what we have in tracking_file
+        workout.tracking_filetype = 'gpx'
+        assert workout.has_fit
+        workout.tracking_file = None
+        workout.tracking_filetype = None
+        assert workout.has_fit
+
+    @patch('ow.models.workout.os')
+    @patch('ow.models.workout.save_map_screenshot')
+    def test_map_screenshot_no_gpx(self, sms, os, root):
+        workout = root['john']['1']
+        assert workout.map_screenshot is None
+        assert not os.path.abspath.called
+        assert not os.path.dirname.called
+        assert not os.path.join.called
+        assert not os.path.exists.called
+        assert not sms.called
+
+    @patch('ow.models.workout.os')
+    @patch('ow.models.workout.save_map_screenshot')
+    def test_map_screenshot_save(self, sms, os, root):
+        """
+        A workout with a tracking file has no map screenshot, one is
+        saved to the filesystem.
+        This test simply asserts the calls to the separate methods that
+        look for existing screenshots and save a new one
+        """
+        os.path.abspath.return_value = 'current_dir'
+        os.path.join.side_effect = join
+        # This forces the "save screenshot" code to be run
+        os.path.exists.return_value = False
+
+        workout = root['john']['1']
+        workout.tracking_file = 'faked gpx file'
+        workout.tracking_filetype = 'gpx'
+
+        uid = str(root['john'].uid)
+        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
+        assert os.path.abspath.called
+        assert os.path.dirname.called
+        assert os.path.join.call_count == 2
+        assert os.path.exists.called
+        sms.assert_called_once_with(workout)
+
+    @patch('ow.models.workout.os')
+    @patch('ow.models.workout.save_map_screenshot')
+    def test_map_screenshot_do_not_save(self, sms, os, root):
+        """
+        A workout with a tracking file has a map screenshot, the path to that
+        is returned without doing anything else
+        """
+        os.path.abspath.return_value = 'current_dir'
+        os.path.join.side_effect = join
+        # This forces the "save screenshot" code NOT to be run
+        os.path.exists.return_value = True
+
+        workout = root['john']['1']
+        workout.tracking_file = 'faked gpx file'
+        workout.tracking_filetype = 'gpx'
+
+        uid = str(root['john'].uid)
+        assert workout.map_screenshot == 'ow:/static/maps/' + uid + '/1.png'
+        assert os.path.abspath.called
+        assert os.path.dirname.called
+        assert os.path.join.call_count == 2
+        assert os.path.exists.called
+        assert not sms.called
Index: ow/tests/test_utilities.py
===================================================================
--- ow/tests/test_utilities.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/tests/test_utilities.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -1,7 +1,13 @@
 import os
+from datetime import timedelta
+from unittest.mock import patch
 from pyexpat import ExpatError
 from xml.dom.minidom import Element
 
 import pytest
+
+from ow.models.root import OpenWorkouts
+from ow.models.user import User
+from ow.models.workout import Workout
 
 from ow.utilities import (
@@ -16,8 +22,28 @@
     mps_to_kmph,
     kmph_to_mps,
+    save_map_screenshot
 )
 
+from ow.tests.helpers import join
+
 
 class TestUtilities(object):
+
+    @pytest.fixture
+    def john(self):
+        john = User(firstname='John', lastname='Doe',
+                    email='john.doe@example.net')
+        john.password = 's3cr3t'
+        return john
+
+    @pytest.fixture
+    def root(self, john):
+        root = OpenWorkouts()
+        root.add_user(john)
+        john['1'] = Workout(
+            duration=timedelta(minutes=60),
+            distance=30
+        )
+        return root
 
     def test_slugify(self):
@@ -54,4 +80,68 @@
     def test_kmph_to_mps(self):
         assert kmph_to_mps(30) == 30 * 0.277778
+
+    @patch('ow.utilities.os')
+    @patch('ow.utilities.subprocess')
+    def test_save_map_screenshot_no_gpx(self, subprocess, os, root, john):
+        saved = save_map_screenshot(john['1'])
+        assert not saved
+        assert not os.path.abspath.called
+        assert not os.path.dirname.called
+        assert not os.path.join.called
+        assert not os.path.exists.called
+        assert not os.makedirs.called
+        assert not subprocess.run.called
+        # even having a fit tracking file, nothing is done
+        john['1'].tracking_file = 'faked fit file'
+        john['1'].tracking_filetype = 'fit'
+        saved = save_map_screenshot(john['1'])
+        assert not saved
+        assert not os.path.abspath.called
+        assert not os.path.dirname.called
+        assert not os.path.join.called
+        assert not os.path.exists.called
+        assert not os.makedirs.called
+        assert not subprocess.run.called
+
+    @patch('ow.utilities.os')
+    @patch('ow.utilities.subprocess')
+    def test_save_map_screenshot_with_gpx(self, subprocess, os, root, john):
+        os.path.abspath.return_value = 'current_dir'
+        os.path.join.side_effect = join
+        # This mimics what happens when the directory for this user map
+        # screenshots does not exist, which means we don'have to create one
+        # (calling os.makedirs)
+        os.path.exists.return_value = False
+
+        john['1'].tracking_file = 'faked gpx content'
+        john['1'].tracking_filetype = 'gpx'
+        saved = save_map_screenshot(john['1'])
+        assert saved
+        os.path.abspath.assert_called_once
+        assert os.path.dirname.called
+        assert os.path.join.call_count == 3
+        assert os.path.exists.called
+        assert os.makedirs.called
+        subprocess.run.assert_called_once
+
+    @patch('ow.utilities.os')
+    @patch('ow.utilities.subprocess')
+    def test_save_map_screenshot_with_gpx_makedirs(
+            self, subprocess, os, root, john):
+        os.path.abspath.return_value = 'current_dir'
+        os.path.join.side_effect = join
+        # If os.path.exists returns True, makedirs is not called
+        os.path.exists.return_value = True
+
+        john['1'].tracking_file = 'faked gpx content'
+        john['1'].tracking_filetype = 'gpx'
+        saved = save_map_screenshot(john['1'])
+        assert saved
+        os.path.abspath.assert_called_once
+        assert os.path.dirname.called
+        assert os.path.join.call_count == 3
+        assert os.path.exists.called
+        assert not os.makedirs.called
+        subprocess.run.assert_called_once
 
 
Index: ow/tests/views/test_workout.py
===================================================================
--- ow/tests/views/test_workout.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/tests/views/test_workout.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -429,2 +429,30 @@
                 assert response.content_type == 'application/xml'
                 assert expected_body in response.body
+
+    def test_workout_map_no_gpx(self, dummy_request):
+        request = dummy_request
+        user = request.root['john']
+        workout = user.workouts()[0]
+        response = workout_views.workout_map(workout, request)
+        assert response == {'start_point': {}}
+
+    def test_workout_map(self, dummy_request):
+        request = dummy_request
+        user = request.root['john']
+        workout = user.workouts()[0]
+        # to ensure has_gpx returns true
+        workout.tracking_filetype = 'gpx'
+        gpx_file_path = os.path.join(
+            os.path.dirname(os.path.dirname(__file__)),
+            'fixtures/20131013.gpx')
+        with patch.object(workout, 'tracking_file') as tf:
+            with open(gpx_file_path, 'r') as gpx_file:
+                tf.open.return_value = BytesIO(gpx_file.read().encode('utf-8'))
+                response = workout_views.workout_map(workout, request)
+                assert response == {
+                    'start_point': {
+                        'elevation': None,
+                        'latitude': 37.108735040304566,
+                        'longitude': 25.472489344630546
+                    }
+                }
Index: ow/utilities.py
===================================================================
--- ow/utilities.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/utilities.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -1,3 +1,6 @@
 import re
+import os
+import logging
+import subprocess
 from datetime import datetime
 from decimal import Decimal
@@ -7,4 +10,6 @@
 from xml.dom import minidom
 from ZODB.blob import Blob
+
+log = logging.getLogger(__name__)
 
 
@@ -184,2 +189,25 @@
         open_blob.write(data)
     return blob
+
+
+def save_map_screenshot(workout):
+    if workout.has_gpx:
+        current_path = os.path.abspath(os.path.dirname(__file__))
+        tool_path = os.path.join(current_path, '../bin/screenshot_map')
+
+        screenshots_path = os.path.join(
+            current_path, 'static/maps', str(workout.owner.uid))
+        if not os.path.exists(screenshots_path):
+            os.makedirs(screenshots_path)
+
+        screenshot_path = os.path.join(
+            screenshots_path, str(workout.workout_id))
+        screenshot_path += '.png'
+
+        subprocess.run(
+            [tool_path, str(workout.owner.uid), str(workout.workout_id),
+             screenshot_path], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+        return True
+
+    return False
Index: ow/views/workout.py
===================================================================
--- ow/views/workout.py	(revision 02048a6be709e0c57f8e3e6fe4a2ae77783c92a4)
+++ ow/views/workout.py	(revision c6219edee5849aa768521f4bea83d02216cd7366)
@@ -214,2 +214,25 @@
         content_disposition='attachment; filename="%s"' % gpx_slug,
         body_file=context.tracking_file.open())
+
+
+@view_config(
+    context=Workout,
+    name='map',
+    renderer='ow:templates/workout-map.pt')
+def workout_map(context, request):
+    """
+    Render a page that has only a map with tracking info
+    """
+    start_point = {}
+    if context.has_gpx:
+        with context.tracking_file.open() as gpx_file:
+            gpx_contents = gpx_file.read()
+            gpx_contents = gpx_contents.decode('utf-8')
+            gpx = gpxpy.parse(gpx_contents)
+            if gpx.tracks:
+                track = gpx.tracks[0]
+                center_point = track.get_center()
+                start_point = {'latitude': center_point.latitude,
+                               'longitude': center_point.longitude,
+                               'elevation': center_point.elevation}
+    return {'start_point': start_point}
