geo-activity-playground 0.26.2__py3-none-any.whl → 0.27.0__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 (47) hide show
  1. geo_activity_playground/__main__.py +23 -20
  2. geo_activity_playground/core/activities.py +1 -44
  3. geo_activity_playground/core/config.py +111 -0
  4. geo_activity_playground/core/enrichment.py +22 -3
  5. geo_activity_playground/core/heart_rate.py +49 -0
  6. geo_activity_playground/core/paths.py +6 -0
  7. geo_activity_playground/core/tasks.py +14 -0
  8. geo_activity_playground/core/tiles.py +1 -1
  9. geo_activity_playground/explorer/tile_visits.py +23 -11
  10. geo_activity_playground/importers/csv_parser.py +73 -0
  11. geo_activity_playground/importers/directory.py +17 -8
  12. geo_activity_playground/importers/strava_api.py +20 -44
  13. geo_activity_playground/importers/strava_checkout.py +63 -36
  14. geo_activity_playground/importers/test_csv_parser.py +49 -0
  15. geo_activity_playground/webui/activity/blueprint.py +3 -4
  16. geo_activity_playground/webui/activity/controller.py +40 -14
  17. geo_activity_playground/webui/activity/templates/activity/show.html.j2 +6 -2
  18. geo_activity_playground/webui/app.py +26 -26
  19. geo_activity_playground/webui/eddington/controller.py +1 -1
  20. geo_activity_playground/webui/equipment/blueprint.py +5 -2
  21. geo_activity_playground/webui/equipment/controller.py +5 -6
  22. geo_activity_playground/webui/explorer/blueprint.py +14 -2
  23. geo_activity_playground/webui/explorer/controller.py +21 -1
  24. geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +12 -1
  25. geo_activity_playground/webui/settings/blueprint.py +106 -0
  26. geo_activity_playground/webui/settings/controller.py +228 -0
  27. geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2 +44 -0
  28. geo_activity_playground/webui/settings/templates/settings/heart-rate.html.j2 +102 -0
  29. geo_activity_playground/webui/settings/templates/settings/index.html.j2 +74 -0
  30. geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2 +30 -0
  31. geo_activity_playground/webui/settings/templates/settings/metadata-extraction.html.j2 +55 -0
  32. geo_activity_playground/webui/settings/templates/settings/privacy-zones.html.j2 +81 -0
  33. geo_activity_playground/webui/{strava/templates/strava/client-id.html.j2 → settings/templates/settings/strava.html.j2} +17 -7
  34. geo_activity_playground/webui/templates/page.html.j2 +5 -1
  35. geo_activity_playground/webui/upload/blueprint.py +10 -1
  36. geo_activity_playground/webui/upload/controller.py +24 -11
  37. geo_activity_playground/webui/upload/templates/upload/reload.html.j2 +16 -0
  38. {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/METADATA +2 -2
  39. {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/RECORD +42 -35
  40. geo_activity_playground/webui/strava/__init__.py +0 -0
  41. geo_activity_playground/webui/strava/blueprint.py +0 -33
  42. geo_activity_playground/webui/strava/controller.py +0 -49
  43. geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +0 -14
  44. geo_activity_playground/webui/templates/settings.html.j2 +0 -24
  45. {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/LICENSE +0 -0
  46. {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/WHEEL +0 -0
  47. {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/entry_points.txt +0 -0
@@ -2,12 +2,13 @@ import altair as alt
2
2
  import pandas as pd
3
3
 
4
4
  from geo_activity_playground.core.activities import ActivityRepository
5
- from geo_activity_playground.core.config import get_config
5
+ from geo_activity_playground.core.config import Config
6
6
 
7
7
 
8
8
  class EquipmentController:
9
- def __init__(self, repository: ActivityRepository) -> None:
9
+ def __init__(self, repository: ActivityRepository, config: Config) -> None:
10
10
  self._repository = repository
11
+ self._config = config
11
12
 
12
13
  def render(self) -> dict:
13
14
  kind_per_equipment = self._repository.meta.groupby("equipment").apply(
@@ -101,10 +102,8 @@ class EquipmentController:
101
102
  "usages_plot_id": f"usages_plot_{hash(equipment)}",
102
103
  }
103
104
 
104
- config = get_config()
105
- if "offsets" in config:
106
- for equipment, offset in config["offsets"].items():
107
- equipment_summary.loc[equipment, "total_distance_km"] += offset
105
+ for equipment, offset in self._config.equipment_offsets.items():
106
+ equipment_summary.loc[equipment, "total_distance_km"] += offset
108
107
 
109
108
  return {
110
109
  "equipment_variables": equipment_variables,
@@ -1,16 +1,23 @@
1
1
  from flask import Blueprint
2
+ from flask import redirect
2
3
  from flask import render_template
3
4
  from flask import Response
5
+ from flask import url_for
4
6
 
5
7
  from ...core.activities import ActivityRepository
6
8
  from ...explorer.tile_visits import TileVisitAccessor
7
9
  from .controller import ExplorerController
10
+ from geo_activity_playground.core.config import ConfigAccessor
8
11
 
9
12
 
10
13
  def make_explorer_blueprint(
11
- repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
14
+ repository: ActivityRepository,
15
+ tile_visit_accessor: TileVisitAccessor,
16
+ config_accessor: ConfigAccessor,
12
17
  ) -> Blueprint:
13
- explorer_controller = ExplorerController(repository, tile_visit_accessor)
18
+ explorer_controller = ExplorerController(
19
+ repository, tile_visit_accessor, config_accessor
20
+ )
14
21
  blueprint = Blueprint("explorer", __name__, template_folder="templates")
15
22
 
16
23
  @blueprint.route("/<zoom>")
@@ -19,6 +26,11 @@ def make_explorer_blueprint(
19
26
  "explorer/index.html.j2", **explorer_controller.render(int(zoom))
20
27
  )
21
28
 
29
+ @blueprint.route("/enable-zoom-level/<zoom>")
30
+ def enable_zoom_level(zoom: str):
31
+ explorer_controller.enable_zoom_level(int(zoom))
32
+ return redirect(url_for(".map", zoom=zoom))
33
+
22
34
  @blueprint.route("/<zoom>/<north>/<east>/<south>/<west>/explored.<suffix>")
23
35
  def download(zoom: str, north: str, east: str, south: str, west: str, suffix: str):
24
36
  mimetypes = {"geojson": "application/json", "gpx": "application/xml"}
@@ -7,8 +7,10 @@ import geojson
7
7
  import matplotlib
8
8
  import numpy as np
9
9
  import pandas as pd
10
+ from flask import flash
10
11
 
11
12
  from geo_activity_playground.core.activities import ActivityRepository
13
+ from geo_activity_playground.core.config import ConfigAccessor
12
14
  from geo_activity_playground.core.coordinates import Bounds
13
15
  from geo_activity_playground.core.tiles import compute_tile
14
16
  from geo_activity_playground.core.tiles import get_tile_upper_left_lat_lon
@@ -19,6 +21,7 @@ from geo_activity_playground.explorer.grid_file import make_explorer_tile
19
21
  from geo_activity_playground.explorer.grid_file import make_grid_file_geojson
20
22
  from geo_activity_playground.explorer.grid_file import make_grid_file_gpx
21
23
  from geo_activity_playground.explorer.grid_file import make_grid_points
24
+ from geo_activity_playground.explorer.tile_visits import compute_tile_evolution
22
25
  from geo_activity_playground.explorer.tile_visits import TileEvolutionState
23
26
  from geo_activity_playground.explorer.tile_visits import TileVisitAccessor
24
27
 
@@ -28,12 +31,29 @@ alt.data_transformers.enable("vegafusion")
28
31
 
29
32
  class ExplorerController:
30
33
  def __init__(
31
- self, repository: ActivityRepository, tile_visit_accessor: TileVisitAccessor
34
+ self,
35
+ repository: ActivityRepository,
36
+ tile_visit_accessor: TileVisitAccessor,
37
+ config_accessor: ConfigAccessor,
32
38
  ) -> None:
33
39
  self._repository = repository
34
40
  self._tile_visit_accessor = tile_visit_accessor
41
+ self._config_accessor = config_accessor
42
+
43
+ def enable_zoom_level(self, zoom: int) -> None:
44
+ if 0 <= zoom <= 19:
45
+ self._config_accessor().explorer_zoom_levels.append(zoom)
46
+ self._config_accessor().explorer_zoom_levels.sort()
47
+ self._config_accessor.save()
48
+ compute_tile_evolution(self._tile_visit_accessor, self._config_accessor())
49
+ flash(f"Enabled {zoom=} for explorer tiles.", category="success")
50
+ else:
51
+ flash(f"{zoom=} is not valid, must be between 0 and 19.", category="danger")
35
52
 
36
53
  def render(self, zoom: int) -> dict:
54
+ if zoom not in self._config_accessor().explorer_zoom_levels:
55
+ return {"zoom_level_not_generated": zoom}
56
+
37
57
  tile_evolution_states = self._tile_visit_accessor.states
38
58
  tile_visits = self._tile_visit_accessor.visits
39
59
  tile_histories = self._tile_visit_accessor.histories
@@ -1,9 +1,19 @@
1
1
  {% extends "page.html.j2" %}
2
2
 
3
3
  {% block container %}
4
+
5
+ <h1>Explorer Tiles</h1>
6
+
7
+ {% if zoom_level_not_generated %}
8
+ <p>You try to access explorer tiles for a level that hasn't been generated before. That is not a problem, we just don't
9
+ generate all levels to save a bit of time. If you want to have it, just enable it!</p>
10
+
11
+ <a href="{{ url_for('.enable_zoom_level', zoom=zoom_level_not_generated) }}" class="btn btn-primary">Enable Zoom {{
12
+ zoom_level_not_generated }}</a>
13
+ {% else %}
14
+
4
15
  <div class="row mb-3">
5
16
  <div class="col">
6
- <h1>Explorer Tiles</h1>
7
17
  <p>You have {{ explored.num_tiles }} explored tiles. There are {{ explored.num_cluster_tiles }} cluster tiles in
8
18
  total. Your largest cluster consists of {{ explored.max_cluster_size }} tiles. Your largest square has size
9
19
  {{
@@ -154,4 +164,5 @@
154
164
  </div>
155
165
  </div>
156
166
 
167
+ {% endif %}
157
168
  {% endblock %}
@@ -0,0 +1,106 @@
1
+ from typing import Optional
2
+
3
+ from flask import Blueprint
4
+ from flask import flash
5
+ from flask import redirect
6
+ from flask import render_template
7
+ from flask import request
8
+ from flask import url_for
9
+
10
+ from geo_activity_playground.core.config import ConfigAccessor
11
+ from geo_activity_playground.webui.settings.controller import SettingsController
12
+
13
+
14
+ def int_or_none(s: str) -> Optional[int]:
15
+ if s:
16
+ try:
17
+ return int(s)
18
+ except ValueError as e:
19
+ flash(f"Cannot parse integer from {s}: {e}", category="danger")
20
+
21
+
22
+ def make_settings_blueprint(config_accessor: ConfigAccessor) -> Blueprint:
23
+ settings_controller = SettingsController(config_accessor)
24
+ blueprint = Blueprint("settings", __name__, template_folder="templates")
25
+
26
+ @blueprint.route("/")
27
+ def index():
28
+ return render_template("settings/index.html.j2")
29
+
30
+ @blueprint.route("/equipment-offsets", methods=["GET", "POST"])
31
+ def equipment_offsets():
32
+ if request.method == "POST":
33
+ equipments = request.form.getlist("equipment")
34
+ offsets = request.form.getlist("offset")
35
+ settings_controller.save_equipment_offsets(equipments, offsets)
36
+ return render_template(
37
+ "settings/equipment-offsets.html.j2",
38
+ **settings_controller.render_equipment_offsets(),
39
+ )
40
+
41
+ @blueprint.route("/heart-rate", methods=["GET", "POST"])
42
+ def heart_rate():
43
+ if request.method == "POST":
44
+ birth_year = int_or_none(request.form["birth_year"])
45
+ heart_rate_resting = int_or_none(request.form["heart_rate_resting"])
46
+ if heart_rate_resting is None:
47
+ heart_rate_resting = 0
48
+ heart_rate_maximum = int_or_none(request.form["heart_rate_maximum"])
49
+ settings_controller.save_heart_rate(
50
+ birth_year, heart_rate_resting, heart_rate_maximum
51
+ )
52
+ return render_template(
53
+ "settings/heart-rate.html.j2", **settings_controller.render_heart_rate()
54
+ )
55
+
56
+ @blueprint.route("/kinds-without-achievements", methods=["GET", "POST"])
57
+ def kinds_without_achievements():
58
+ if request.method == "POST":
59
+ kinds = request.form.getlist("kind")
60
+ settings_controller.save_kinds_without_achievements(kinds)
61
+ return render_template(
62
+ "settings/kinds-without-achievements.html.j2",
63
+ **settings_controller.render_kinds_without_achievements(),
64
+ )
65
+
66
+ @blueprint.route("/metadata-extraction", methods=["GET", "POST"])
67
+ def metadata_extraction():
68
+ if request.method == "POST":
69
+ regexes = request.form.getlist("regex")
70
+ settings_controller.save_metadata_extraction(regexes)
71
+ return render_template(
72
+ "settings/metadata-extraction.html.j2",
73
+ **settings_controller.render_metadata_extraction(),
74
+ )
75
+
76
+ @blueprint.route("/privacy-zones", methods=["GET", "POST"])
77
+ def privacy_zones():
78
+ if request.method == "POST":
79
+ zone_names = request.form.getlist("zone_name")
80
+ zone_geojsons = request.form.getlist("zone_geojson")
81
+ settings_controller.save_privacy_zones(zone_names, zone_geojsons)
82
+ return render_template(
83
+ "settings/privacy-zones.html.j2",
84
+ **settings_controller.render_privacy_zones(),
85
+ )
86
+
87
+ @blueprint.route("/strava", methods=["GET", "POST"])
88
+ def strava():
89
+ if request.method == "POST":
90
+ strava_client_id = request.form["strava_client_id"]
91
+ strava_client_secret = request.form["strava_client_secret"]
92
+ url = settings_controller.save_strava(
93
+ strava_client_id, strava_client_secret
94
+ )
95
+ return redirect(url)
96
+ return render_template(
97
+ "settings/strava.html.j2", **settings_controller.render_strava()
98
+ )
99
+
100
+ @blueprint.route("/strava-callback")
101
+ def strava_callback():
102
+ code = request.args.get("code", type=str)
103
+ settings_controller.save_strava_code(code)
104
+ return redirect(url_for(".strava"))
105
+
106
+ return blueprint
@@ -0,0 +1,228 @@
1
+ import json
2
+ import re
3
+ import urllib.parse
4
+ from typing import Optional
5
+
6
+ from flask import flash
7
+ from flask import url_for
8
+
9
+ from geo_activity_playground.core.config import ConfigAccessor
10
+ from geo_activity_playground.core.heart_rate import HeartRateZoneComputer
11
+
12
+
13
+ class SettingsController:
14
+ def __init__(self, config_accessor: ConfigAccessor) -> None:
15
+ self._config_accessor = config_accessor
16
+
17
+ def render_equipment_offsets(self) -> dict:
18
+ return {
19
+ "equipment_offsets": self._config_accessor().equipment_offsets,
20
+ }
21
+
22
+ def save_equipment_offsets(
23
+ self,
24
+ equipments: list[str],
25
+ offsets: list[str],
26
+ ) -> None:
27
+ assert len(equipments) == len(offsets)
28
+ new_equipment_offsets = {}
29
+ for equipment, offset_str in zip(equipments, offsets):
30
+ if not equipment or not offset_str:
31
+ continue
32
+
33
+ try:
34
+ offset = float(offset_str)
35
+ except ValueError as e:
36
+ flash(
37
+ f"Cannot parse number {offset_str} for {equipment}: {e}",
38
+ category="danger",
39
+ )
40
+ continue
41
+
42
+ if not offset:
43
+ continue
44
+
45
+ new_equipment_offsets[equipment] = offset
46
+ self._config_accessor().equipment_offsets = new_equipment_offsets
47
+ self._config_accessor.save()
48
+ flash("Updated equipment offsets.", category="success")
49
+
50
+ def render_heart_rate(self) -> dict:
51
+ result = {
52
+ "birth_year": self._config_accessor().birth_year,
53
+ "heart_rate_resting": self._config_accessor().heart_rate_resting,
54
+ "heart_rate_maximum": self._config_accessor().heart_rate_maximum,
55
+ }
56
+
57
+ self._heart_rate_computer = HeartRateZoneComputer(self._config_accessor())
58
+ try:
59
+ result["zone_boundaries"] = self._heart_rate_computer.zone_boundaries()
60
+ except RuntimeError as e:
61
+ pass
62
+ return result
63
+
64
+ def save_heart_rate(
65
+ self,
66
+ birth_year: Optional[int],
67
+ heart_rate_resting: Optional[int],
68
+ heart_rate_maximum: Optional[int],
69
+ ) -> None:
70
+ self._config_accessor().birth_year = birth_year
71
+ self._config_accessor().heart_rate_resting = heart_rate_resting
72
+ self._config_accessor().heart_rate_maximum = heart_rate_maximum
73
+ self._config_accessor.save()
74
+ flash("Updated heart rate data.", category="success")
75
+
76
+ def render_kinds_without_achievements(self) -> dict:
77
+ return {
78
+ "kinds_without_achievements": self._config_accessor().kinds_without_achievements,
79
+ }
80
+
81
+ def save_kinds_without_achievements(
82
+ self,
83
+ kinds: list[str],
84
+ ) -> None:
85
+ new_kinds = [kind.strip() for kind in kinds if kind.strip()]
86
+ new_kinds.sort()
87
+
88
+ self._config_accessor().kinds_without_achievements = new_kinds
89
+ self._config_accessor.save()
90
+ flash("Updated kinds without achievements.", category="success")
91
+
92
+ def render_metadata_extraction(self) -> dict:
93
+ return {
94
+ "metadata_extraction_regexes": self._config_accessor().metadata_extraction_regexes,
95
+ }
96
+
97
+ def save_metadata_extraction(
98
+ self,
99
+ metadata_extraction_regexes: list[str],
100
+ ) -> None:
101
+ new_metadata_extraction_regexes = []
102
+ for regex in metadata_extraction_regexes:
103
+ try:
104
+ re.compile(regex)
105
+ except re.error as e:
106
+ flash(
107
+ f"Cannot parse regex {regex} due to error: {e}", category="danger"
108
+ )
109
+ else:
110
+ new_metadata_extraction_regexes.append(regex)
111
+
112
+ self._config_accessor().metadata_extraction_regexes = (
113
+ new_metadata_extraction_regexes
114
+ )
115
+ self._config_accessor.save()
116
+ flash("Updated metadata extraction settings.", category="success")
117
+
118
+ def render_privacy_zones(self) -> dict:
119
+ return {
120
+ "privacy_zones": {
121
+ name: _wrap_coordinates(coordinates)
122
+ for name, coordinates in self._config_accessor().privacy_zones.items()
123
+ }
124
+ }
125
+
126
+ def save_privacy_zones(
127
+ self, zone_names: list[str], zone_geojsons: list[str]
128
+ ) -> None:
129
+ assert len(zone_names) == len(zone_geojsons)
130
+ new_zone_config = {}
131
+
132
+ for zone_name, zone_geojson_str in zip(zone_names, zone_geojsons):
133
+ if not zone_name or not zone_geojson_str:
134
+ continue
135
+
136
+ try:
137
+ zone_geojson = json.loads(zone_geojson_str)
138
+ except json.decoder.JSONDecodeError as e:
139
+ flash(
140
+ f"Could not parse GeoJSON for {zone_name} due to the following error: {e}"
141
+ )
142
+ continue
143
+
144
+ if not zone_geojson["type"] == "FeatureCollection":
145
+ flash(
146
+ f"Pasted GeoJSON for {zone_name} must be of type 'FeatureCollection'.",
147
+ category="danger",
148
+ )
149
+ continue
150
+
151
+ features = zone_geojson["features"]
152
+
153
+ if not len(features) == 1:
154
+ flash(
155
+ f"Pasted GeoJSON for {zone_name} must contain exactly one feature. You cannot have multiple shapes for one privacy zone",
156
+ category="danger",
157
+ )
158
+ continue
159
+
160
+ feature = features[0]
161
+ geometry = feature["geometry"]
162
+
163
+ if not geometry["type"] == "Polygon":
164
+ flash(
165
+ f"Geometry for {zone_name} is not a polygon. You need to create a polygon (or circle or rectangle).",
166
+ category="danger",
167
+ )
168
+ continue
169
+
170
+ coordinates = geometry["coordinates"]
171
+
172
+ if not len(coordinates) == 1:
173
+ flash(
174
+ f"Polygon for {zone_name} consists of multiple polygons. Please supply a simple one.",
175
+ category="danger",
176
+ )
177
+ continue
178
+
179
+ points = coordinates[0]
180
+
181
+ new_zone_config[zone_name] = points
182
+
183
+ self._config_accessor().privacy_zones = new_zone_config
184
+ self._config_accessor.save()
185
+ flash("Updated privacy zones.", category="success")
186
+
187
+ def render_strava(self) -> dict:
188
+ return {
189
+ "strava_client_id": self._config_accessor().strava_client_id,
190
+ "strava_client_secret": self._config_accessor().strava_client_secret,
191
+ "strava_client_code": self._config_accessor().strava_client_code,
192
+ }
193
+
194
+ def save_strava(self, client_id: str, client_secret: str) -> str:
195
+ self._strava_client_id = client_id
196
+ self._strava_client_secret = client_secret
197
+
198
+ payload = {
199
+ "client_id": client_id,
200
+ "redirect_uri": url_for(".strava_callback", _external=True),
201
+ "response_type": "code",
202
+ "scope": "activity:read_all",
203
+ }
204
+
205
+ arg_string = "&".join(
206
+ f"{key}={urllib.parse.quote(value)}" for key, value in payload.items()
207
+ )
208
+ return f"https://www.strava.com/oauth/authorize?{arg_string}"
209
+
210
+ def save_strava_code(self, code: str) -> None:
211
+ self._config_accessor().strava_client_id = self._strava_client_id
212
+ self._config_accessor().strava_client_secret = self._strava_client_secret
213
+ self._config_accessor().strava_client_code = code
214
+ self._config_accessor.save()
215
+ flash("Connected to Strava API", category="success")
216
+
217
+
218
+ def _wrap_coordinates(coordinates: list[list[float]]) -> dict:
219
+ return {
220
+ "type": "FeatureCollection",
221
+ "features": [
222
+ {
223
+ "type": "Feature",
224
+ "properties": {},
225
+ "geometry": {"coordinates": [coordinates], "type": "Polygon"},
226
+ }
227
+ ],
228
+ }
@@ -0,0 +1,44 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Equipment Offsets</h1>
6
+
7
+ <p>If you record every activity with an equipment but haven't started from the beginning, you might have a certain
8
+ distance that is not accounted for. In order to have this reflected in the equipment overview, you can enter offsets
9
+ here.</p>
10
+
11
+ <form method="POST">
12
+ <div class="row row-cols-1 row-cols-md-3 g-4 mb-3">
13
+ {% for equipment, offset in equipment_offsets.items() %}
14
+ <div class="col-md-4">
15
+ <div class="mb-3">
16
+ <label for="equipment_{{ loop.index }}" class="form-label">Equipment</label>
17
+ <input type="text" class="form-control" id="equipment_{{ loop.index }}" name="equipment"
18
+ value="{{ equipment }}" />
19
+ </div>
20
+ <div class="mb-3">
21
+ <label for="offset_{{ loop.index }}" class="form-label">Offset / km</label>
22
+ <input type="number" class="form-control" id="offset_{{ loop.index }}" name="offset"
23
+ value="{{ offset }}" />
24
+ </div>
25
+ </div>
26
+ {% endfor %}
27
+
28
+ <div class="col-md-4">
29
+ <div class="mb-3">
30
+ <label for="equipment_new" class="form-label">Equipment</label>
31
+ <input type="text" class="form-control" id="equipment_new" name="equipment" />
32
+ </div>
33
+ <div class="mb-3">
34
+ <label for="offset_new" class="form-label">Offset / km</label>
35
+ <input type="number" class="form-control" id="offset_new" name="offset" />
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <button type="submit" class="btn btn-primary">Save</button>
41
+ </form>
42
+
43
+
44
+ {% endblock %}
@@ -0,0 +1,102 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Heart Rate</h1>
6
+
7
+ <p>If you have recorded activities with heart rate data, you can also let it display the heart rate zones.</p>
8
+
9
+ <p>The definition of the heart rate zones is not standardized. Usually there are five zones and they have the same
10
+ names. What differs is how their ranges are computed and there is some chaos around that.</p>
11
+
12
+ <p>All definitions that I found take the maximum heart rate as the upper limit. One can measure this as part of a
13
+ professional training or just use the <em>220 minus age</em> prescription which at least for me matches close
14
+ enough. What they differ on is how they use a lower bound. It seems that <a
15
+ href="https://www.polar.com/blog/running-heart-rate-zones-basics/">Polar</a> or <a
16
+ href="https://www.rei.com/learn/expert-advice/how-to-train-with-a-heart-rate-monitor.html">REI</a> basically use
17
+ 0 as the lower bound. My Garmin system also uses 0 as the lower bound. But as one can see in <a
18
+ href="https://theathleteblog.com/heart-rate-zones/">this blog</a>, one can also use the resting heart rate as
19
+ the lower bound.</p>
20
+
21
+ <p>Based on the maximum and resting heart rate we will then compute the heart rate zones using certain percentages of
22
+ <em>effort</em>. We can compute the heart rate as the following:
23
+ </p>
24
+
25
+ <blockquote>
26
+ <p>rate = effort × (maximum – minimum) + minimum</p>
27
+ </blockquote>
28
+
29
+ <div class="row mb-3">
30
+ <div class="col-md-4">
31
+ <form method="POST">
32
+ <div class="mb-3">
33
+ <label for="birth_year" class="form-label">Birth year</label>
34
+ <input type="text" class="form-control" id="birth_year" name="birth_year"
35
+ value="{{ birth_year or '' }}" />
36
+ </div>
37
+ <div class="mb-3">
38
+ <label for="heart_rate_maximum" class="form-label">Maximum heart rate</label>
39
+ <input type="text" class="form-control" id="heart_rate_maximum" name="heart_rate_maximum"
40
+ value="{{ heart_rate_maximum or '' }}" />
41
+ </div>
42
+ <div class="mb-3">
43
+ <label for="heart_rate_resting" class="form-label">Resting heart rate</label>
44
+ <input type="text" class="form-control" id="heart_rate_resting" name="heart_rate_resting"
45
+ value="{{ heart_rate_resting or '' }}" />
46
+ </div>
47
+ <button type="submit" class="btn btn-primary">Save</button>
48
+ </form>
49
+ </div>
50
+
51
+ {% if zone_boundaries %}
52
+ <div class="col-md-8">
53
+ <p>Your heart rate zone boundaries:</p>
54
+ <table class="table">
55
+ <thead>
56
+ <tr>
57
+ <td>Zone</td>
58
+ <td>Percentage</td>
59
+ <td>Heart rate</td>
60
+ <td>Training</td>
61
+ </tr>
62
+ </thead>
63
+ <tbody>
64
+ <tr>
65
+ <td>1</td>
66
+ <td>50 to 60 %</td>
67
+ <td>{{ zone_boundaries[0][0] }} to {{ zone_boundaries[0][1] }} Hz</td>
68
+ <td>Warmup/Recovery</td>
69
+ </tr>
70
+ <tr>
71
+ <td>2</td>
72
+ <td>60 to 70 %</td>
73
+ <td>{{ zone_boundaries[1][0] }} to {{ zone_boundaries[1][1] }} Hz</td>
74
+ <td>Base Fitness</td>
75
+ </tr>
76
+ <tr>
77
+ <td>3</td>
78
+ <td>70 to 80 %</td>
79
+ <td>{{ zone_boundaries[2][0] }} to {{ zone_boundaries[2][1] }} Hz</td>
80
+ <td>Aerobic Endurance</td>
81
+ </tr>
82
+ <tr>
83
+ <td>4</td>
84
+ <td>80 to 90 %</td>
85
+ <td>{{ zone_boundaries[3][0] }} to {{ zone_boundaries[3][1] }} Hz</td>
86
+ <td>Anerobic Capacity</td>
87
+ </tr>
88
+ <tr>
89
+ <td>5</td>
90
+ <td>90 to 100 %</td>
91
+ <td>{{ zone_boundaries[4][0] }} to {{ zone_boundaries[4][1] }} Hz</td>
92
+ <td>Speed Training</td>
93
+ </tr>
94
+ </tbody>
95
+ </table>
96
+
97
+ </div>
98
+ {% endif %}
99
+
100
+ </div>
101
+
102
+ {% endblock %}