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.
- geo_activity_playground/__main__.py +23 -20
- geo_activity_playground/core/activities.py +1 -44
- geo_activity_playground/core/config.py +111 -0
- geo_activity_playground/core/enrichment.py +22 -3
- geo_activity_playground/core/heart_rate.py +49 -0
- geo_activity_playground/core/paths.py +6 -0
- geo_activity_playground/core/tasks.py +14 -0
- geo_activity_playground/core/tiles.py +1 -1
- geo_activity_playground/explorer/tile_visits.py +23 -11
- geo_activity_playground/importers/csv_parser.py +73 -0
- geo_activity_playground/importers/directory.py +17 -8
- geo_activity_playground/importers/strava_api.py +20 -44
- geo_activity_playground/importers/strava_checkout.py +63 -36
- geo_activity_playground/importers/test_csv_parser.py +49 -0
- geo_activity_playground/webui/activity/blueprint.py +3 -4
- geo_activity_playground/webui/activity/controller.py +40 -14
- geo_activity_playground/webui/activity/templates/activity/show.html.j2 +6 -2
- geo_activity_playground/webui/app.py +26 -26
- geo_activity_playground/webui/eddington/controller.py +1 -1
- geo_activity_playground/webui/equipment/blueprint.py +5 -2
- geo_activity_playground/webui/equipment/controller.py +5 -6
- geo_activity_playground/webui/explorer/blueprint.py +14 -2
- geo_activity_playground/webui/explorer/controller.py +21 -1
- geo_activity_playground/webui/explorer/templates/explorer/index.html.j2 +12 -1
- geo_activity_playground/webui/settings/blueprint.py +106 -0
- geo_activity_playground/webui/settings/controller.py +228 -0
- geo_activity_playground/webui/settings/templates/settings/equipment-offsets.html.j2 +44 -0
- geo_activity_playground/webui/settings/templates/settings/heart-rate.html.j2 +102 -0
- geo_activity_playground/webui/settings/templates/settings/index.html.j2 +74 -0
- geo_activity_playground/webui/settings/templates/settings/kinds-without-achievements.html.j2 +30 -0
- geo_activity_playground/webui/settings/templates/settings/metadata-extraction.html.j2 +55 -0
- geo_activity_playground/webui/settings/templates/settings/privacy-zones.html.j2 +81 -0
- geo_activity_playground/webui/{strava/templates/strava/client-id.html.j2 → settings/templates/settings/strava.html.j2} +17 -7
- geo_activity_playground/webui/templates/page.html.j2 +5 -1
- geo_activity_playground/webui/upload/blueprint.py +10 -1
- geo_activity_playground/webui/upload/controller.py +24 -11
- geo_activity_playground/webui/upload/templates/upload/reload.html.j2 +16 -0
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/METADATA +2 -2
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/RECORD +42 -35
- geo_activity_playground/webui/strava/__init__.py +0 -0
- geo_activity_playground/webui/strava/blueprint.py +0 -33
- geo_activity_playground/webui/strava/controller.py +0 -49
- geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +0 -14
- geo_activity_playground/webui/templates/settings.html.j2 +0 -24
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/LICENSE +0 -0
- {geo_activity_playground-0.26.2.dist-info → geo_activity_playground-0.27.0.dist-info}/WHEEL +0 -0
- {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
|
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
|
-
|
105
|
-
|
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,
|
14
|
+
repository: ActivityRepository,
|
15
|
+
tile_visit_accessor: TileVisitAccessor,
|
16
|
+
config_accessor: ConfigAccessor,
|
12
17
|
) -> Blueprint:
|
13
|
-
explorer_controller = ExplorerController(
|
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,
|
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 %}
|