whatdidyoudo 0.1.13__py3-none-any.whl → 0.2.4__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.

Potentially problematic release.


This version of whatdidyoudo might be problematic. Click here for more details.

whatdidyoudo/app.py CHANGED
@@ -5,12 +5,12 @@ from collections import defaultdict
5
5
  from dataclasses import dataclass
6
6
  import requests
7
7
 
8
- from flask import Flask, render_template
8
+ from flask import Flask, render_template, request
9
9
  from flask_caching import Cache
10
- from flask_limiter import Limiter
10
+ from flask_limiter import Limiter, RateLimitExceeded
11
11
  from flask_limiter.util import get_remote_address
12
12
 
13
- __version__ = "0.1.13"
13
+ __version__ = "0.2.4"
14
14
 
15
15
  app = Flask(__name__)
16
16
  cache = Cache(app, config={"CACHE_TYPE": "SimpleCache",
@@ -32,20 +32,31 @@ def get_etree_from_url(url: str) -> ET.Element:
32
32
  return ET.fromstring(response.content)
33
33
 
34
34
 
35
- def get_changes(user: str, date: str) -> defaultdict[str, Changes]:
36
- """Return a {app: Changes} dictionary."""
37
- # {user: {app: Changes}}
35
+ def get_changes(user: str, start_date: str, end_date: str | None = None,
36
+ start_time: str = "00:00", end_time: str = "23:59"):
37
+ """Return a ({app: Changes}, [changeset_ids]) tuple for a date/time range.
38
+
39
+ start_date, end_date are ISO date strings (YYYY-MM-DD). start_time and
40
+ end_time are HH:MM strings. The function builds ISO datetimes for the OSM
41
+ API (UTC, appended with Z).
42
+ """
38
43
  changes: defaultdict[str, Changes] = defaultdict(Changes)
39
- datetime_date = datetime.date.fromisoformat(date)
40
- start_time = f"{datetime_date}T00:00:00Z"
41
- end_time = f"{datetime_date + datetime.timedelta(days=1)}T00:00:00Z"
44
+ changeset_ids: list[str] = []
45
+ # Ensure end_date defaults to start_date when not provided
46
+ end_date = end_date or start_date
47
+ # Build ISO datetime strings expected by the OSM API
48
+ # e.g. 2025-10-24T00:00:00Z
49
+ start_time_iso = f"{start_date}T{start_time}:00Z"
50
+ end_time_iso = f"{end_date}T{end_time}:00Z"
42
51
 
43
52
  changeset_url = ("https://api.openstreetmap.org/api/0.6/changesets?"
44
- f"display_name={user}&time={start_time},{end_time}")
53
+ f"display_name={user}&"
54
+ f"time={start_time_iso},{end_time_iso}")
45
55
  root = get_etree_from_url(url=changeset_url)
46
56
 
47
57
  for cs in root.findall("changeset"):
48
58
  cs_id = cs.attrib["id"]
59
+ changeset_ids.append(cs_id)
49
60
 
50
61
  tags = {tag.attrib["k"]: tag.attrib["v"] for tag in cs.findall("tag")}
51
62
  editor = tags.get("created_by", "")
@@ -60,41 +71,70 @@ def get_changes(user: str, date: str) -> defaultdict[str, Changes]:
60
71
 
61
72
  for action in root:
62
73
  changes[editor].changes += len(action)
63
- return changes
74
+ return changes, changeset_ids
64
75
 
65
76
 
66
77
  @app.route('/')
67
78
  @app.route('/<user>/')
68
79
  @app.route('/<user>')
69
- @app.route('/<user>/<date>')
70
- def whatdidyoudo(user: str | None = None, date: str | None = None) -> str:
71
- """shows OSM tasks done by a user on a specific day."""
80
+ @app.route('/<user>/<start_date>/')
81
+ @app.route('/<user>/<start_date>')
82
+ @app.route('/<user>/<start_date>/<end_date>')
83
+ def whatdidyoudo(user: str | None = None, start_date: str | None = None,
84
+ end_date: str | None = None) -> str:
85
+ """shows OSM tasks done by a user within a date/time range.
86
+
87
+ Expert mode allows finer-grained start/end times via query params:
88
+ expert=1, start_time=HH:MM, end_time=HH:MM
89
+ """
72
90
  changes: defaultdict[str, defaultdict[str, Changes]] = \
73
91
  defaultdict(lambda: defaultdict(Changes))
74
92
  changesets: dict[str, int] = {}
75
93
  errors: list[str] = []
94
+ # Read expert mode and time params from query string
95
+ expert = request.args.get('expert', '0') in ('1', 'true', 'True')
96
+ start_time = request.args.get('start_time', '00:00')
97
+ end_time = request.args.get('end_time', '23:59')
98
+
99
+ date_str = (f"between {start_date} {start_time} and {end_date} {end_time}"
100
+ if end_date or expert else f"on {start_date}")
76
101
  today = datetime.date.today().isoformat()
77
- if not date:
78
- date = today
102
+
103
+ changeset_ids: list[str] = []
79
104
  for name in [item.strip() for item in (user or "").split(",")
80
105
  if item.strip()]:
81
- cache_key = f"changes_{name}_{date}"
82
- cur_changes = cache.get(cache_key) # type: ignore
83
- if not cur_changes:
106
+ cache_key = (f"changes_{name}_{start_date}_{end_date}_"
107
+ f"{start_time}_{end_time}")
108
+ cur_data = cache.get(cache_key) # type: ignore
109
+ if not cur_data:
84
110
  try:
85
111
  with limiter.limit("10 per minute"):
86
- cur_changes = get_changes(name, date)
87
- if date != today:
88
- cache.set(cache_key, cur_changes) # type: ignore
112
+ cur_data = get_changes(user=name,
113
+ start_date=start_date or today,
114
+ end_date=end_date,
115
+ start_time=start_time,
116
+ end_time=end_time)
117
+ # Only cache results when the range does not include today
118
+ if ((start_date and start_date != today) or
119
+ (end_date and end_date != today)):
120
+ cache.set(cache_key, cur_data) # type: ignore
89
121
  except requests.HTTPError:
90
122
  errors.append(
91
- f"Can't determine changes for user {name} on {date}.")
92
- if cur_changes:
123
+ f"Can't determine changes for user {name} {date_str}.")
124
+ except RateLimitExceeded as msg:
125
+ errors.append("Rate limit exceeded while processing user "
126
+ f"{name}: {msg}")
127
+ if cur_data:
128
+ cur_changes, cur_ids = cur_data
93
129
  changes[name] = cur_changes
94
-
95
- return render_template('form.html', user=user, date=date, changes=changes,
96
- changesets=changesets, errors=errors,
97
- version=__version__)
130
+ changeset_ids.extend(cur_ids)
131
+
132
+ return render_template('form.html', user=user, start_date=start_date,
133
+ end_date=end_date, start_time=start_time,
134
+ end_time=end_time, expert=expert,
135
+ changes=changes, changesets=changesets,
136
+ errors=errors, date_str=date_str,
137
+ version=__version__, changeset_ids=changeset_ids)
98
138
 
99
139
 
100
140
  def main():
@@ -16,7 +16,7 @@ body {
16
16
  box-shadow: 0 4px 24px rgba(0,0,0,0.3);
17
17
  padding: 2em 2.5em 1.5em 2.5em;
18
18
  margin-top: 2em;
19
- max-width: 600px;
19
+ max-width: 800px;
20
20
  width: 95%;
21
21
  }
22
22
 
@@ -37,12 +37,61 @@ form {
37
37
  width: 100%;
38
38
  }
39
39
 
40
- label {
40
+ .expert-toggle {
41
+ display: flex;
42
+ align-items: center; /* vertically center checkbox and label within the form's row */
43
+ margin-top: 0.5em;
44
+ }
45
+
46
+ @media (max-width: 600px) {
47
+ .expert-toggle {
48
+ margin-top: 0; /* in stacked layout keep normal spacing */
49
+ }
50
+ }
51
+
52
+ /* Expert rows: align labels and inputs into two columns so inputs stack vertically
53
+ and are perfectly aligned regardless of label width. */
54
+ .expert-row {
55
+ /* allow the element to not introduce its own box so its children
56
+ participate in the parent's grid; keeps markup simple while
57
+ allowing a single shared grid column sizing for labels */
58
+ display: contents;
59
+ }
60
+
61
+ #expertControls {
62
+ display: grid;
63
+ grid-template-columns: auto 1fr; /* dynamic label column, flexible input column */
64
+ gap: 0.5em 1em;
65
+ align-items: center;
66
+ }
67
+
68
+ #expertControls label {
69
+ justify-self: end; /* right-align labels to create a neat gutter */
70
+ }
71
+
72
+ #expertControls input[type="datetime-local"] {
73
+ width: 100%;
74
+ min-width: 0; /* allow the input to shrink inside grid */
75
+ }
76
+
77
+ /* On small screens, stack label above input for readability */
78
+ @media (max-width: 600px) {
79
+ #expertControls {
80
+ grid-template-columns: 1fr; /* single column: label above input */
81
+ }
82
+ #expertControls label {
83
+ justify-self: start;
84
+ }
85
+ }
86
+
87
+ label.hidden {
41
88
  display: none;
42
89
  }
43
90
 
44
91
  input[type="text"],
45
92
  input[type="date"],
93
+ input[type="datetime-local"],
94
+ input[type="checkbox"],
46
95
  button {
47
96
  border: none;
48
97
  border-radius: 8px;
@@ -61,15 +110,25 @@ input[type="text"] {
61
110
  color: #f3f3f3;
62
111
  }
63
112
 
113
+ input[type="datetime-local"],
64
114
  input[type="date"] {
65
115
  /* flex: 1 1 120px; */
66
116
  padding: 0.7em 0.5em;
67
117
  background: #2c2f34;
68
118
  color: #f3f3f3;
119
+ }
120
+
121
+ input[type="date"] {
69
122
  min-width: 120px;
70
123
  max-width: 160px;
71
124
  }
72
125
 
126
+ input[type="datetime-local"] {
127
+ min-width: 120px;
128
+ max-width: 260px;
129
+ }
130
+
131
+
73
132
  button {
74
133
  padding: 0.7em 1.5em;
75
134
  background: #4f8cff;
@@ -124,7 +183,8 @@ a:hover {
124
183
  flex-direction: column;
125
184
  gap: 0.7em;
126
185
  }
127
- input[type="date"] {
186
+ input[type="date"],
187
+ input[type="datetime-local"] {
128
188
  width: 100%;
129
189
  min-width: unset;
130
190
  max-width: unset;
@@ -7,31 +7,110 @@
7
7
  <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
8
8
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
9
9
  <script>
10
- window.onload = function() {
11
- var dateInput = document.getElementById('date');
12
- if (!dateInput.value) {
13
- var today = new Date();
14
- var yyyy = today.getFullYear();
15
- var mm = String(today.getMonth() + 1).padStart(2, '0');
16
- var dd = String(today.getDate()).padStart(2, '0');
17
- dateInput.value = yyyy + '-' + mm + '-' + dd;
10
+ function isoToday() {
11
+ const t = new Date();
12
+ const yyyy = t.getFullYear();
13
+ const mm = String(t.getMonth() + 1).padStart(2, '0');
14
+ const dd = String(t.getDate()).padStart(2, '0');
15
+ return `${yyyy}-${mm}-${dd}`;
16
+ }
17
+
18
+ function initForm() {
19
+ const expert = document.getElementById('expert');
20
+ const expertControls = document.getElementById('expertControls');
21
+ const simpleRow = document.getElementById('simpleDateRow');
22
+
23
+ // Initialize dates/time defaults
24
+ const single = document.getElementById('single_date');
25
+ const start_dt = document.getElementById('start_dt');
26
+ const end_dt = document.getElementById('end_dt');
27
+
28
+ if (single && !single.value) single.value = isoToday();
29
+ // default datetime-local values: YYYY-MM-DDTHH:MM
30
+ if (start_dt && !start_dt.value) start_dt.value = (single ? single.value : isoToday()) + 'T00:00';
31
+ if (end_dt && !end_dt.value) end_dt.value = (single ? single.value : isoToday()) + 'T23:59';
32
+
33
+ // If server rendered the expert checkbox checked, reflect that in UI
34
+ if (expert.checked) {
35
+ expertControls.style.display = '';
36
+ simpleRow.style.display = 'none';
37
+ }
38
+
39
+ expert.addEventListener('change', function() {
40
+ if (expert.checked) {
41
+ expertControls.style.display = '';
42
+ simpleRow.style.display = 'none';
43
+ // copy single date into start/end if empty
44
+ if (start_dt && !start_dt.value) start_dt.value = (single ? single.value : isoToday()) + 'T00:00';
45
+ if (end_dt && !end_dt.value) end_dt.value = (single ? single.value : isoToday()) + 'T23:59';
46
+ } else {
47
+ expertControls.style.display = 'none';
48
+ simpleRow.style.display = '';
49
+ }
50
+ });
51
+ }
52
+
53
+ function submitForm() {
54
+ const user = encodeURIComponent(document.getElementById('user').value);
55
+ const expert = document.getElementById('expert').checked;
56
+ if (!expert) {
57
+ const date = encodeURIComponent(document.getElementById('single_date').value);
58
+ // keep URL as /user/date
59
+ window.location.href = '/' + user + '/' + date;
60
+ return;
61
+ }
62
+ // expert mode: use datetime-local inputs. We will split them into date and time
63
+ const sd_raw = document.getElementById('start_dt').value; // YYYY-MM-DDTHH:MM
64
+ const ed_raw = document.getElementById('end_dt').value || sd_raw;
65
+ if (!sd_raw) {
66
+ alert('Please provide a start date/time.');
67
+ return;
18
68
  }
19
- };
69
+ const [sd_date, sd_time] = sd_raw.split('T');
70
+ const [ed_date, ed_time] = ed_raw.split('T');
71
+ const qs = `?expert=1&start_time=${encodeURIComponent(sd_time)}&end_time=${encodeURIComponent(ed_time)}`;
72
+ window.location.href = '/' + user + '/' + encodeURIComponent(sd_date) + '/' + encodeURIComponent(ed_date) + qs;
73
+ }
74
+
75
+ window.addEventListener('DOMContentLoaded', initForm);
20
76
  </script>
21
77
  </head>
22
78
  <body>
23
79
  <div class="container">
24
80
  <h2><a href="/">What Did You Do?</a></h2>
25
- <form method="get" action="" onsubmit="event.preventDefault(); window.location.href='/' + encodeURIComponent(document.getElementById('user').value) + '/' + encodeURIComponent(document.getElementById('date').value);">
26
- <label for="user">User:</label>
81
+ <form id="mainForm" method="get" action="" onsubmit="event.preventDefault(); submitForm();">
82
+ <label for="user" class="hidden">User:</label>
27
83
  <input type="text" id="user" name="user" value="{{ user or '' }}" placeholder="insert OSM username" required>
28
84
 
29
- <label for="date">Date:</label>
30
- <input type="date" id="date" name="date" value="{{ date or '' }}" required>
85
+ <!-- Simple date (default) -->
86
+ <div id="simpleDateRow">
87
+ <label for="single_date" class="hidden">Date:</label>
88
+ <input type="date" id="single_date" name="single_date" value="{{ start_date or '' }}">
89
+ </div>
90
+
91
+ <!-- Expert controls (hidden by default) -->
92
+ <div id="expertControls" style="display: none; margin-top: 0.5em;">
93
+ <div class="expert-row">
94
+ <label for="start_dt">Start:</label>
95
+ <input type="datetime-local" id="start_dt" name="start_dt"
96
+ value="{{ (start_date ~ 'T' ~ start_time) if start_date else '' }}">
97
+ </div>
98
+ <div class="expert-row">
99
+ <label for="end_dt">End:</label>
100
+ <input type="datetime-local" id="end_dt" name="end_dt"
101
+ value="{{ ((end_date or start_date) ~ 'T' ~ (end_time or '23:59')) if (end_date or start_date) else '' }}">
102
+ </div>
103
+ </div>
104
+
105
+ <div class="expert-toggle">
106
+ <input type="checkbox" id="expert" name="expert"
107
+ value="1" {{ 'checked' if expert else '' }}>
108
+ <label for="expert">Expert mode</label>
109
+ </div>
31
110
 
32
111
  <button type="submit">Show</button>
33
112
  </form>
34
- {% if user and date %}
113
+ {% if user %}
35
114
  {% for error in errors %}
36
115
  <p class=error>{{ error }}</p>
37
116
  {% endfor %}
@@ -42,10 +121,10 @@
42
121
  {% set ns.changesets = ns.changesets + Change.changesets %}
43
122
  {% endfor %}
44
123
  <ul>
45
- <p><a href="https://www.openstreetmap.org/user/{{ user }}">{{ name }}</a>
46
- did {{ ns.changes }} change{{ "s" if ns.changes > 1 else "" }}
124
+ <p><b><a href="https://www.openstreetmap.org/user/{{ name }}">{{ name }}</a></b>
125
+ did <b>{{ ns.changes }} change{{ "s" if ns.changes > 1 else "" }}</b>
47
126
  in {{ ns.changesets }} changeset{{ "s" if ns.changesets > 1 else "" }}
48
- on {{ date }}:</p>
127
+ {{ date_str }}:</p>
49
128
  {% for app, Change in apps.items() %}
50
129
  <li>{{ Change.changes }} change{{ "s" if Change.changes > 1 else "" }} in
51
130
  {{ Change.changesets }} changeset{{ "s" if Change.changesets > 1 else "" }}
@@ -89,7 +168,46 @@
89
168
  </ul>
90
169
  </div>
91
170
  {% endif %}
92
- <p class="footer">Powered by <a href="https://github.com/rompe/whatdidyoudo">whatdidyoudo {{ version }}</a></p>
171
+ {% if changeset_ids and changeset_ids|length > 0 %}
172
+ <div id="map" style="height: 300px; margin-top: 2em; border-radius: 12px; overflow: hidden;"></div>
173
+ <link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
174
+ <script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
175
+ <script>
176
+ const changesetIds = JSON.parse('{{ changeset_ids|tojson|safe }}');
177
+ if (changesetIds.length > 0) {
178
+ const bounds = [];
179
+ Promise.all(
180
+ changesetIds.map(id =>
181
+ fetch(`https://api.openstreetmap.org/api/0.6/changeset/${id}`)
182
+ .then(r => r.text())
183
+ .then(xml => {
184
+ const parser = new DOMParser();
185
+ const doc = parser.parseFromString(xml, 'application/xml');
186
+ const cs = doc.querySelector('changeset');
187
+ if (cs) {
188
+ bounds.push([
189
+ [parseFloat(cs.getAttribute('min_lat')), parseFloat(cs.getAttribute('min_lon'))],
190
+ [parseFloat(cs.getAttribute('max_lat')), parseFloat(cs.getAttribute('max_lon'))]
191
+ ]);
192
+ }
193
+ })
194
+ )
195
+ ).then(() => {
196
+ if (bounds.length > 0) {
197
+ const map = L.map('map').setView([0, 0], 2);
198
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
199
+ maxZoom: 19,
200
+ attribution: '© OpenStreetMap contributors'
201
+ }).addTo(map);
202
+ const allBounds = L.latLngBounds(bounds.flat());
203
+ map.fitBounds(allBounds);
204
+ bounds.forEach(b => L.rectangle(b, {color: '#4f8cff', weight: 2}).addTo(map));
205
+ }
206
+ });
207
+ }
208
+ </script>
209
+ {% endif %}
210
+ <p class="footer">Powered by <a href="https://github.com/rompe/whatdidyoudo">whatdidyoudo {{ version }}</a></p>
93
211
  </div>
94
212
  </body>
95
213
  </html>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whatdidyoudo
3
- Version: 0.1.13
3
+ Version: 0.2.4
4
4
  Summary: A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day.
5
5
  Project-URL: Homepage, https://github.com/rompe/whatdidyoudo
6
6
  Project-URL: Issues, https://github.com/rompe/whatdidyoudo/issues
@@ -0,0 +1,8 @@
1
+ whatdidyoudo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ whatdidyoudo/app.py,sha256=Y3gOvH1AxDGJJnqNCdSO2Y7QqbHwb9GUpnrP1H1ua3U,5895
3
+ whatdidyoudo/static/favicon.svg,sha256=I3-FiTtYeApxQ40hYyvtQfF38lbTSMA7C4BE-P4XpsY,421
4
+ whatdidyoudo/static/style.css,sha256=I6FjoX_C0ZY9x9TYPW7qs01Ibzy8sEXScxqZyEbOi2M,4047
5
+ whatdidyoudo/templates/form.html,sha256=o_wxJQ9vSFoKkita6Tn-fNviyWZezqpwwRpAPhtTXZs,10467
6
+ whatdidyoudo-0.2.4.dist-info/METADATA,sha256=9mn0MHImaELSkI8lFC2GZ5xbufRgg0jvS36aD5VgkFI,2510
7
+ whatdidyoudo-0.2.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
+ whatdidyoudo-0.2.4.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- whatdidyoudo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
- whatdidyoudo/app.py,sha256=RSUGS75mXeEdcVK8rHm8Mqj_E7700Xbp4_1SzJ4j4N4,3722
3
- whatdidyoudo/static/favicon.svg,sha256=I3-FiTtYeApxQ40hYyvtQfF38lbTSMA7C4BE-P4XpsY,421
4
- whatdidyoudo/static/style.css,sha256=xXmwH9Rc9jhGSCWKI4i_-hNOoowzN6xW5B8ixOc9xhk,2436
5
- whatdidyoudo/templates/form.html,sha256=xplKUN4q7AH5ukhy9jnmd2W7vc7k5J0SBGkCP2rd3m8,4644
6
- whatdidyoudo-0.1.13.dist-info/METADATA,sha256=VMqu-8NnCtemgdNDrYU0gGb3vW_heWKKGTm2MwdTw98,2511
7
- whatdidyoudo-0.1.13.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
8
- whatdidyoudo-0.1.13.dist-info/RECORD,,