Index: ow/migrations/2.py
===================================================================
--- ow/migrations/2.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
+++ ow/migrations/2.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -0,0 +1,41 @@
+
+
+def migrate(root):
+    """
+    Adds the "hashed" workout index to the main catalog, reindexing all
+    existing workouts.
+
+    >>> from decimal import Decimal
+    >>> from datetime import datetime, timezone, timedelta
+    >>> from ow.catalog import get_catalog
+    >>> from ow.models.root import OpenWorkouts
+    >>> from ow.models.user import User
+    >>> from ow.models.workout import Workout
+    >>> root = OpenWorkouts()
+    >>> indexes = get_catalog(root)
+    >>> 'hashed' in root.catalog
+    True
+    >>> del root.catalog['hashed']
+    >>> 'hashed' in root.catalog
+    False
+    >>> user = User(email='user@example.net')
+    >>> workout = Workout(start=datetime.now(timezone.utc))
+    >>> workout.duration = timedelta(seconds=3600)
+    >>> workout.distance = Decimal(30)
+    >>> workout.sport = 'cycling'
+    >>> workout.title = 'cycling in migrations'
+    >>> root.add_user(user)
+    >>> user.add_workout(workout)
+    >>> migrate(root)
+    >>> 'hashed' in root.catalog
+    True
+    >>> hashes = [h for h in root.catalog['hashed']._fwd_index]
+    >>> workout.hashed in hashes
+    True
+    >>> 'some-faked-non-existant-hash' in hashes
+    False
+    """
+    root._update_indexes()
+    for user in root.users:
+        for workout in user.workouts():
+            root.reindex(workout)
Index: ow/models/root.py
===================================================================
--- ow/models/root.py	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/models/root.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -43,4 +43,5 @@
             'nickname': CatalogFieldIndex('nickname'),
             'sport': CatalogFieldIndex('sport'),
+            'hashed': CatalogFieldIndex('hashed'),
         }
         return indexes
@@ -140,2 +141,11 @@
     def sports_json(self):
         return json.dumps(self.sports)
+
+    def get_workout_by_hash(self, hashed):
+        if hashed is not None:
+            # for some reason, when searching for None
+            # the catalog will return all workouts
+            res = self.query(Eq('hashed', hashed))
+            if res:
+                return next(res)
+        return None
Index: ow/models/workout.py
===================================================================
--- ow/models/workout.py	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/models/workout.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -136,4 +136,18 @@
 
     @property
+    def hashed(self):
+        """
+        Return a unique hash that we use to look for duplicated workouts.
+        The hash contains: owner's uid, start time, duration and distance
+        """
+        hashed = ''
+        if self.owner is not None:
+            hashed += str(self.owner.uid)
+        hashed += self.start.strftime('%Y%m%d%H%M%S')
+        hashed += str(self.duration.seconds)
+        hashed += str(self.distance)
+        return hashed
+
+    @property
     def trimmed_notes(self):
         """
Index: ow/templates/add_workout.pt
===================================================================
--- ow/templates/add_workout.pt	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/templates/add_workout.pt	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -25,26 +25,84 @@
         ${form.csrf_token()}
 
-        <fieldset>
-          <p>
-              <label for="title" i18n:translate="">Title</label>
-              ${form.errorlist('title')}
-              ${form.text('title')}
-          </p>
-          <p>
-              <label for="notes" i18n:translate="">Notes</label>
-              ${form.errorlist('notes')}
-              ${form.textarea('notes', rows=10, cols=50)}
-          </p>
-          <p>
-            <label for="tracking_file" i18n:translate="">
-              Workout file (gpx, fit)</label>
-            ${form.errorlist('tracking_file')}
-            ${form.file('tracking_file')}
-          </p>
-        </fieldset>
+      <tal:is_duplicate tal:condition="duplicate is not None">
+        <article class="workout-resume">
 
-        <tal:with-localizer tal:define="localizer get_localizer(request)">
-          ${form.submit("submit", localizer.translate(_('Save')),  **{'class':"button button-normal"})}
-        </tal:with-localizer>
+          <h2 class="workout-title">
+            <tal:warning i18n:translate="">THIS MAY BE A DUPLICATE OF:</tal:warning>
+            <a href="" tal:content="duplicate.title"
+               tal:attributes="href request.resource_url(duplicate)"></a>
+          </h2>
+
+          <ul class="workout-info">
+            <li>
+              <tal:c tal:content="duplicate.sport"></tal:c>
+            </li>
+            <li>
+              <tal:c tal:content="duplicate.start_in_timezone(context.timezone)"></tal:c>
+            </li>
+            <li>
+              <!--! use the properly formatted duration instead of the timedelta object -->
+              <tal:c tal:content="duplicate._duration"></tal:c>
+            </li>
+            <li tal:condition="duplicate.distance">
+              <tal:c tal:content="duplicate.rounded_distance"></tal:c> km
+            </li>
+            <li tal:condition="duplicate.uphill">
+              +<tal:c tal:content="duplicate.uphill"></tal:c> m
+            </li>
+          </ul>
+
+          <div class="workout-map" tal:condition="duplicate.has_gpx">
+            <a href="" tal:attributes="href request.resource_url(duplicate)">
+              <tal:has-screenshot tal:condition="duplicate.map_screenshot is not None">
+                <img src="" tal:attributes="src request.static_url(duplicate.map_screenshot);
+                          alt duplicate.title; title duplicate.title">
+              </tal:has-screenshot>
+              <tal:has-not-screenshot tal:condition="duplicate.map_screenshot is None">
+                <img src="" tal:attributes="src request.static_url('ow:static/media/img/no_map.gif');
+                          alt duplicate.title; title duplicate.title; class 'js-needs-map'">
+              </tal:has-not-screenshot>
+            </a>
+          </div>
+
+        </article>
+
+        <div>
+      </tal:is_duplicate>
+
+      <fieldset>
+
+        <p>
+          <label for="title" i18n:translate="">Title</label>
+          ${form.errorlist('title')}
+          ${form.text('title')}
+        </p>
+
+        <p>
+          <label for="notes" i18n:translate="">Notes</label>
+          ${form.errorlist('notes')}
+          ${form.textarea('notes', rows=10, cols=50)}
+        </p>
+
+        <p>
+          <label for="tracking_file" i18n:translate="">
+            Workout file (gpx, fit)</label>
+          ${form.errorlist('tracking_file')}
+          ${form.file('tracking_file')}
+        </p>
+
+        <p tal:condition="duplicate is not None">
+          <label for="allow_duplicates" i18n:translate="">Allow duplicated workouts</label>
+          <small i18n:translate="">
+            Mark this checkbox if you want to add to override the mechanism not allowing duplicated workouts
+          </small>
+          <input type="checkbox" name="allow_duplicates">
+        </p>
+
+      </fieldset>
+
+      <tal:with-localizer tal:define="localizer get_localizer(request)">
+        ${form.submit("submit", localizer.translate(_('Save')),  **{'class':"button button-normal"})}
+      </tal:with-localizer>
 
       ${form.end()}
Index: ow/tests/models/test_root.py
===================================================================
--- ow/tests/models/test_root.py	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/tests/models/test_root.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -35,19 +35,19 @@
         # a new OpenWorkouts instance has a catalog created automatically
         assert isinstance(root.catalog, Catalog)
-        assert len(root.catalog) == 3
-        for key in ['email', 'nickname', 'sport']:
+        assert len(root.catalog) == 4
+        for key in ['email', 'nickname', 'sport', 'hashed']:
             assert key in root.catalog
 
     def test_update_indexes(self, root):
         indexes = sorted([i for i in root.catalog])
-        assert indexes == ['email', 'nickname', 'sport']
+        assert indexes == ['email', 'hashed', 'nickname', 'sport']
         # remove one index
         del root.catalog['email']
         indexes = sorted([i for i in root.catalog])
-        assert indexes == ['nickname', 'sport']
+        assert indexes == ['hashed', 'nickname', 'sport']
         # now update indexes, the index will be back there
         root._update_indexes()
         indexes = sorted([i for i in root.catalog])
-        assert indexes == ['email', 'nickname', 'sport']
+        assert indexes == ['email', 'hashed', 'nickname', 'sport']
 
     def test_add_user_ok(self, root):
Index: ow/tests/test_catalog.py
===================================================================
--- ow/tests/test_catalog.py	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/tests/test_catalog.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -56,5 +56,5 @@
         changes = update_indexes(catalog, indexes)
         assert changes['added'] == ['newindex']
-        assert changes['removed'] == ['email', 'nickname', 'sport']
+        assert changes['removed'] == ['email', 'nickname', 'sport', 'hashed']
 
     def test_update_indexes_empty(self, root):
@@ -63,5 +63,5 @@
         changes = update_indexes(catalog, indexes)
         assert changes['added'] == []
-        assert changes['removed'] == ['email', 'nickname', 'sport']
+        assert changes['removed'] == ['email', 'nickname', 'sport', 'hashed']
 
     def test_install_catalog(self):
Index: ow/views/workout.py
===================================================================
--- ow/views/workout.py	(revision e52a502772a6427641a63efc127cc769cfec6d81)
+++ ow/views/workout.py	(revision 42baca45356aff1b03ad74e6c467053f46e60834)
@@ -89,4 +89,7 @@
     form = Form(request, schema=UploadedWorkoutSchema())
 
+    duplicate = None
+    allow_duplicates = request.POST.get('allow_duplicates') == 'on'
+
     if 'submit' in request.POST and form.validate():
         # Grab some information from the tracking file
@@ -98,10 +101,24 @@
         # Add basic info gathered from the file
         workout.load_from_file()
-        # Add the workout
-        context.add_workout(workout)
-        return HTTPFound(location=request.resource_url(workout))
+        # Ensure this workout is not a duplicate of an existing workout.
+        #
+        # hashed is not "complete" for a workout that has not been added
+        # yet, as it does not have the owner set, so we have to "build it"
+        hashed = str(context.uid) + workout.hashed
+        duplicate = request.root.get_workout_by_hash(hashed)
+
+        if duplicate and not allow_duplicates:
+            form.errors['tracking_file'] = _(
+                'This workout looks like a duplicate of another workout, '
+                'please enable workout duplicates below to save it'
+            )
+        else:
+            # Add the workout
+            context.add_workout(workout)
+            return HTTPFound(location=request.resource_url(workout))
 
     return {
-        'form': FormRenderer(form)
+        'form': FormRenderer(form),
+        'duplicate': duplicate
     }
 
