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.
- geo_activity_playground/core/activities.py +11 -2
- geo_activity_playground/core/config.py +2 -0
- geo_activity_playground/core/enrichment.py +18 -7
- geo_activity_playground/core/paths.py +3 -2
- geo_activity_playground/importers/strava_api.py +1 -1
- geo_activity_playground/webui/activity/blueprint.py +54 -2
- geo_activity_playground/webui/activity/controller.py +16 -0
- geo_activity_playground/webui/activity/templates/activity/day.html.j2 +4 -4
- geo_activity_playground/webui/activity/templates/activity/edit.html.j2 +42 -0
- geo_activity_playground/webui/activity/templates/activity/name.html.j2 +4 -4
- geo_activity_playground/webui/activity/templates/activity/show.html.j2 +20 -7
- geo_activity_playground/webui/app.py +14 -3
- geo_activity_playground/webui/equipment/templates/equipment/index.html.j2 +1 -1
- geo_activity_playground/webui/heatmap/heatmap_controller.py +8 -6
- geo_activity_playground/webui/search/blueprint.py +87 -6
- geo_activity_playground/webui/search/templates/search/index.html.j2 +67 -14
- geo_activity_playground/webui/settings/blueprint.py +44 -0
- geo_activity_playground/webui/settings/templates/settings/index.html.j2 +18 -0
- geo_activity_playground/webui/settings/templates/settings/kind-renames.html.j2 +25 -0
- geo_activity_playground/webui/settings/templates/settings/segmentation.html.j2 +27 -0
- geo_activity_playground/webui/static/apple-touch-icon.png +0 -0
- geo_activity_playground/webui/static/favicon-48x48.png +0 -0
- geo_activity_playground/webui/static/favicon.ico +0 -0
- geo_activity_playground/webui/static/favicon.svg +3 -0
- geo_activity_playground/webui/static/site.webmanifest +20 -18
- geo_activity_playground/webui/static/web-app-manifest-192x192.png +0 -0
- geo_activity_playground/webui/static/web-app-manifest-512x512.png +0 -0
- geo_activity_playground/webui/summary/templates/summary/index.html.j2 +2 -2
- geo_activity_playground/webui/templates/home.html.j2 +3 -10
- geo_activity_playground/webui/templates/page.html.j2 +3 -3
- {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/METADATA +1 -1
- {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/RECORD +35 -30
- {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/WHEEL +1 -1
- geo_activity_playground/webui/static/android-chrome-384x384.png +0 -0
- geo_activity_playground/webui/static/safari-pinned-tab.svg +0 -121
- {geo_activity_playground-0.29.2.dist-info → geo_activity_playground-0.31.0.dist-info}/LICENSE +0 -0
- {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
|
-
|
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,
|
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
|
157
|
-
|
158
|
-
|
159
|
-
|
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
|
-
|
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
|
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
|
)
|
@@ -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
|
-
|
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 -
|
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
|
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("/"
|
24
|
+
@blueprint.route("/")
|
13
25
|
def index():
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
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
|