geo-activity-playground 0.26.3__py3-none-any.whl → 0.27.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 (48) 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 +11 -2
  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 +57 -32
  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/home.html.j2 +1 -1
  35. geo_activity_playground/webui/templates/page.html.j2 +5 -1
  36. geo_activity_playground/webui/upload/blueprint.py +10 -1
  37. geo_activity_playground/webui/upload/controller.py +24 -11
  38. geo_activity_playground/webui/upload/templates/upload/reload.html.j2 +16 -0
  39. {geo_activity_playground-0.26.3.dist-info → geo_activity_playground-0.27.1.dist-info}/METADATA +1 -1
  40. {geo_activity_playground-0.26.3.dist-info → geo_activity_playground-0.27.1.dist-info}/RECORD +43 -36
  41. geo_activity_playground/webui/strava/__init__.py +0 -0
  42. geo_activity_playground/webui/strava/blueprint.py +0 -33
  43. geo_activity_playground/webui/strava/controller.py +0 -49
  44. geo_activity_playground/webui/strava/templates/strava/connected.html.j2 +0 -14
  45. geo_activity_playground/webui/templates/settings.html.j2 +0 -24
  46. {geo_activity_playground-0.26.3.dist-info → geo_activity_playground-0.27.1.dist-info}/LICENSE +0 -0
  47. {geo_activity_playground-0.26.3.dist-info → geo_activity_playground-0.27.1.dist-info}/WHEEL +0 -0
  48. {geo_activity_playground-0.26.3.dist-info → geo_activity_playground-0.27.1.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,8 @@
1
1
  import datetime
2
- import functools
3
- import json
4
2
  import logging
5
3
  import pathlib
6
4
  import pickle
7
5
  import time
8
- from typing import Any
9
6
 
10
7
  import pandas as pd
11
8
  from stravalib import Client
@@ -15,46 +12,28 @@ from stravalib.exc import RateLimitExceeded
15
12
  from tqdm import tqdm
16
13
 
17
14
  from geo_activity_playground.core.activities import ActivityMeta
18
- from geo_activity_playground.core.config import get_config
15
+ from geo_activity_playground.core.config import Config
19
16
  from geo_activity_playground.core.paths import activity_extracted_meta_dir
20
17
  from geo_activity_playground.core.paths import activity_extracted_time_series_dir
21
- from geo_activity_playground.core.paths import cache_dir
22
18
  from geo_activity_playground.core.paths import strava_api_dir
23
- from geo_activity_playground.core.paths import strava_dynamic_config_path
19
+ from geo_activity_playground.core.paths import strava_last_activity_date_path
20
+ from geo_activity_playground.core.tasks import get_state
21
+ from geo_activity_playground.core.tasks import set_state
24
22
  from geo_activity_playground.core.time_conversion import convert_to_datetime_ns
25
23
 
26
24
 
27
25
  logger = logging.getLogger(__name__)
28
26
 
29
27
 
30
- def get_state(path: pathlib.Path) -> Any:
31
- if path.exists():
32
- with open(path) as f:
33
- return json.load(f)
34
-
35
-
36
- def set_state(path: pathlib.Path, state: Any) -> None:
37
- path.parent.mkdir(exist_ok=True, parents=True)
38
- with open(path, "w") as f:
39
- json.dump(state, f, indent=2, sort_keys=True, ensure_ascii=False)
40
-
41
-
42
- def get_current_access_token() -> str:
43
- if strava_dynamic_config_path().exists():
44
- with open(strava_dynamic_config_path()) as f:
45
- strava_config = json.load(f)
46
- else:
47
- config = get_config()
48
- strava_config = config["strava"]
49
-
50
- tokens = get_state(strava_api_dir() / "strava_tokens.json")
28
+ def get_current_access_token(config: Config) -> str:
29
+ tokens = get_state(strava_api_dir() / "strava_tokens.json", None)
51
30
  if not tokens:
52
31
  logger.info("Create Strava access token …")
53
32
  client = Client()
54
33
  token_response = client.exchange_code_for_token(
55
- client_id=strava_config["client_id"],
56
- client_secret=strava_config["client_secret"],
57
- code=strava_config["code"],
34
+ client_id=config.strava_client_id,
35
+ client_secret=config.strava_client_secret,
36
+ code=config.strava_client_code,
58
37
  )
59
38
  tokens = {
60
39
  "access": token_response["access_token"],
@@ -66,8 +45,8 @@ def get_current_access_token() -> str:
66
45
  logger.info("Renew Strava access token …")
67
46
  client = Client()
68
47
  token_response = client.refresh_access_token(
69
- client_id=strava_config["client_id"],
70
- client_secret=strava_config["client_secret"],
48
+ client_id=config.strava_client_id,
49
+ client_secret=config.strava_client_secret,
71
50
  refresh_token=tokens["refresh"],
72
51
  )
73
52
  tokens = {
@@ -89,8 +68,8 @@ def round_to_next_quarter_hour(date: datetime.datetime) -> datetime.datetime:
89
68
  return next_quarter
90
69
 
91
70
 
92
- def import_from_strava_api() -> None:
93
- while try_import_strava():
71
+ def import_from_strava_api(config: Config) -> None:
72
+ while try_import_strava(config):
94
73
  now = datetime.datetime.now()
95
74
  next_quarter = round_to_next_quarter_hour(now)
96
75
  seconds_to_wait = (next_quarter - now).total_seconds() + 10
@@ -100,17 +79,12 @@ def import_from_strava_api() -> None:
100
79
  time.sleep(seconds_to_wait)
101
80
 
102
81
 
103
- def try_import_strava() -> bool:
104
- last_activity_date_path = cache_dir() / "strava-last-activity-date.json"
105
- if last_activity_date_path.exists():
106
- with open(last_activity_date_path) as f:
107
- get_after = json.load(f)
108
- else:
109
- get_after = "2000-01-01T00:00:00Z"
82
+ def try_import_strava(config: Config) -> bool:
83
+ get_after = get_state(strava_last_activity_date_path(), "2000-01-01T00:00:00Z")
110
84
 
111
85
  gear_names = {None: "None"}
112
86
 
113
- client = Client(access_token=get_current_access_token())
87
+ client = Client(access_token=get_current_access_token(config))
114
88
 
115
89
  try:
116
90
  for activity in tqdm(
@@ -178,8 +152,10 @@ def try_import_strava() -> bool:
178
152
  ) as f:
179
153
  pickle.dump(activity_meta, f)
180
154
 
181
- with open(last_activity_date_path, "w") as f:
182
- json.dump(activity.start_date.isoformat().replace("+00:00", "Z"), f)
155
+ set_state(
156
+ strava_last_activity_date_path(),
157
+ activity.start_date.isoformat().replace("+00:00", "Z"),
158
+ )
183
159
 
184
160
  limit_exceeded = False
185
161
  except RateLimitExceeded:
@@ -13,13 +13,18 @@ import numpy as np
13
13
  import pandas as pd
14
14
  from tqdm import tqdm
15
15
 
16
+ from geo_activity_playground.core.activities import ActivityMeta
16
17
  from geo_activity_playground.core.paths import activity_extracted_meta_dir
17
18
  from geo_activity_playground.core.paths import activity_extracted_time_series_dir
19
+ from geo_activity_playground.core.paths import strava_last_activity_date_path
20
+ from geo_activity_playground.core.tasks import get_state
21
+ from geo_activity_playground.core.tasks import set_state
18
22
  from geo_activity_playground.core.tasks import work_tracker_path
19
23
  from geo_activity_playground.core.tasks import WorkTracker
20
24
  from geo_activity_playground.core.time_conversion import convert_to_datetime_ns
21
25
  from geo_activity_playground.importers.activity_parsers import ActivityParseError
22
26
  from geo_activity_playground.importers.activity_parsers import read_activity
27
+ from geo_activity_playground.importers.csv_parser import parse_csv
23
28
 
24
29
 
25
30
  logger = logging.getLogger(__name__)
@@ -48,9 +53,9 @@ EXPECTED_COLUMNS = [
48
53
  "Filename",
49
54
  "Athlete Weight",
50
55
  "Bike Weight",
51
- "Elapsed Time.1",
56
+ "Elapsed Time",
52
57
  "Moving Time",
53
- "Distance.1",
58
+ "Distance",
54
59
  "Max Speed",
55
60
  "Average Speed",
56
61
  "Elevation Gain",
@@ -63,14 +68,14 @@ EXPECTED_COLUMNS = [
63
68
  "Average Negative Grade",
64
69
  "Max Cadence",
65
70
  "Average Cadence",
66
- "Max Heart Rate.1",
71
+ "Max Heart Rate",
67
72
  "Average Heart Rate",
68
73
  "Max Watts",
69
74
  "Average Watts",
70
75
  "Calories",
71
76
  "Max Temperature",
72
77
  "Average Temperature",
73
- "Relative Effort.1",
78
+ "Relative Effort",
74
79
  "Total Work",
75
80
  "Number of Runs",
76
81
  "Uphill Time",
@@ -83,7 +88,7 @@ EXPECTED_COLUMNS = [
83
88
  "Power Count",
84
89
  "Prefer Perceived Exertion",
85
90
  "Perceived Relative Effort",
86
- "Commute.1",
91
+ "Commute",
87
92
  "Total Weight Lifted",
88
93
  "From Upload",
89
94
  "Grade Adjusted Distance",
@@ -130,34 +135,41 @@ EXPECTED_COLUMNS = [
130
135
  ]
131
136
 
132
137
 
133
- def float_or_none(x: Union[float, str]) -> Optional[float]:
134
- try:
135
- return float(x)
136
- except ValueError:
137
- return None
138
+ def float_with_comma_or_period(x: str) -> Optional[float]:
139
+ if len(x) == 0:
140
+ return 0
141
+
142
+ if "," in x:
143
+ x = x.replace(",", ".")
144
+ return float(x)
138
145
 
139
146
 
140
147
  def import_from_strava_checkout() -> None:
141
148
  checkout_path = pathlib.Path("Strava Export")
142
- activities = pd.read_csv(checkout_path / "activities.csv")
149
+ with open(checkout_path / "activities.csv") as f:
150
+ rows = parse_csv(f.read())
151
+ header = rows[0]
152
+
153
+ if len(header) != len(EXPECTED_COLUMNS):
154
+ logger.error(
155
+ f"You are trying to import a Strava checkout where the `activities.csv` contains an unexpected header format. In order to import this, we need to map these to the English ones. Unfortunately Strava often changes the number of columns. Your file has {len(header)} but we expect {len(EXPECTED_COLUMNS)}. This means that the program needs to be updated to match the new Strava export format. Please go to https://github.com/martin-ueding/geo-activity-playground/issues and open a new issue and share the following output in the ticket:"
156
+ )
157
+ print(header)
158
+ sys.exit(1)
143
159
 
144
- if activities.columns[0] == EXPECTED_COLUMNS[0]:
160
+ if header[0] == EXPECTED_COLUMNS[0]:
145
161
  dayfirst = False
146
- if activities.columns[0] == "Aktivitäts-ID":
147
- activities = pd.read_csv(checkout_path / "activities.csv", decimal=",")
148
- if len(activities.columns) != len(EXPECTED_COLUMNS):
149
- logger.error(
150
- f"You are trying to import a Strava checkout where the `activities.csv` contains German column headers. In order to import this, we need to map these to the English ones. Unfortunately Strava has changed the number of columns. Your file has {len(activities.columns)} but we expect {len(EXPECTED_COLUMNS)}. This means that the program needs to be updated to match the new Strava export format. Please go to https://github.com/martin-ueding/geo-activity-playground/issues and open a new issue and share the following output in the ticket:"
151
- )
152
- print(activities.columns)
153
- print(activities.dtypes)
154
- sys.exit(1)
155
- activities.columns = EXPECTED_COLUMNS
162
+ if header[0] == "Aktivitäts-ID":
163
+ header = EXPECTED_COLUMNS
156
164
  dayfirst = True
157
165
 
158
- activities.index = activities["Activity ID"]
166
+ table = {
167
+ header[i]: [rows[r][i] for r in range(1, len(rows))] for i in range(len(header))
168
+ }
169
+ all_activity_ids = [int(value) for value in table["Activity ID"]]
170
+
159
171
  work_tracker = WorkTracker(work_tracker_path("import-strava-checkout-activities"))
160
- activities_ids_to_parse = work_tracker.filter(activities["Activity ID"])
172
+ activities_ids_to_parse = work_tracker.filter(all_activity_ids)
161
173
  activities_ids_to_parse = [
162
174
  activity_id
163
175
  for activity_id in activities_ids_to_parse
@@ -166,16 +178,23 @@ def import_from_strava_checkout() -> None:
166
178
 
167
179
  for activity_id in tqdm(activities_ids_to_parse, desc="Import from Strava export"):
168
180
  work_tracker.mark_done(activity_id)
169
- row = activities.loc[activity_id]
181
+ index = all_activity_ids.index(activity_id)
182
+ row = {column: table[column][index] for column in header}
183
+
170
184
  # Some manually recorded activities have no file name. Pandas reads that as a float. We skip those.
171
- if isinstance(row["Filename"], float):
185
+ if not row["Filename"]:
172
186
  continue
187
+
188
+ start_datetime = dateutil.parser.parse(row["Activity Date"], dayfirst=dayfirst)
189
+
173
190
  activity_file = checkout_path / row["Filename"]
174
- table_activity_meta = {
175
- "calories": float_or_none(row["Calories"]),
191
+ table_activity_meta: ActivityMeta = {
192
+ "calories": float_with_comma_or_period(row["Calories"]),
176
193
  "commute": row["Commute"] == "true",
177
194
  "distance_km": row["Distance"],
178
- "elapsed_time": datetime.timedelta(seconds=int(row["Elapsed Time"])),
195
+ "elapsed_time": datetime.timedelta(
196
+ seconds=float_with_comma_or_period(row["Elapsed Time"])
197
+ ),
179
198
  "equipment": str(
180
199
  nan_as_none(row["Activity Gear"])
181
200
  or nan_as_none(row["Bike"])
@@ -186,9 +205,8 @@ def import_from_strava_checkout() -> None:
186
205
  "id": activity_id,
187
206
  "name": row["Activity Name"],
188
207
  "path": str(activity_file),
189
- "start": convert_to_datetime_ns(
190
- dateutil.parser.parse(row["Activity Date"], dayfirst=dayfirst)
191
- ),
208
+ "start": convert_to_datetime_ns(start_datetime),
209
+ "steps": float_with_comma_or_period(row["Total Steps"]),
192
210
  }
193
211
 
194
212
  time_series_path = (
@@ -219,6 +237,13 @@ def import_from_strava_checkout() -> None:
219
237
  with open(meta_path, "wb") as f:
220
238
  pickle.dump(table_activity_meta, f)
221
239
  time_series.to_parquet(time_series_path)
240
+
241
+ start_str = (
242
+ pd.Timestamp(table_activity_meta["start"]).to_pydatetime().isoformat() + "Z"
243
+ )
244
+ latest_start_str = get_state(strava_last_activity_date_path(), start_str)
245
+ set_state(strava_last_activity_date_path(), max(start_str, latest_start_str))
246
+
222
247
  work_tracker.close()
223
248
 
224
249
 
@@ -0,0 +1,49 @@
1
+ import pytest
2
+
3
+ from geo_activity_playground.importers.csv_parser import _parse_cell
4
+ from geo_activity_playground.importers.csv_parser import _parse_line
5
+ from geo_activity_playground.importers.csv_parser import parse_csv
6
+
7
+
8
+ def test_parse_csv() -> None:
9
+ data = """
10
+ A,B,C
11
+ a,"b,b",c
12
+ d,"e
13
+ f",g
14
+ """
15
+ expected = [["A", "B", "C"], ["a", "b,b", "c"], ["d", "e\nf", "g"]]
16
+ assert parse_csv(data) == expected
17
+
18
+
19
+ def test_parse_cell_plain() -> None:
20
+ assert _parse_cell("foo", 0) == ("foo", 3)
21
+
22
+
23
+ def test_parse_cell_with_quotes() -> None:
24
+ assert _parse_cell('"foo"', 0) == ("foo", 5)
25
+
26
+
27
+ def test_parse_cell_with_escape() -> None:
28
+ assert _parse_cell('"f\\"oo"', 0) == ('f"oo', 7)
29
+
30
+
31
+ def test_parse_cell_with_newline() -> None:
32
+ assert _parse_cell('"f\noo"', 0) == ("f\noo", 6)
33
+
34
+
35
+ def test_parse_cell_empty() -> None:
36
+ assert _parse_cell("", 0) == ("", 0)
37
+
38
+
39
+ def test_parse_line() -> None:
40
+ assert _parse_line("a,b,c\n", 0) == (["a", "b", "c"], 6)
41
+
42
+
43
+ def test_parse_line_empty_cell() -> None:
44
+ assert _parse_line("a,,c\n", 0) == (["a", "", "c"], 5)
45
+
46
+
47
+ @pytest.mark.xfail
48
+ def test_parse_line_empty_cell_at_end() -> None:
49
+ assert _parse_line("a,b,\n", 0) == (["a", "b", ""], 5)
@@ -8,19 +8,18 @@ from flask import Response
8
8
  from ...core.activities import ActivityRepository
9
9
  from ...explorer.tile_visits import TileVisitAccessor
10
10
  from .controller import ActivityController
11
+ from geo_activity_playground.core.config import Config
11
12
  from geo_activity_playground.core.privacy_zones import PrivacyZone
12
13
 
13
14
 
14
15
  def make_activity_blueprint(
15
16
  repository: ActivityRepository,
16
17
  tile_visit_accessor: TileVisitAccessor,
17
- privacy_zones: Collection[PrivacyZone],
18
+ config: Config,
18
19
  ) -> Blueprint:
19
20
  blueprint = Blueprint("activity", __name__, template_folder="templates")
20
21
 
21
- activity_controller = ActivityController(
22
- repository, tile_visit_accessor, privacy_zones
23
- )
22
+ activity_controller = ActivityController(repository, tile_visit_accessor, config)
24
23
 
25
24
  @blueprint.route("/all")
26
25
  def all():
@@ -1,26 +1,24 @@
1
1
  import datetime
2
- import functools
3
2
  import io
4
3
  import logging
5
4
  import re
6
- from collections.abc import Collection
5
+ from typing import Optional
7
6
 
8
7
  import altair as alt
9
8
  import geojson
10
9
  import matplotlib
11
- import matplotlib.pyplot as pl
12
10
  import numpy as np
13
11
  import pandas as pd
14
12
  from PIL import Image
15
13
  from PIL import ImageDraw
16
- from PIL import ImageFont
17
14
 
18
15
  from geo_activity_playground.core.activities import ActivityMeta
19
16
  from geo_activity_playground.core.activities import ActivityRepository
20
- from geo_activity_playground.core.activities import extract_heart_rate_zones
21
17
  from geo_activity_playground.core.activities import make_geojson_color_line
22
18
  from geo_activity_playground.core.activities import make_geojson_from_time_series
23
19
  from geo_activity_playground.core.activities import make_speed_color_bar
20
+ from geo_activity_playground.core.config import Config
21
+ from geo_activity_playground.core.heart_rate import HeartRateZoneComputer
24
22
  from geo_activity_playground.core.heatmap import add_margin_to_geo_bounds
25
23
  from geo_activity_playground.core.heatmap import build_map_from_tiles
26
24
  from geo_activity_playground.core.heatmap import GeoBounds
@@ -41,13 +39,13 @@ class ActivityController:
41
39
  self,
42
40
  repository: ActivityRepository,
43
41
  tile_visit_accessor: TileVisitAccessor,
44
- privacy_zones: Collection[PrivacyZone],
42
+ config: Config,
45
43
  ) -> None:
46
44
  self._repository = repository
47
45
  self._tile_visit_accessor = tile_visit_accessor
48
- self._privacy_zones = privacy_zones
46
+ self._config = config
47
+ self._heart_rate_zone_computer = HeartRateZoneComputer(config)
49
48
 
50
- @functools.lru_cache()
51
49
  def render_activity(self, id: int) -> dict:
52
50
  activity = self._repository.get_activity_by_id(id)
53
51
 
@@ -82,18 +80,23 @@ class ActivityController:
82
80
  "time": activity["start"].time(),
83
81
  "new_tiles": new_tiles,
84
82
  }
85
- if (heart_zones := extract_heart_rate_zones(time_series)) is not None:
86
- result["heart_zones_plot"] = heartrate_zone_plot(heart_zones)
83
+ if (
84
+ heart_zones := _extract_heart_rate_zones(
85
+ time_series, self._heart_rate_zone_computer
86
+ )
87
+ ) is not None:
88
+ result["heart_zones_plot"] = heart_rate_zone_plot(heart_zones)
87
89
  if "altitude" in time_series.columns:
88
90
  result["altitude_time_plot"] = altitude_time_plot(time_series)
89
91
  if "heartrate" in time_series.columns:
90
- result["heartrate_time_plot"] = heartrate_time_plot(time_series)
92
+ result["heartrate_time_plot"] = heart_rate_time_plot(time_series)
91
93
  return result
92
94
 
93
95
  def render_sharepic(self, id: int) -> bytes:
94
96
  activity = self._repository.get_activity_by_id(id)
95
97
  time_series = self._repository.get_time_series(id)
96
- for privacy_zone in self._privacy_zones:
98
+ for coordinates in self._config.privacy_zones.values():
99
+ privacy_zone = PrivacyZone(coordinates)
97
100
  time_series = privacy_zone.filter_time_series(time_series)
98
101
  if len(time_series) == 0:
99
102
  time_series = self._repository.get_time_series(id)
@@ -280,7 +283,7 @@ def altitude_time_plot(time_series: pd.DataFrame) -> str:
280
283
  )
281
284
 
282
285
 
283
- def heartrate_time_plot(time_series: pd.DataFrame) -> str:
286
+ def heart_rate_time_plot(time_series: pd.DataFrame) -> str:
284
287
  return (
285
288
  alt.Chart(time_series, title="Heart Rate")
286
289
  .mark_line()
@@ -294,7 +297,7 @@ def heartrate_time_plot(time_series: pd.DataFrame) -> str:
294
297
  )
295
298
 
296
299
 
297
- def heartrate_zone_plot(heart_zones: pd.DataFrame) -> str:
300
+ def heart_rate_zone_plot(heart_zones: pd.DataFrame) -> str:
298
301
  return (
299
302
  alt.Chart(heart_zones, title="Heart Rate Zones")
300
303
  .mark_bar()
@@ -471,3 +474,26 @@ def make_sharepic(activity: ActivityMeta, time_series: pd.DataFrame) -> bytes:
471
474
  img.save(f, format="png")
472
475
  # pl.imsave(f, background, format="png")
473
476
  return bytes(f.getbuffer())
477
+
478
+
479
+ def _extract_heart_rate_zones(
480
+ time_series: pd.DataFrame, heart_rate_zone_computer: HeartRateZoneComputer
481
+ ) -> Optional[pd.DataFrame]:
482
+ if "heartrate" not in time_series:
483
+ return
484
+
485
+ try:
486
+ zones = heart_rate_zone_computer.compute_zones(
487
+ time_series["heartrate"], time_series["time"].iloc[0].year
488
+ )
489
+ except RuntimeError:
490
+ return
491
+
492
+ df = pd.DataFrame({"heartzone": zones, "step": time_series["time"].diff()}).dropna()
493
+ duration_per_zone = df.groupby("heartzone").sum()["step"].dt.total_seconds() / 60
494
+ duration_per_zone.name = "minutes"
495
+ for i in range(6):
496
+ if i not in duration_per_zone:
497
+ duration_per_zone.loc[i] = 0.0
498
+ result = duration_per_zone.reset_index()
499
+ return result
@@ -121,11 +121,15 @@
121
121
  <div class="col-md-4">
122
122
  {{ vega_direct("heartrate_time_plot", heartrate_time_plot) }}
123
123
  </div>
124
- {% if heart_zones_plot is defined %}
125
124
  <div class="col-md-4">
125
+ {% if heart_zones_plot is defined %}
126
126
  {{ vega_direct("heart_zones_plot", heart_zones_plot) }}
127
+ {% else %}
128
+ <p>Your activity has heart data, but this program doesn't know your maximum heart rate (or birth year) and
129
+ therefore cannot compute the heart rate zones. Go to the <a
130
+ href="{{ url_for('settings.heart_rate') }}">settings</a>.</p>
131
+ {% endif %}
127
132
  </div>
128
- {% endif %}
129
133
  </div>
130
134
  {% endif %}
131
135
 
@@ -18,11 +18,11 @@ from .explorer.blueprint import make_explorer_blueprint
18
18
  from .heatmap.blueprint import make_heatmap_blueprint
19
19
  from .search_controller import SearchController
20
20
  from .square_planner.blueprint import make_square_planner_blueprint
21
- from .strava.blueprint import make_strava_blueprint
22
21
  from .summary.blueprint import make_summary_blueprint
23
22
  from .tile.blueprint import make_tile_blueprint
24
23
  from .upload.blueprint import make_upload_blueprint
25
- from geo_activity_playground.core.privacy_zones import PrivacyZone
24
+ from geo_activity_playground.core.config import ConfigAccessor
25
+ from geo_activity_playground.webui.settings.blueprint import make_settings_blueprint
26
26
 
27
27
 
28
28
  def route_search(app: Flask, repository: ActivityRepository) -> None:
@@ -45,12 +45,6 @@ def route_start(app: Flask, repository: ActivityRepository) -> None:
45
45
  return render_template("home.html.j2", **entry_controller.render())
46
46
 
47
47
 
48
- def route_settings(app: Flask) -> None:
49
- @app.route("/settings/")
50
- def settings():
51
- return render_template("settings.html.j2")
52
-
53
-
54
48
  def get_secret_key():
55
49
  secret_file = pathlib.Path("Cache/flask-secret.json")
56
50
  if secret_file.exists():
@@ -63,30 +57,28 @@ def get_secret_key():
63
57
  return secret
64
58
 
65
59
 
66
- def webui_main(
60
+ def web_ui_main(
67
61
  repository: ActivityRepository,
68
62
  tile_visit_accessor: TileVisitAccessor,
69
- config: dict,
63
+ config_accessor: ConfigAccessor,
70
64
  host: str,
71
65
  port: int,
72
66
  ) -> None:
73
- app = Flask(__name__)
74
67
 
75
- route_search(app, repository)
76
- route_start(app, repository)
77
- route_settings(app)
68
+ repository.reload()
78
69
 
70
+ app = Flask(__name__)
79
71
  app.config["UPLOAD_FOLDER"] = "Activities"
80
72
  app.secret_key = get_secret_key()
81
73
 
74
+ route_search(app, repository)
75
+ route_start(app, repository)
76
+
82
77
  app.register_blueprint(
83
78
  make_activity_blueprint(
84
79
  repository,
85
80
  tile_visit_accessor,
86
- [
87
- PrivacyZone(points)
88
- for points in config.get("privacy_zones", {}).values()
89
- ],
81
+ config_accessor(),
90
82
  ),
91
83
  url_prefix="/activity",
92
84
  )
@@ -95,14 +87,19 @@ def webui_main(
95
87
  make_eddington_blueprint(repository), url_prefix="/eddington"
96
88
  )
97
89
  app.register_blueprint(
98
- make_equipment_blueprint(repository), url_prefix="/equipment"
90
+ make_equipment_blueprint(repository, config_accessor()), url_prefix="/equipment"
99
91
  )
100
92
  app.register_blueprint(
101
- make_explorer_blueprint(repository, tile_visit_accessor), url_prefix="/explorer"
93
+ make_explorer_blueprint(repository, tile_visit_accessor, config_accessor),
94
+ url_prefix="/explorer",
102
95
  )
103
96
  app.register_blueprint(
104
97
  make_heatmap_blueprint(repository, tile_visit_accessor), url_prefix="/heatmap"
105
98
  )
99
+ app.register_blueprint(
100
+ make_settings_blueprint(config_accessor),
101
+ url_prefix="/settings",
102
+ )
106
103
  app.register_blueprint(
107
104
  make_square_planner_blueprint(repository, tile_visit_accessor),
108
105
  url_prefix="/square-planner",
@@ -111,21 +108,24 @@ def webui_main(
111
108
  make_summary_blueprint(repository),
112
109
  url_prefix="/summary",
113
110
  )
114
- app.register_blueprint(
115
- make_strava_blueprint(host, port),
116
- url_prefix="/strava",
117
- )
118
111
  app.register_blueprint(make_tile_blueprint(), url_prefix="/tile")
119
112
  app.register_blueprint(
120
- make_upload_blueprint(repository, tile_visit_accessor, config),
113
+ make_upload_blueprint(repository, tile_visit_accessor, config_accessor()),
121
114
  url_prefix="/upload",
122
115
  )
123
116
 
124
117
  @app.context_processor
125
118
  def inject_global_variables() -> dict:
126
119
  return {
127
- "version": importlib.metadata.version("geo-activity-playground"),
120
+ "version": _try_get_version(),
128
121
  "num_activities": len(repository),
129
122
  }
130
123
 
131
124
  app.run(host=host, port=port)
125
+
126
+
127
+ def _try_get_version():
128
+ try:
129
+ return importlib.metadata.version("geo-activity-playground")
130
+ except importlib.metadata.PackageNotFoundError:
131
+ pass
@@ -16,7 +16,7 @@ class EddingtonController:
16
16
  activities["day"] = [start.date() for start in activities["start"]]
17
17
 
18
18
  sum_per_day = activities.groupby("day").apply(
19
- lambda group: int(sum(group["distance_km"]))
19
+ lambda group: int(sum(group["distance_km"])), include_groups=False
20
20
  )
21
21
  counts = dict(zip(*np.unique(sorted(sum_per_day), return_counts=True)))
22
22
  eddington = pd.DataFrame(
@@ -3,12 +3,15 @@ from flask import render_template
3
3
 
4
4
  from ...core.activities import ActivityRepository
5
5
  from .controller import EquipmentController
6
+ from geo_activity_playground.core.config import Config
6
7
 
7
8
 
8
- def make_equipment_blueprint(repository: ActivityRepository) -> Blueprint:
9
+ def make_equipment_blueprint(
10
+ repository: ActivityRepository, config: Config
11
+ ) -> Blueprint:
9
12
  blueprint = Blueprint("equipment", __name__, template_folder="templates")
10
13
 
11
- equipment_controller = EquipmentController(repository)
14
+ equipment_controller = EquipmentController(repository, config)
12
15
 
13
16
  @blueprint.route("/")
14
17
  def index():