geo-activity-playground 0.23.0__py3-none-any.whl → 0.24.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. geo_activity_playground/__main__.py +1 -1
  2. geo_activity_playground/core/activities.py +18 -12
  3. geo_activity_playground/core/activity_parsers.py +8 -32
  4. geo_activity_playground/core/cache_migrations.py +24 -0
  5. geo_activity_playground/core/heatmap.py +21 -21
  6. geo_activity_playground/core/privacy_zones.py +16 -0
  7. geo_activity_playground/core/similarity.py +1 -1
  8. geo_activity_playground/core/test_time_conversion.py +37 -0
  9. geo_activity_playground/core/time_conversion.py +14 -0
  10. geo_activity_playground/explorer/tile_visits.py +44 -32
  11. geo_activity_playground/importers/__init__.py +0 -0
  12. geo_activity_playground/importers/directory.py +7 -2
  13. geo_activity_playground/importers/strava_api.py +8 -1
  14. geo_activity_playground/importers/strava_checkout.py +4 -3
  15. geo_activity_playground/webui/__init__.py +0 -0
  16. geo_activity_playground/webui/activity/__init__.py +0 -0
  17. geo_activity_playground/webui/activity/blueprint.py +58 -0
  18. geo_activity_playground/webui/{activity_controller.py → activity/controller.py} +128 -18
  19. geo_activity_playground/webui/{templates/activity-day.html.j2 → activity/templates/activity/day.html.j2} +14 -2
  20. geo_activity_playground/webui/{templates/activity-name.html.j2 → activity/templates/activity/name.html.j2} +1 -1
  21. geo_activity_playground/webui/{templates/activity.html.j2 → activity/templates/activity/show.html.j2} +9 -4
  22. geo_activity_playground/webui/app.py +54 -283
  23. geo_activity_playground/webui/calendar/__init__.py +0 -0
  24. geo_activity_playground/webui/calendar/blueprint.py +26 -0
  25. geo_activity_playground/webui/{calendar_controller.py → calendar/controller.py} +5 -5
  26. geo_activity_playground/webui/{templates/calendar.html.j2 → calendar/templates/calendar/index.html.j2} +3 -2
  27. geo_activity_playground/webui/{templates/calendar-month.html.j2 → calendar/templates/calendar/month.html.j2} +2 -2
  28. geo_activity_playground/webui/eddington/__init__.py +0 -0
  29. geo_activity_playground/webui/eddington/blueprint.py +19 -0
  30. geo_activity_playground/webui/{eddington_controller.py → eddington/controller.py} +14 -6
  31. geo_activity_playground/webui/eddington/templates/eddington/index.html.j2 +56 -0
  32. geo_activity_playground/webui/entry_controller.py +1 -1
  33. geo_activity_playground/webui/equipment/__init__.py +0 -0
  34. geo_activity_playground/webui/equipment/blueprint.py +19 -0
  35. geo_activity_playground/webui/{equipment_controller.py → equipment/controller.py} +5 -3
  36. geo_activity_playground/webui/explorer/__init__.py +0 -0
  37. geo_activity_playground/webui/explorer/blueprint.py +54 -0
  38. geo_activity_playground/webui/{templates/explorer.html.j2 → explorer/templates/explorer/index.html.j2} +2 -2
  39. geo_activity_playground/webui/heatmap/__init__.py +0 -0
  40. geo_activity_playground/webui/heatmap/blueprint.py +41 -0
  41. geo_activity_playground/webui/{heatmap_controller.py → heatmap/heatmap_controller.py} +38 -11
  42. geo_activity_playground/webui/{templates/heatmap.html.j2 → heatmap/templates/heatmap/index.html.j2} +17 -2
  43. geo_activity_playground/webui/search_controller.py +1 -9
  44. geo_activity_playground/webui/square_planner/__init__.py +0 -0
  45. geo_activity_playground/webui/square_planner/blueprint.py +38 -0
  46. geo_activity_playground/webui/summary/__init__.py +0 -0
  47. geo_activity_playground/webui/summary/blueprint.py +16 -0
  48. geo_activity_playground/webui/summary/controller.py +268 -0
  49. geo_activity_playground/webui/summary/templates/summary/index.html.j2 +135 -0
  50. geo_activity_playground/webui/templates/{index.html.j2 → home.html.j2} +1 -1
  51. geo_activity_playground/webui/templates/page.html.j2 +22 -19
  52. geo_activity_playground/webui/templates/search.html.j2 +1 -1
  53. geo_activity_playground/webui/tile/__init__.py +0 -0
  54. geo_activity_playground/webui/tile/blueprint.py +31 -0
  55. geo_activity_playground/webui/upload/__init__.py +0 -0
  56. geo_activity_playground/webui/upload/blueprint.py +28 -0
  57. geo_activity_playground/webui/{upload_controller.py → upload/controller.py} +1 -0
  58. geo_activity_playground/webui/{templates/upload.html.j2 → upload/templates/upload/index.html.j2} +1 -1
  59. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/METADATA +2 -1
  60. geo_activity_playground-0.24.1.dist-info/RECORD +95 -0
  61. geo_activity_playground/webui/config_controller.py +0 -12
  62. geo_activity_playground/webui/locations_controller.py +0 -28
  63. geo_activity_playground/webui/summary_controller.py +0 -60
  64. geo_activity_playground/webui/templates/config.html.j2 +0 -24
  65. geo_activity_playground/webui/templates/eddington.html.j2 +0 -18
  66. geo_activity_playground/webui/templates/locations.html.j2 +0 -38
  67. geo_activity_playground/webui/templates/summary.html.j2 +0 -21
  68. geo_activity_playground-0.23.0.dist-info/RECORD +0 -74
  69. /geo_activity_playground/webui/{templates/activity-lines.html.j2 → activity/templates/activity/lines.html.j2} +0 -0
  70. /geo_activity_playground/webui/{templates/equipment.html.j2 → equipment/templates/equipment/index.html.j2} +0 -0
  71. /geo_activity_playground/webui/{explorer_controller.py → explorer/controller.py} +0 -0
  72. /geo_activity_playground/webui/{square_planner_controller.py → square_planner/controller.py} +0 -0
  73. /geo_activity_playground/webui/{templates/square-planner.html.j2 → square_planner/templates/square_planner/index.html.j2} +0 -0
  74. /geo_activity_playground/webui/{tile_controller.py → tile/controller.py} +0 -0
  75. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/LICENSE +0 -0
  76. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/WHEEL +0 -0
  77. {geo_activity_playground-0.23.0.dist-info → geo_activity_playground-0.24.1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,58 @@
1
+ import urllib.parse
2
+ from collections.abc import Collection
3
+
4
+ from flask import Blueprint
5
+ from flask import render_template
6
+ from flask import Response
7
+
8
+ from ...core.activities import ActivityRepository
9
+ from ...explorer.tile_visits import TileVisitAccessor
10
+ from .controller import ActivityController
11
+ from geo_activity_playground.core.privacy_zones import PrivacyZone
12
+
13
+
14
+ def make_activity_blueprint(
15
+ repository: ActivityRepository,
16
+ tile_visit_accessor: TileVisitAccessor,
17
+ privacy_zones: Collection[PrivacyZone],
18
+ ) -> Blueprint:
19
+ blueprint = Blueprint("activity", __name__, template_folder="templates")
20
+
21
+ activity_controller = ActivityController(
22
+ repository, tile_visit_accessor, privacy_zones
23
+ )
24
+
25
+ @blueprint.route("/all")
26
+ def all():
27
+ return render_template(
28
+ "activity/lines.html.j2", **activity_controller.render_all()
29
+ )
30
+
31
+ @blueprint.route("/<id>")
32
+ def show(id: str):
33
+ return render_template(
34
+ "activity/show.html.j2", **activity_controller.render_activity(int(id))
35
+ )
36
+
37
+ @blueprint.route("/<id>/sharepic.png")
38
+ def sharepic(id: str):
39
+ return Response(
40
+ activity_controller.render_sharepic(int(id)),
41
+ mimetype="image/png",
42
+ )
43
+
44
+ @blueprint.route("/day/<year>/<month>/<day>")
45
+ def day(year: str, month: str, day: str):
46
+ return render_template(
47
+ "activity/day.html.j2",
48
+ **activity_controller.render_day(int(year), int(month), int(day))
49
+ )
50
+
51
+ @blueprint.route("/name/<name>")
52
+ def name(name: str):
53
+ return render_template(
54
+ "activity/name.html.j2",
55
+ **activity_controller.render_name(urllib.parse.unquote(name))
56
+ )
57
+
58
+ return blueprint
@@ -2,7 +2,8 @@ import datetime
2
2
  import functools
3
3
  import io
4
4
  import logging
5
- import pickle
5
+ import re
6
+ from collections.abc import Collection
6
7
 
7
8
  import altair as alt
8
9
  import geojson
@@ -12,7 +13,9 @@ import numpy as np
12
13
  import pandas as pd
13
14
  from PIL import Image
14
15
  from PIL import ImageDraw
16
+ from PIL import ImageFont
15
17
 
18
+ from geo_activity_playground.core.activities import ActivityMeta
16
19
  from geo_activity_playground.core.activities import ActivityRepository
17
20
  from geo_activity_playground.core.activities import extract_heart_rate_zones
18
21
  from geo_activity_playground.core.activities import make_geojson_color_line
@@ -20,18 +23,29 @@ from geo_activity_playground.core.activities import make_geojson_from_time_serie
20
23
  from geo_activity_playground.core.activities import make_speed_color_bar
21
24
  from geo_activity_playground.core.heatmap import add_margin_to_geo_bounds
22
25
  from geo_activity_playground.core.heatmap import build_map_from_tiles
23
- from geo_activity_playground.core.heatmap import crop_image_to_bounds
26
+ from geo_activity_playground.core.heatmap import GeoBounds
24
27
  from geo_activity_playground.core.heatmap import get_bounds
25
28
  from geo_activity_playground.core.heatmap import get_sensible_zoom_level
26
29
  from geo_activity_playground.core.heatmap import OSM_TILE_SIZE
30
+ from geo_activity_playground.core.heatmap import PixelBounds
31
+ from geo_activity_playground.core.heatmap import TileBounds
32
+ from geo_activity_playground.core.privacy_zones import PrivacyZone
27
33
  from geo_activity_playground.core.tiles import compute_tile_float
34
+ from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
28
35
 
29
36
  logger = logging.getLogger(__name__)
30
37
 
31
38
 
32
39
  class ActivityController:
33
- def __init__(self, repository: ActivityRepository) -> None:
40
+ def __init__(
41
+ self,
42
+ repository: ActivityRepository,
43
+ tile_visit_accessor: TileVisitAccessor,
44
+ privacy_zones: Collection[PrivacyZone],
45
+ ) -> None:
34
46
  self._repository = repository
47
+ self._tile_visit_accessor = tile_visit_accessor
48
+ self._privacy_zones = privacy_zones
35
49
 
36
50
  @functools.lru_cache()
37
51
  def render_activity(self, id: int) -> dict:
@@ -47,6 +61,14 @@ class ActivityController:
47
61
  similar_activities = [row for _, row in similar_activities.iterrows()]
48
62
  similar_activities.reverse()
49
63
 
64
+ new_tiles = {
65
+ zoom: sum(
66
+ self._tile_visit_accessor.histories[zoom]["activity_id"]
67
+ == activity["id"]
68
+ )
69
+ for zoom in [14, 17]
70
+ }
71
+
50
72
  result = {
51
73
  "activity": activity,
52
74
  "line_json": line_json,
@@ -56,8 +78,9 @@ class ActivityController:
56
78
  "speed_distribution_plot": speed_distribution_plot(time_series),
57
79
  "similar_activites": similar_activities,
58
80
  "speed_color_bar": make_speed_color_bar(time_series),
59
- "date": activity.start.date(),
60
- "time": activity.start.time(),
81
+ "date": activity["start"].date(),
82
+ "time": activity["start"].time(),
83
+ "new_tiles": new_tiles,
61
84
  }
62
85
  if (heart_zones := extract_heart_rate_zones(time_series)) is not None:
63
86
  result["heart_zones_plot"] = heartrate_zone_plot(heart_zones)
@@ -68,8 +91,13 @@ class ActivityController:
68
91
  return result
69
92
 
70
93
  def render_sharepic(self, id: int) -> bytes:
94
+ activity = self._repository.get_activity_by_id(id)
71
95
  time_series = self._repository.get_time_series(id)
72
- return make_sharepic(time_series)
96
+ for privacy_zone in self._privacy_zones:
97
+ time_series = privacy_zone.filter_time_series(time_series)
98
+ if len(time_series) == 0:
99
+ time_series = self._repository.get_time_series(id)
100
+ return make_sharepic(activity, time_series)
73
101
 
74
102
  def render_day(self, year: int, month: int, day: int) -> dict:
75
103
  meta = self._repository.meta
@@ -110,6 +138,8 @@ class ActivityController:
110
138
  "activities": activities_list,
111
139
  "geojson": geojson.dumps(fc),
112
140
  "date": datetime.date(year, month, day).isoformat(),
141
+ "total_distance": activities_that_day["distance_km"].sum(),
142
+ "total_elapsed_time": activities_that_day["elapsed_time"].sum(),
113
143
  }
114
144
 
115
145
  def render_all(self) -> dict:
@@ -322,17 +352,83 @@ def name_minutes_plot(meta: pd.DataFrame) -> str:
322
352
  )
323
353
 
324
354
 
325
- def make_sharepic(time_series: pd.DataFrame) -> bytes:
355
+ def make_pixel_bounds_square(bounds: PixelBounds) -> PixelBounds:
356
+ x_radius = (bounds.x_max - bounds.x_min) // 2
357
+ y_radius = (bounds.y_max - bounds.y_min) // 2
358
+ x_center = (bounds.x_max + bounds.x_min) // 2
359
+ y_center = (bounds.y_max + bounds.y_min) // 2
360
+
361
+ radius = max(x_radius, y_radius)
362
+
363
+ return PixelBounds(
364
+ x_min=x_center - radius,
365
+ y_min=y_center - radius,
366
+ x_max=x_center + radius,
367
+ y_max=y_center + radius,
368
+ )
369
+
370
+
371
+ def make_tile_bounds_square(bounds: TileBounds) -> TileBounds:
372
+ x_radius = (bounds.x_tile_max - bounds.x_tile_min) / 2
373
+ y_radius = (bounds.y_tile_max - bounds.y_tile_min) / 2
374
+ x_center = (bounds.x_tile_max + bounds.x_tile_min) / 2
375
+ y_center = (bounds.y_tile_max + bounds.y_tile_min) / 2
376
+
377
+ radius = max(x_radius, y_radius)
378
+
379
+ return TileBounds(
380
+ zoom=bounds.zoom,
381
+ x_tile_min=int(x_center - radius),
382
+ y_tile_min=int(y_center - radius),
383
+ x_tile_max=int(np.ceil(x_center + radius)),
384
+ y_tile_max=int(np.ceil(y_center + radius)),
385
+ )
386
+
387
+
388
+ def get_crop_mask(geo_bounds: GeoBounds, tile_bounds: TileBounds) -> PixelBounds:
389
+ min_x, min_y = compute_tile_float(
390
+ geo_bounds.lat_max, geo_bounds.lon_min, tile_bounds.zoom
391
+ )
392
+ max_x, max_y = compute_tile_float(
393
+ geo_bounds.lat_min, geo_bounds.lon_max, tile_bounds.zoom
394
+ )
395
+
396
+ crop_mask = PixelBounds(
397
+ int((min_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE),
398
+ int((max_x - tile_bounds.x_tile_min) * OSM_TILE_SIZE),
399
+ int((min_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE),
400
+ int((max_y - tile_bounds.y_tile_min) * OSM_TILE_SIZE),
401
+ )
402
+ crop_mask = make_pixel_bounds_square(crop_mask)
403
+
404
+ return crop_mask
405
+
406
+
407
+ def pixels_in_bounds(bounds: PixelBounds) -> int:
408
+ return (bounds.x_max - bounds.x_min) * (bounds.y_max - bounds.y_min)
409
+
410
+
411
+ def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
326
412
  lat_lon_data = np.array([time_series["latitude"], time_series["longitude"]]).T
327
413
 
328
414
  geo_bounds = get_bounds(lat_lon_data)
329
415
  geo_bounds = add_margin_to_geo_bounds(geo_bounds)
330
416
  tile_bounds = get_sensible_zoom_level(geo_bounds, (1500, 1500))
417
+ tile_bounds = make_tile_bounds_square(tile_bounds)
331
418
  background = build_map_from_tiles(tile_bounds)
332
419
  # background = convert_to_grayscale(background)
333
420
 
334
- img = Image.new("RGB", tile_bounds.shape[::-1])
335
- draw = ImageDraw.Draw(img)
421
+ crop_mask = get_crop_mask(geo_bounds, tile_bounds)
422
+ assert pixels_in_bounds(crop_mask) <= 10_000_000, crop_mask
423
+
424
+ background = background[
425
+ crop_mask.y_min : crop_mask.y_max,
426
+ crop_mask.x_min : crop_mask.x_max,
427
+ :,
428
+ ]
429
+
430
+ img = Image.fromarray((background * 255).astype("uint8"), "RGB")
431
+ draw = ImageDraw.Draw(img, mode="RGBA")
336
432
 
337
433
  for _, group in time_series.groupby("segment_id"):
338
434
  xs, ys = compute_tile_float(
@@ -340,24 +436,38 @@ def make_sharepic(time_series: pd.DataFrame) -> bytes:
340
436
  )
341
437
  yx = list(
342
438
  (
343
- int((x - tile_bounds.x_tile_min) * OSM_TILE_SIZE),
344
- int((y - tile_bounds.y_tile_min) * OSM_TILE_SIZE),
439
+ int((x - tile_bounds.x_tile_min) * OSM_TILE_SIZE - crop_mask.x_min),
440
+ int((y - tile_bounds.y_tile_min) * OSM_TILE_SIZE - crop_mask.y_min),
345
441
  )
346
442
  for x, y in zip(xs, ys)
347
443
  )
348
444
 
349
445
  draw.line(yx, fill="red", width=4)
350
446
 
351
- aimg = np.array(img) / 255
447
+ draw.rectangle([0, img.height - 70, img.width, img.height], fill=(0, 0, 0, 128))
448
+ facts = [
449
+ f"{activity['kind']}",
450
+ f"{activity['start'].date()}",
451
+ f"{activity['equipment']}",
452
+ f"\n{activity['distance_km']:.1f} km",
453
+ re.sub(r"^0 days ", "", f"{activity['elapsed_time']}"),
454
+ ]
455
+ if activity["calories"] and not pd.isna(activity["calories"]):
456
+ facts.append(f"{activity['calories']:.0f} kcal")
457
+ if activity["steps"] and not pd.isna(activity["steps"]):
458
+ facts.append(f"{activity['steps']:.0f} steps")
459
+
460
+ draw.text((35, img.height - 70 + 10), " ".join(facts), font_size=20)
352
461
 
353
- weight = np.dstack([aimg[:, :, 0]] * 3)
462
+ # img_array = np.array(img) / 255
354
463
 
355
- background = (1 - weight) * background + aimg
356
- background[background > 1.0] = 1.0
357
- background[background < 0.0] = 0.0
464
+ # weight = np.dstack([img_array[:, :, 0]] * 3)
358
465
 
359
- background = crop_image_to_bounds(background, geo_bounds, tile_bounds)
466
+ # background = (1 - weight) * background + img_array
467
+ # background[background > 1.0] = 1.0
468
+ # background[background < 0.0] = 0.0
360
469
 
361
470
  f = io.BytesIO()
362
- pl.imsave(f, background, format="png")
471
+ img.save(f, format="png")
472
+ # pl.imsave(f, background, format="png")
363
473
  return bytes(f.getbuffer())
@@ -29,7 +29,8 @@
29
29
  <div class="col-md-3">
30
30
  <ol>
31
31
  {% for activity in activities %}
32
- <li><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
32
+ <li><span style="color: {{ activity['color'] }};">█</span> <a
33
+ href="{{ url_for('activity.show', id=activity.id) }}">{{
33
34
  activity.name }}</a></li>
34
35
  {% endfor %}
35
36
  </ol>
@@ -54,7 +55,8 @@
54
55
  <tbody>
55
56
  {% for activity in activities %}
56
57
  <tr>
57
- <td><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
58
+ <td><span style="color: {{ activity['color'] }};">█</span> <a
59
+ href="{{ url_for('activity.show', id=activity.id) }}">{{
58
60
  activity.name }}</a></td>
59
61
  <td>{{ activity.start }}</td>
60
62
  <td>{{ activity.distance_km | round(1) }}</td>
@@ -63,6 +65,16 @@
63
65
  <td>{{ activity["kind"] }}</td>
64
66
  </tr>
65
67
  {% endfor %}
68
+ {% if activities|length > 1 %}
69
+ <tr>
70
+ <td><b>Total</b></td>
71
+ <td></td>
72
+ <td><b>{{ total_distance | round(1) }}</b></td>
73
+ <td><b>{{ total_elapsed_time }}</b></td>
74
+ <td></td>
75
+ <td></td>
76
+ </tr>
77
+ {% endif %}
66
78
  </tbody>
67
79
  </table>
68
80
  </div>
@@ -64,7 +64,7 @@
64
64
  <tbody>
65
65
  {% for activity in activities %}
66
66
  <tr>
67
- <td><span style="color: {{ activity['color'] }};">█</span> <a href="/activity/{{ activity.id }}">{{
67
+ <td><span style="color: {{ activity['color'] }};">█</span> <a href="{{ url_for('activity.show', id=activity.id) }}">{{
68
68
  activity.name }}</a></td>
69
69
  <td>{{ activity.start }}</td>
70
70
  <td>{{ activity.distance_km | round(1) }}</td>
@@ -21,7 +21,8 @@
21
21
  <dt>Elapsed time</dt>
22
22
  <dd>{{ activity.elapsed_time }}</dd>
23
23
  <dt>Start time</dt>
24
- <dd><a href="/activity/day/{{ date.year }}/{{ date.month }}/{{ date.day }}">{{ date }}</a> {{ time }}
24
+ <dd><a href="{{ url_for('activity.day', year=date.year, month=date.month, day=date.day) }}">{{ date }}</a>
25
+ {{ time }}
25
26
  </dd>
26
27
  <dt>Calories</dt>
27
28
  <dd>{{ activity.calories }}</dd>
@@ -29,6 +30,10 @@
29
30
  <dd>{{ activity.steps }}</dd>
30
31
  <dt>Equipment</dt>
31
32
  <dd>{{ activity.equipment }}</dd>
33
+ <dt>New Explorer Tiles</dt>
34
+ <dd>{{ new_tiles[14] }}</dd>
35
+ <dt>New Squadratinhos</dt>
36
+ <dd>{{ new_tiles[17] }}</dd>
32
37
  <dt>ID</dt>
33
38
  <dd>{{ activity.id }}</dd>
34
39
  <dt>Source path</dt>
@@ -126,7 +131,7 @@
126
131
  <div class="col">
127
132
  <h2>Share picture</h2>
128
133
 
129
- <img src="/activity/{{ activity.id }}/sharepic.png" />
134
+ <img src="{{ url_for('.sharepic', id=activity.id) }}" />
130
135
  </div>
131
136
  </div>
132
137
 
@@ -135,7 +140,7 @@
135
140
  <div class="col">
136
141
  <h2>Activities with the same name</h2>
137
142
 
138
- <p><a href="/activity/name/{{ activity['name']|urlencode() }}">Overview over these activities</a></p>
143
+ <p><a href="{{ url_for('.name', name=activity['name']) }}">Overview over these activities</a></p>
139
144
 
140
145
  <table class="table">
141
146
  <thead>
@@ -150,7 +155,7 @@
150
155
  <tbody>
151
156
  {% for other_activity in similar_activites %}
152
157
  <tr>
153
- <td><a href="/activity/{{ other_activity.id }}">{{ other_activity.start
158
+ <td><a href="{{ url_for('.show', id=other_activity.id) }}">{{ other_activity.start
154
159
  }}</a></td>
155
160
  <td>{{ other_activity.distance_km | round(1) }}</td>
156
161
  <td>{{ other_activity.elapsed_time }}</td>