geo-activity-playground 0.29.2__py3-none-any.whl → 0.31.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 (37) hide show
  1. geo_activity_playground/core/activities.py +11 -2
  2. geo_activity_playground/core/config.py +2 -0
  3. geo_activity_playground/core/enrichment.py +18 -7
  4. geo_activity_playground/core/paths.py +3 -2
  5. geo_activity_playground/importers/strava_api.py +1 -1
  6. geo_activity_playground/webui/activity/blueprint.py +54 -2
  7. geo_activity_playground/webui/activity/controller.py +16 -0
  8. geo_activity_playground/webui/activity/templates/activity/day.html.j2 +4 -4
  9. geo_activity_playground/webui/activity/templates/activity/edit.html.j2 +42 -0
  10. geo_activity_playground/webui/activity/templates/activity/name.html.j2 +4 -4
  11. geo_activity_playground/webui/activity/templates/activity/show.html.j2 +20 -7
  12. geo_activity_playground/webui/app.py +14 -3
  13. geo_activity_playground/webui/equipment/templates/equipment/index.html.j2 +1 -1
  14. geo_activity_playground/webui/heatmap/heatmap_controller.py +8 -6
  15. geo_activity_playground/webui/search/blueprint.py +87 -6
  16. geo_activity_playground/webui/search/templates/search/index.html.j2 +67 -14
  17. geo_activity_playground/webui/settings/blueprint.py +44 -0
  18. geo_activity_playground/webui/settings/templates/settings/index.html.j2 +18 -0
  19. geo_activity_playground/webui/settings/templates/settings/kind-renames.html.j2 +25 -0
  20. geo_activity_playground/webui/settings/templates/settings/segmentation.html.j2 +27 -0
  21. geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
  22. geo_activity_playground/webui/static/favicon-48x48.png +0 -0
  23. geo_activity_playground/webui/static/favicon.ico +0 -0
  24. geo_activity_playground/webui/static/favicon.svg +3 -0
  25. geo_activity_playground/webui/static/site.webmanifest +20 -18
  26. geo_activity_playground/webui/static/web-app-manifest-192x192.png +0 -0
  27. geo_activity_playground/webui/static/web-app-manifest-512x512.png +0 -0
  28. geo_activity_playground/webui/summary/templates/summary/index.html.j2 +2 -2
  29. geo_activity_playground/webui/templates/home.html.j2 +3 -10
  30. geo_activity_playground/webui/templates/page.html.j2 +3 -3
  31. {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/METADATA +1 -1
  32. {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/RECORD +35 -30
  33. {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/WHEEL +1 -1
  34. geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
  35. geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -121
  36. {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/LICENSE +0 -0
  37. {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  import datetime
2
2
  import functools
3
+ import json
3
4
  import logging
4
5
  import pickle
5
6
  from typing import Any
@@ -16,6 +17,7 @@ from tqdm import tqdm
16
17
  from geo_activity_playground.core.paths import activities_file
17
18
  from geo_activity_playground.core.paths import activity_enriched_meta_dir
18
19
  from geo_activity_playground.core.paths import activity_enriched_time_series_dir
20
+ from geo_activity_playground.core.paths import activity_meta_override_dir
19
21
 
20
22
  logger = logging.getLogger(__name__)
21
23
 
@@ -83,7 +85,12 @@ def build_activity_meta() -> None:
83
85
  rows = []
84
86
  for new_id in tqdm(new_ids, desc="Register new activities"):
85
87
  with open(activity_enriched_meta_dir() / f"{new_id}.pickle", "rb") as f:
86
- rows.append(pickle.load(f))
88
+ data = pickle.load(f)
89
+ override_file = activity_meta_override_dir() / f"{new_id}.json"
90
+ if override_file.exists():
91
+ with open(override_file) as f:
92
+ data.update(json.load(f))
93
+ rows.append(data)
87
94
 
88
95
  if rows:
89
96
  new_shard = pd.DataFrame(rows)
@@ -140,7 +147,6 @@ class ActivityRepository:
140
147
  if not dropna or not pd.isna(row["start"]):
141
148
  yield row
142
149
 
143
- @functools.lru_cache()
144
150
  def get_activity_by_id(self, id: int) -> ActivityMeta:
145
151
  activity = self.meta.loc[id]
146
152
  assert isinstance(activity["name"], str), activity["name"]
@@ -158,6 +164,9 @@ class ActivityRepository:
158
164
 
159
165
  return df
160
166
 
167
+ def save(self) -> None:
168
+ self._meta.to_parquet(activities_file())
169
+
161
170
 
162
171
  def make_geojson_from_time_series(time_series: pd.DataFrame) -> str:
163
172
  fc = geojson.FeatureCollection(
@@ -29,6 +29,7 @@ class Config:
29
29
  )
30
30
  heart_rate_resting: int = 0
31
31
  heart_rate_maximum: Optional[int] = None
32
+ kind_renames: dict[str, str] = dataclasses.field(default_factory=dict)
32
33
  kinds_without_achievements: list[str] = dataclasses.field(default_factory=list)
33
34
  metadata_extraction_regexes: list[str] = dataclasses.field(default_factory=list)
34
35
  num_processes: Optional[int] = 1
@@ -39,6 +40,7 @@ class Config:
39
40
  strava_client_id: int = 131693
40
41
  strava_client_secret: str = "0ccc0100a2c218512a7ef0cea3b0e322fb4b4365"
41
42
  strava_client_code: Optional[str] = None
43
+ time_diff_threshold_seconds: Optional[int] = 30
42
44
  upload_password: Optional[str] = None
43
45
 
44
46
 
@@ -82,11 +82,15 @@ def enrich_activities(config: Config) -> None:
82
82
  )
83
83
  continue
84
84
 
85
+ # Rename kinds if needed.
86
+ if metadata["kind"] in config.kind_renames:
87
+ metadata["kind"] = config.kind_renames[metadata["kind"]]
88
+
85
89
  # Enrich time series.
86
90
  if metadata["kind"] in config.kinds_without_achievements:
87
91
  metadata["consider_for_achievements"] = False
88
92
  time_series = _embellish_single_time_series(
89
- time_series, metadata.get("start", None)
93
+ time_series, metadata.get("start", None), config.time_diff_threshold_seconds
90
94
  )
91
95
  metadata.update(_get_metadata_from_timeseries(time_series))
92
96
 
@@ -131,7 +135,9 @@ def _compute_moving_time(time_series: pd.DataFrame) -> datetime.timedelta:
131
135
 
132
136
 
133
137
  def _embellish_single_time_series(
134
- timeseries: pd.DataFrame, start: Optional[datetime.datetime] = None
138
+ timeseries: pd.DataFrame,
139
+ start: Optional[datetime.datetime],
140
+ time_diff_threshold_seconds: int,
135
141
  ) -> pd.DataFrame:
136
142
  if start is not None and pd.api.types.is_dtype_equal(
137
143
  timeseries["time"].dtype, "int64"
@@ -153,10 +159,12 @@ def _embellish_single_time_series(
153
159
  timeseries["latitude"],
154
160
  timeseries["longitude"],
155
161
  ).fillna(0.0)
156
- time_diff_threshold_seconds = 30
157
- time_diff = (timeseries["time"] - timeseries["time"].shift(1)).dt.total_seconds()
158
- jump_indices = time_diff >= time_diff_threshold_seconds
159
- distances.loc[jump_indices] = 0.0
162
+ if time_diff_threshold_seconds:
163
+ time_diff = (
164
+ timeseries["time"] - timeseries["time"].shift(1)
165
+ ).dt.total_seconds()
166
+ jump_indices = time_diff >= time_diff_threshold_seconds
167
+ distances.loc[jump_indices] = 0.0
160
168
 
161
169
  if "distance_km" not in timeseries.columns:
162
170
  timeseries["distance_km"] = pd.Series(np.cumsum(distances)) / 1000
@@ -173,7 +181,10 @@ def _embellish_single_time_series(
173
181
  timeseries = timeseries.loc[~potential_jumps].copy()
174
182
 
175
183
  if "segment_id" not in timeseries.columns:
176
- timeseries["segment_id"] = np.cumsum(jump_indices)
184
+ if time_diff_threshold_seconds:
185
+ timeseries["segment_id"] = np.cumsum(jump_indices)
186
+ else:
187
+ timeseries["segment_id"] = 0
177
188
 
178
189
  if "x" not in timeseries.columns:
179
190
  x, y = compute_tile_float(timeseries["latitude"], timeseries["longitude"], 0)
@@ -8,7 +8,6 @@ import typing
8
8
 
9
9
 
10
10
  def dir_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
11
- @functools.cache
12
11
  def wrapper() -> pathlib.Path:
13
12
  path.mkdir(exist_ok=True, parents=True)
14
13
  return path
@@ -17,7 +16,6 @@ def dir_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
17
16
 
18
17
 
19
18
  def file_wrapper(path: pathlib.Path) -> typing.Callable[[], pathlib.Path]:
20
- @functools.cache
21
19
  def wrapper() -> pathlib.Path:
22
20
  path.parent.mkdir(exist_ok=True, parents=True)
23
21
  return path
@@ -55,6 +53,8 @@ _strava_last_activity_date_path = _cache_dir / "strava-last-activity-date.json"
55
53
 
56
54
  _new_config_file = pathlib.Path("config.json")
57
55
 
56
+ _activity_meta_override_dir = pathlib.Path("Metadata Override")
57
+
58
58
 
59
59
  cache_dir = dir_wrapper(_cache_dir)
60
60
 
@@ -65,6 +65,7 @@ activity_enriched_meta_dir = dir_wrapper(_activity_enriched_meta_dir)
65
65
  activity_enriched_time_series_dir = dir_wrapper(_activity_enriched_time_series_dir)
66
66
  tiles_per_time_series = dir_wrapper(_tiles_per_time_series)
67
67
  strava_api_dir = dir_wrapper(_strava_api_dir)
68
+ activity_meta_override_dir = dir_wrapper(_activity_meta_override_dir)
68
69
 
69
70
  activities_file = file_wrapper(_activities_file)
70
71
  strava_dynamic_config_path = file_wrapper(_strava_dynamic_config_path)
@@ -139,7 +139,7 @@ def try_import_strava(config: Config) -> bool:
139
139
  "commute": activity.commute,
140
140
  "distance_km": activity.distance / 1000,
141
141
  "name": activity.name,
142
- "kind": str(activity.type),
142
+ "kind": str(activity.type.root),
143
143
  "start": convert_to_datetime_ns(activity.start_date),
144
144
  "elapsed_time": activity.elapsed_time,
145
145
  "equipment": gear_names[activity.gear_id],
@@ -1,21 +1,29 @@
1
+ import json
1
2
  import urllib.parse
2
3
  from collections.abc import Collection
3
4
 
4
5
  from flask import Blueprint
6
+ from flask import redirect
5
7
  from flask import render_template
8
+ from flask import request
6
9
  from flask import Response
10
+ from flask import url_for
7
11
 
8
12
  from ...core.activities import ActivityRepository
9
13
  from ...explorer.tile_visits import TileVisitAccessor
10
14
  from .controller import ActivityController
11
15
  from geo_activity_playground.core.config import Config
16
+ from geo_activity_playground.core.paths import activity_meta_override_dir
12
17
  from geo_activity_playground.core.privacy_zones import PrivacyZone
18
+ from geo_activity_playground.webui.authenticator import Authenticator
19
+ from geo_activity_playground.webui.authenticator import needs_authentication
13
20
 
14
21
 
15
22
  def make_activity_blueprint(
16
23
  repository: ActivityRepository,
17
24
  tile_visit_accessor: TileVisitAccessor,
18
25
  config: Config,
26
+ authenticator: Authenticator,
19
27
  ) -> Blueprint:
20
28
  blueprint = Blueprint("activity", __name__, template_folder="templates")
21
29
 
@@ -44,14 +52,58 @@ def make_activity_blueprint(
44
52
  def day(year: str, month: str, day: str):
45
53
  return render_template(
46
54
  "activity/day.html.j2",
47
- **activity_controller.render_day(int(year), int(month), int(day))
55
+ **activity_controller.render_day(int(year), int(month), int(day)),
48
56
  )
49
57
 
50
58
  @blueprint.route("/name/<name>")
51
59
  def name(name: str):
52
60
  return render_template(
53
61
  "activity/name.html.j2",
54
- **activity_controller.render_name(urllib.parse.unquote(name))
62
+ **activity_controller.render_name(urllib.parse.unquote(name)),
63
+ )
64
+
65
+ @blueprint.route("/edit/<id>", methods=["GET", "POST"])
66
+ @needs_authentication(authenticator)
67
+ def edit(id: str):
68
+ activity_id = int(id)
69
+ activity = repository.get_activity_by_id(activity_id)
70
+ override_file = activity_meta_override_dir() / f"{activity_id}.json"
71
+ if override_file.exists():
72
+ with open(override_file) as f:
73
+ override = json.load(f)
74
+ else:
75
+ override = {}
76
+
77
+ if request.method == "POST":
78
+ override = {}
79
+ if value := request.form.get("name"):
80
+ override["name"] = value
81
+ repository.meta.loc[activity_id, "name"] = value
82
+ if value := request.form.get("kind"):
83
+ override["kind"] = value
84
+ repository.meta.loc[activity_id, "kind"] = value
85
+ if value := request.form.get("equipment"):
86
+ override["equipment"] = value
87
+ repository.meta.loc[activity_id, "equipment"] = value
88
+ if value := request.form.get("commute"):
89
+ override["commute"] = True
90
+ repository.meta.loc[activity_id, "commute"] = True
91
+ if value := request.form.get("consider_for_achievements"):
92
+ override["consider_for_achievements"] = True
93
+ repository.meta.loc[activity_id, "consider_for_achievements"] = True
94
+
95
+ with open(override_file, "w") as f:
96
+ json.dump(override, f, ensure_ascii=False, indent=4, sort_keys=True)
97
+
98
+ repository.save()
99
+
100
+ return redirect(url_for(".show", id=activity_id))
101
+
102
+ return render_template(
103
+ "activity/edit.html.j2",
104
+ activity_id=activity_id,
105
+ activity=activity,
106
+ override=override,
55
107
  )
56
108
 
57
109
  return blueprint
@@ -115,6 +115,8 @@ class ActivityController:
115
115
  result["altitude_time_plot"] = altitude_time_plot(time_series)
116
116
  if "heartrate" in time_series.columns:
117
117
  result["heartrate_time_plot"] = heart_rate_time_plot(time_series)
118
+ if "cadence" in time_series.columns:
119
+ result["cadence_time_plot"] = cadence_time_plot(time_series)
118
120
  return result
119
121
 
120
122
  def render_sharepic(self, id: int) -> bytes:
@@ -324,6 +326,20 @@ def heart_rate_time_plot(time_series: pd.DataFrame) -> str:
324
326
  )
325
327
 
326
328
 
329
+ def cadence_time_plot(time_series: pd.DataFrame) -> str:
330
+ return (
331
+ alt.Chart(time_series, title="Cadence")
332
+ .mark_line()
333
+ .encode(
334
+ alt.X("time", title="Time"),
335
+ alt.Y("cadence", title="Cadence"),
336
+ alt.Color("segment_id:N", title="Segment"),
337
+ )
338
+ .interactive(bind_y=False)
339
+ .to_json(format="vega")
340
+ )
341
+
342
+
327
343
  def heart_rate_zone_plot(heart_zones: pd.DataFrame) -> str:
328
344
  return (
329
345
  alt.Chart(heart_zones, title="Heart Rate Zones")
@@ -41,7 +41,7 @@
41
41
  <div class="col">
42
42
  <h2>Activities</h2>
43
43
 
44
- <table class="table">
44
+ <table class="table table-sort table-arrows">
45
45
  <thead>
46
46
  <tr>
47
47
  <th>Name</th>
@@ -58,9 +58,9 @@
58
58
  <td><span style="color: {{ activity['color'] }};">█</span> <a
59
59
  href="{{ url_for('activity.show', id=activity.id) }}">{{
60
60
  activity.name }}</a></td>
61
- <td>{{ activity.start }}</td>
61
+ <td>{{ activity.start|dt }}</td>
62
62
  <td>{{ activity.distance_km | round(1) }}</td>
63
- <td>{{ activity.elapsed_time }}</td>
63
+ <td>{{ activity.elapsed_time|td }}</td>
64
64
  <td>{{ activity["equipment"] }}</td>
65
65
  <td>{{ activity["kind"] }}</td>
66
66
  </tr>
@@ -70,7 +70,7 @@
70
70
  <td><b>Total</b></td>
71
71
  <td></td>
72
72
  <td><b>{{ total_distance | round(1) }}</b></td>
73
- <td><b>{{ total_elapsed_time }}</b></td>
73
+ <td><b>{{ total_elapsed_time|td }}</b></td>
74
74
  <td></td>
75
75
  <td></td>
76
76
  </tr>
@@ -0,0 +1,42 @@
1
+ {% extends "page.html.j2" %}
2
+
3
+ {% block container %}
4
+
5
+ <h1 class="mb-3">Edit Activity</h1>
6
+
7
+ <form method="POST">
8
+ <div class="mb-3">
9
+ <label for="name" class="form-label">Name</label>
10
+ <input type="text" class="form-control" id="name" name="name" value="{{ override['name'] }}" />
11
+ </div>
12
+
13
+ <div class="mb-3">
14
+ <label for="kind" class="form-label">Kind</label>
15
+ <input type="text" class="form-control" id="kind" name="kind" value="{{ override['kind'] }}" />
16
+ </div>
17
+
18
+ <div class="mb-3">
19
+ <label for="equipment" class="form-label">Equipment</label>
20
+ <input type="text" class="form-control" id="equipment" name="equipment" value="{{ override['equipment'] }}" />
21
+ </div>
22
+
23
+ <div class="mb-3">
24
+ <div class="form-check">
25
+ <input type="checkbox" class="form-check-input" id="commute" name="commute" {% if override['commute'] %}
26
+ checked {% endif %} />
27
+ <label for="commute" class="form-check-label">Commute</label>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="mb-3">
32
+ <div class="form-check">
33
+ <input type="checkbox" class="form-check-input" id="consider_for_achievements"
34
+ name="consider_for_achievements" {% if override['consider_for_achievements'] %} checked {% endif %} />
35
+ <label for="consider_for_achievements" class="form-check-label">Consider for achievements</label>
36
+ </div>
37
+ </div>
38
+
39
+ <button type="submit" class="btn btn-primary">Save</button>
40
+ </form>
41
+
42
+ {% endblock %}
@@ -50,12 +50,12 @@
50
50
  <div class="col">
51
51
  <h2>Activities</h2>
52
52
 
53
- <table class="table">
53
+ <table class="table table-sort table-arrows">
54
54
  <thead>
55
55
  <tr>
56
56
  <th>Name</th>
57
57
  <th>Date</th>
58
- <th>Distance / km</th>
58
+ <th class="numeric-sort">Distance / km</th>
59
59
  <th>Elapsed time</th>
60
60
  <th>Equipment</th>
61
61
  <th>Kind</th>
@@ -66,9 +66,9 @@
66
66
  <tr>
67
67
  <td><span style="color: {{ activity['color'] }};">█</span> <a href="{{ url_for('activity.show', id=activity.id) }}">{{
68
68
  activity.name }}</a></td>
69
- <td>{{ activity.start }}</td>
69
+ <td>{{ activity.start|dt }}</td>
70
70
  <td>{{ activity.distance_km | round(1) }}</td>
71
- <td>{{ activity.elapsed_time }}</td>
71
+ <td>{{ activity.elapsed_time|td }}</td>
72
72
  <td>{{ activity["equipment"] }}</td>
73
73
  <td>{{ activity["kind"] }}</td>
74
74
  </tr>
@@ -19,9 +19,9 @@
19
19
  <dt>Distance</dt>
20
20
  <dd>{{ activity.distance_km|round(1) }} km</dd>
21
21
  <dt>Elapsed time</dt>
22
- <dd>{{ activity.elapsed_time }}</dd>
22
+ <dd>{{ activity.elapsed_time|td }}</dd>
23
23
  <dt>Moving time</dt>
24
- <dd>{{ activity.moving_time }}</dd>
24
+ <dd>{{ activity.moving_time|td }}</dd>
25
25
  <dt>Start time</dt>
26
26
  <dd><a href="{{ url_for('activity.day', year=date.year, month=date.month, day=date.day) }}">{{ date }}</a>
27
27
  {{ time }}
@@ -31,7 +31,7 @@
31
31
  <dt>Steps</dt>
32
32
  <dd>{{ activity.steps }}</dd>
33
33
  <dt>Equipment</dt>
34
- <dd>{{ activity.equipment }}</dd>
34
+ <dd>{{ activity['equipment'] }}</dd>
35
35
  <dt>New Explorer Tiles</dt>
36
36
  <dd>{{ new_tiles[14] }}</dd>
37
37
  <dt>New Squadratinhos</dt>
@@ -41,6 +41,8 @@
41
41
  <dt>Source path</dt>
42
42
  <dd>{{ activity.path }}</dd>
43
43
  </dl>
44
+
45
+ <a href="{{ url_for('.edit', id=activity['id']) }}" class="btn btn-secondary btn-small">Edit metadata</a>
44
46
  </div>
45
47
  <div class="col-8">
46
48
  <div id="activity-map" style="height: 500px;" class="mb-3"></div>
@@ -129,6 +131,17 @@
129
131
  </div>
130
132
  {% endif %}
131
133
 
134
+ {% if cadence_time_plot is defined %}
135
+ <h2>Cadence</h2>
136
+
137
+ <div class="row mb-3">
138
+ <div class="col-md-4">
139
+ {{ vega_direct("cadence_time_plot", cadence_time_plot) }}
140
+ </div>
141
+ </div>
142
+ {% endif %}
143
+
144
+
132
145
  <h2>Share picture</h2>
133
146
 
134
147
  <p><img src="{{ url_for('.sharepic', id=activity.id) }}" /></p>
@@ -177,11 +190,11 @@
177
190
 
178
191
  <p><a href="{{ url_for('.name', name=activity['name']) }}">Overview over these activities</a></p>
179
192
 
180
- <table class="table">
193
+ <table class="table table-sort table-arrows">
181
194
  <thead>
182
195
  <tr>
183
196
  <th>Date</th>
184
- <th>Distance / km</th>
197
+ <th class="numeric-sort">Distance / km</th>
185
198
  <th>Elapsed time</th>
186
199
  <th>Equipment</th>
187
200
  <th>Kind</th>
@@ -190,10 +203,10 @@
190
203
  <tbody>
191
204
  {% for other_activity in similar_activites %}
192
205
  <tr>
193
- <td><a href="{{ url_for('.show', id=other_activity.id) }}">{{ other_activity.start
206
+ <td><a href="{{ url_for('.show', id=other_activity.id) }}">{{ other_activity.start|dt
194
207
  }}</a></td>
195
208
  <td>{{ other_activity.distance_km | round(1) }}</td>
196
- <td>{{ other_activity.elapsed_time }}</td>
209
+ <td>{{ other_activity.elapsed_time|td }}</td>
197
210
  <td>{{ other_activity["equipment"] }}</td>
198
211
  <td>{{ other_activity["kind"] }}</td>
199
212
  </tr>
@@ -1,3 +1,4 @@
1
+ import datetime
1
2
  import importlib
2
3
  import json
3
4
  import pathlib
@@ -60,6 +61,18 @@ def web_ui_main(
60
61
  app.config["UPLOAD_FOLDER"] = "Activities"
61
62
  app.secret_key = get_secret_key()
62
63
 
64
+ @app.template_filter()
65
+ def dt(value: datetime.datetime):
66
+ return value.strftime("%Y-%m-%d %H:%M")
67
+
68
+ @app.template_filter()
69
+ def td(v: datetime.timedelta):
70
+ seconds = v.total_seconds()
71
+ h = int(seconds // 3600)
72
+ m = int(seconds // 60 % 60)
73
+ s = int(seconds // 1 % 60)
74
+ return f"{h}:{m:02d}:{s:02d}"
75
+
63
76
  authenticator = Authenticator(config_accessor())
64
77
 
65
78
  route_start(app, repository, config_accessor())
@@ -68,9 +81,7 @@ def web_ui_main(
68
81
 
69
82
  app.register_blueprint(
70
83
  make_activity_blueprint(
71
- repository,
72
- tile_visit_accessor,
73
- config_accessor(),
84
+ repository, tile_visit_accessor, config_accessor(), authenticator
74
85
  ),
75
86
  url_prefix="/activity",
76
87
  )
@@ -9,7 +9,7 @@
9
9
 
10
10
  <div class="row mb-3">
11
11
  <div class="col">
12
- <table class="table">
12
+ <table class="table table-sort table-arrows">
13
13
  <thead>
14
14
  <tr>
15
15
  <th>Equipment</th>
@@ -90,23 +90,25 @@ class HeatmapController:
90
90
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
91
91
  tile_count_cache_path.parent.mkdir(parents=True, exist_ok=True)
92
92
  activity_ids = self.activities_per_tile[z].get((x, y), set())
93
- if activity_ids:
93
+ activity_ids_kind = set()
94
+ for activity_id in activity_ids:
95
+ activity = self._repository.get_activity_by_id(activity_id)
96
+ if activity["kind"] == kind:
97
+ activity_ids_kind.add(activity_id)
98
+ if activity_ids_kind:
94
99
  with work_tracker(
95
100
  tile_count_cache_path.with_suffix(".json")
96
101
  ) as parsed_activities:
97
- if parsed_activities - activity_ids:
102
+ if parsed_activities - activity_ids_kind:
98
103
  logger.warning(
99
104
  f"Resetting heatmap cache for {kind=}/{x=}/{y=}/{z=} because activities have been removed."
100
105
  )
101
106
  tile_counts = np.zeros(tile_pixels, dtype=np.int32)
102
107
  parsed_activities.clear()
103
- for activity_id in activity_ids:
108
+ for activity_id in activity_ids_kind:
104
109
  if activity_id in parsed_activities:
105
110
  continue
106
111
  parsed_activities.add(activity_id)
107
- activity = self._repository.get_activity_by_id(activity_id)
108
- if activity["kind"] != kind:
109
- continue
110
112
  time_series = self._repository.get_time_series(activity_id)
111
113
  for _, group in time_series.groupby("segment_id"):
112
114
  xy_pixels = (
@@ -1,4 +1,8 @@
1
+ from functools import reduce
2
+
3
+ import dateutil.parser
1
4
  from flask import Blueprint
5
+ from flask import flash
2
6
  from flask import render_template
3
7
  from flask import request
4
8
  from flask import Response
@@ -6,15 +10,92 @@ from flask import Response
6
10
  from ...core.activities import ActivityRepository
7
11
 
8
12
 
13
+ def reduce_or(selections):
14
+ return reduce(lambda a, b: a | b, selections)
15
+
16
+
17
+ def reduce_and(selections):
18
+ return reduce(lambda a, b: a & b, selections)
19
+
20
+
9
21
  def make_search_blueprint(repository: ActivityRepository) -> Blueprint:
10
22
  blueprint = Blueprint("search", __name__, template_folder="templates")
11
23
 
12
- @blueprint.route("/", methods=["POST"])
24
+ @blueprint.route("/")
13
25
  def index():
14
- activities = []
15
- for _, row in repository.meta.iterrows():
16
- if request.form["name"] in row["name"]:
17
- activities.append(row)
18
- return render_template("search/index.html.j2", activities=activities)
26
+ kinds_avail = repository.meta["kind"].unique()
27
+ equipments_avail = repository.meta["equipment"].unique()
28
+
29
+ print(request.args)
30
+
31
+ activities = repository.meta
32
+
33
+ if equipments := request.args.getlist("equipment"):
34
+ selection = reduce_or(
35
+ activities["equipment"] == equipment for equipment in equipments
36
+ )
37
+ activities = activities.loc[selection]
38
+
39
+ if kinds := request.args.getlist("kind"):
40
+ selection = reduce_or(activities["kind"] == kind for kind in kinds)
41
+ activities = activities.loc[selection]
42
+
43
+ name_exact = bool(request.args.get("name_exact", False))
44
+ name_casing = bool(request.args.get("name_casing", False))
45
+ if name := request.args.get("name", ""):
46
+ if name_casing:
47
+ haystack = activities["name"]
48
+ needle = name
49
+ else:
50
+ haystack = activities["name"].str.lower()
51
+ needle = name.lower()
52
+ if name_exact:
53
+ selection = haystack == needle
54
+ else:
55
+ selection = [needle in an for an in haystack]
56
+ activities = activities.loc[selection]
57
+
58
+ begin = request.args.get("begin", "")
59
+ end = request.args.get("end", "")
60
+
61
+ if begin:
62
+ try:
63
+ begin_dt = dateutil.parser.parse(begin)
64
+ except ValueError:
65
+ flash(
66
+ f"Cannot parse date `{begin}`, please use a different format.",
67
+ category="danger",
68
+ )
69
+ else:
70
+ selection = begin_dt <= activities["start"]
71
+ activities = activities.loc[selection]
72
+
73
+ if end:
74
+ try:
75
+ end_dt = dateutil.parser.parse(end)
76
+ except ValueError:
77
+ flash(
78
+ f"Cannot parse date `{end}`, please use a different format.",
79
+ category="danger",
80
+ )
81
+ else:
82
+ selection = activities["start"] < end_dt
83
+ activities = activities.loc[selection]
84
+
85
+ activities = activities.sort_values("start", ascending=False)
86
+
87
+ return render_template(
88
+ "search/index.html.j2",
89
+ activities=list(activities.iterrows()),
90
+ equipments=request.args.getlist("equipment"),
91
+ equipments_avail=sorted(equipments_avail),
92
+ kinds=request.args.getlist("kind"),
93
+ kinds_avail=sorted(kinds_avail),
94
+ name=name,
95
+ name_exact=name_exact,
96
+ name_casing=name_casing,
97
+ begin=begin,
98
+ end=end,
99
+ )
19
100
 
20
101
  return blueprint