whatdidyoudo 0.2.5__tar.gz

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.

@@ -0,0 +1,134 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+ MANIFEST
26
+
27
+ # PyInstaller
28
+ # Usually these files are written by a python script from a template
29
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
30
+ *.manifest
31
+ *.spec
32
+
33
+ # Installer logs
34
+ debug.log
35
+ pip-log.txt
36
+ pip-delete-this-directory.txt
37
+
38
+ # Unit test / coverage reports
39
+ htmlcov/
40
+ .tox/
41
+ .nox/
42
+ .coverage
43
+ .coverage.*
44
+ .cache
45
+ nosetests.xml
46
+ coverage.xml
47
+ *.cover
48
+ .hypothesis/
49
+ .pytest_cache/
50
+
51
+ # Translations
52
+ *.mo
53
+ *.pot
54
+
55
+ # Django stuff:
56
+ *.log
57
+ local_settings.py
58
+ db.sqlite3
59
+ db.sqlite3-journal
60
+
61
+ # Flask stuff:
62
+ instance/
63
+ .webassets-cache
64
+
65
+ # Scrapy stuff:
66
+ .scrapy
67
+
68
+ # Sphinx documentation
69
+ docs/_build/
70
+
71
+ # PyBuilder
72
+ .target/
73
+
74
+ # Jupyter Notebook
75
+ .ipynb_checkpoints
76
+
77
+ # IPython
78
+ profile_default/
79
+ ipython_config.py
80
+
81
+ # pyenv
82
+ .python-version
83
+
84
+ # pipenv
85
+ Pipfile.lock
86
+
87
+ # poetry
88
+ poetry.lock
89
+
90
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
91
+ __pypackages__/
92
+
93
+ # Celery stuff
94
+ celerybeat-schedule
95
+ celerybeat.pid
96
+
97
+ # SageMath parsed files
98
+ *.sage.py
99
+
100
+ # Environments
101
+ .env
102
+ .venv
103
+ env/
104
+ venv/
105
+ ENV/
106
+ env.bak/
107
+ venv.bak/
108
+
109
+ # Spyder project settings
110
+ .spyderproject
111
+ .spyproject
112
+
113
+ # Rope project settings
114
+ .ropeproject
115
+
116
+ # mkdocs documentation
117
+ /site
118
+
119
+ # mypy
120
+ .mypy_cache/
121
+ .dmypy.json
122
+ .dmypy-working/
123
+
124
+ # Pyre type checker
125
+ .pyre/
126
+
127
+ # pytype static type analyzer
128
+ .pytype/
129
+
130
+ # Cython debug symbols
131
+ cython_debug/
132
+
133
+ # VS Code
134
+ .vscode/
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: whatdidyoudo
3
+ Version: 0.2.5
4
+ Summary: A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day.
5
+ Project-URL: Homepage, https://github.com/rompe/whatdidyoudo
6
+ Project-URL: Issues, https://github.com/rompe/whatdidyoudo/issues
7
+ Author-email: Ulf Rompe <whatdidyoudo.rompe.org@rompe.org>
8
+ License: MIT
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: flask-caching==2.3.1
11
+ Requires-Dist: flask-limiter>=4.0.0
12
+ Requires-Dist: flask>=3.0.3
13
+ Requires-Dist: requests>=2.32.4
14
+ Provides-Extra: dev
15
+ Requires-Dist: hatchling>=1.27.0; extra == 'dev'
16
+ Requires-Dist: pytest>=9.0.0; extra == 'dev'
17
+ Requires-Dist: ruff>=0.13.2; extra == 'dev'
18
+ Requires-Dist: twine>=6.2.0; extra == 'dev'
19
+ Requires-Dist: types-requests>=2.32.4; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
22
+ # whatdidyoudo
23
+
24
+ A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day.
25
+
26
+ ## Background
27
+
28
+ I often ask myself after contributing many changes to [OpenStreetMap](https://www.openstreetmap.org/),
29
+ either by walking around while extensively using [StreetComplete](https://streetcomplete.app/),
30
+ [MapComplete](https://mapcomplete.org/) or [Vespucci](https://vespucci.io/), or by doing some tasks
31
+ in [iD](https://www.openstreetmap.org/edit) or [JOSM](https://josm.openstreetmap.de/):
32
+
33
+ ***How many changes did I contribute to the map today?***
34
+
35
+ I'm not the only one. I heard questions like this quite a few times:
36
+
37
+ ***Where can I see how much I did on yesterday's mapwalk?***
38
+
39
+ Because I think that simple questions deserve simple answers, I made this tool to give exactly
40
+ this information and nothing else.
41
+
42
+ You don't need to self-host it, it is available for anyone at
43
+ [whatdidyoudo.rompe.org](https://whatdidyoudo.rompe.org).
44
+
45
+ ## Setup
46
+
47
+ Fun fact: of course you don't really need *uv* for this. I'm just using this project to
48
+ get used to it as I think it has a lot of potential.
49
+
50
+ ### Install [uv](https://github.com/astral-sh/uv) if needed
51
+
52
+ ```sh
53
+ pip install uv
54
+ ```
55
+
56
+ ### Install dependencies using *uv*
57
+
58
+ ```sh
59
+ uv pip install -r pyproject.toml
60
+ ```
61
+
62
+ If you want to develop:
63
+
64
+ ```sh
65
+ uv pip install -r pyproject.toml --extra dev
66
+ ```
67
+
68
+ ### Run tests
69
+
70
+ ```sh
71
+ pytest
72
+ ```
73
+
74
+ ### Run the app in test mode
75
+
76
+ ```sh
77
+ python whatdidyoudo/app.py
78
+ ```
79
+
80
+ Visit [http://127.0.0.1:5000/](http://127.0.0.1:5000/) in your browser to see "hello world".
81
+
82
+ ### Build a package and upload it to Pypi
83
+
84
+ ```sh
85
+ uvx hatchling build
86
+ uvx twine upload dist/*
87
+ ```
88
+
89
+ ## License
90
+
91
+ This project is licensed under the MIT License. See the `pyproject.toml` for details.
@@ -0,0 +1,70 @@
1
+ # whatdidyoudo
2
+
3
+ A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day.
4
+
5
+ ## Background
6
+
7
+ I often ask myself after contributing many changes to [OpenStreetMap](https://www.openstreetmap.org/),
8
+ either by walking around while extensively using [StreetComplete](https://streetcomplete.app/),
9
+ [MapComplete](https://mapcomplete.org/) or [Vespucci](https://vespucci.io/), or by doing some tasks
10
+ in [iD](https://www.openstreetmap.org/edit) or [JOSM](https://josm.openstreetmap.de/):
11
+
12
+ ***How many changes did I contribute to the map today?***
13
+
14
+ I'm not the only one. I heard questions like this quite a few times:
15
+
16
+ ***Where can I see how much I did on yesterday's mapwalk?***
17
+
18
+ Because I think that simple questions deserve simple answers, I made this tool to give exactly
19
+ this information and nothing else.
20
+
21
+ You don't need to self-host it, it is available for anyone at
22
+ [whatdidyoudo.rompe.org](https://whatdidyoudo.rompe.org).
23
+
24
+ ## Setup
25
+
26
+ Fun fact: of course you don't really need *uv* for this. I'm just using this project to
27
+ get used to it as I think it has a lot of potential.
28
+
29
+ ### Install [uv](https://github.com/astral-sh/uv) if needed
30
+
31
+ ```sh
32
+ pip install uv
33
+ ```
34
+
35
+ ### Install dependencies using *uv*
36
+
37
+ ```sh
38
+ uv pip install -r pyproject.toml
39
+ ```
40
+
41
+ If you want to develop:
42
+
43
+ ```sh
44
+ uv pip install -r pyproject.toml --extra dev
45
+ ```
46
+
47
+ ### Run tests
48
+
49
+ ```sh
50
+ pytest
51
+ ```
52
+
53
+ ### Run the app in test mode
54
+
55
+ ```sh
56
+ python whatdidyoudo/app.py
57
+ ```
58
+
59
+ Visit [http://127.0.0.1:5000/](http://127.0.0.1:5000/) in your browser to see "hello world".
60
+
61
+ ### Build a package and upload it to Pypi
62
+
63
+ ```sh
64
+ uvx hatchling build
65
+ uvx twine upload dist/*
66
+ ```
67
+
68
+ ## License
69
+
70
+ This project is licensed under the MIT License. See the `pyproject.toml` for details.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [tool.hatch.build]
6
+ packages = ["whatdidyoudo"]
7
+ [tool.hatch.version]
8
+ source = "code"
9
+ path = "whatdidyoudo/__init__.py"
10
+
11
+ [project]
12
+ name = "whatdidyoudo"
13
+ dynamic = ["version"]
14
+ description = "A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day."
15
+ authors = [
16
+ { name = "Ulf Rompe", email = "whatdidyoudo.rompe.org@rompe.org" }
17
+ ]
18
+ license = { text = "MIT" }
19
+ readme = "README.md"
20
+ requires-python = ">=3.11"
21
+ dependencies = [
22
+ "Flask>=3.0.3",
23
+ "flask-caching==2.3.1",
24
+ "flask-limiter>=4.0.0",
25
+ "requests>=2.32.4",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "Ruff>=0.13.2",
31
+ "types-requests>=2.32.4",
32
+ "hatchling>=1.27.0",
33
+ "twine>=6.2.0",
34
+ "pytest>=9.0.0",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://github.com/rompe/whatdidyoudo"
39
+ Issues = "https://github.com/rompe/whatdidyoudo/issues"
40
+
41
+
42
+ [tool.uv]
43
+ # uv-specific settings go here
44
+
45
+ [tool.ruff]
46
+ line-length = 100
47
+ target-version = "py311"
@@ -0,0 +1,4 @@
1
+ """
2
+ The whatdidyoudo package.
3
+ """
4
+ __version__ = "0.2.5"
@@ -0,0 +1,147 @@
1
+ """A Flask app that shows OSM tasks done by a user on a specific day."""
2
+ import datetime
3
+ import xml.etree.ElementTree as ET
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass
6
+ import requests
7
+
8
+ from flask import Flask, render_template, request
9
+ from flask_caching import Cache
10
+ from flask_limiter import Limiter, RateLimitExceeded
11
+ from flask_limiter.util import get_remote_address
12
+
13
+ from whatdidyoudo import __version__
14
+
15
+ app = Flask(__name__)
16
+ cache = Cache(app, config={"CACHE_TYPE": "SimpleCache",
17
+ "CACHE_DEFAULT_TIMEOUT": 60 * 60 * 24 * 7})
18
+ limiter = Limiter(app=app, key_func=get_remote_address)
19
+
20
+
21
+ @dataclass
22
+ class Changes:
23
+ """Represent changes made by a user."""
24
+ changes: int = 0
25
+ changesets: int = 0
26
+
27
+
28
+ def get_etree_from_url(url: str) -> ET.Element:
29
+ """Fetches XML content from a URL and returns the root Element."""
30
+ response = requests.get(url, timeout=120)
31
+ response.raise_for_status() # Raise an error for bad responses
32
+ return ET.fromstring(response.content)
33
+
34
+
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
+ """
43
+ changes: defaultdict[str, Changes] = defaultdict(Changes)
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"
51
+
52
+ changeset_url = ("https://api.openstreetmap.org/api/0.6/changesets?"
53
+ f"display_name={user}&"
54
+ f"time={start_time_iso},{end_time_iso}")
55
+ root = get_etree_from_url(url=changeset_url)
56
+
57
+ for cs in root.findall("changeset"):
58
+ cs_id = cs.attrib["id"]
59
+ changeset_ids.append(cs_id)
60
+
61
+ tags = {tag.attrib["k"]: tag.attrib["v"] for tag in cs.findall("tag")}
62
+ editor = tags.get("created_by", "")
63
+ changes[editor].changesets += 1
64
+
65
+ diff_url = ("https://api.openstreetmap.org/api/0.6/changeset/"
66
+ f"{cs_id}/download")
67
+ try:
68
+ root = get_etree_from_url(url=diff_url)
69
+ except requests.HTTPError:
70
+ continue
71
+
72
+ for action in root:
73
+ changes[editor].changes += len(action)
74
+ return changes, changeset_ids
75
+
76
+
77
+ @app.route('/')
78
+ @app.route('/<user>/')
79
+ @app.route('/<user>')
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
+ """
86
+ Show OSM tasks done by a user within a date/time range.
87
+
88
+ Expert mode allows finer-grained start/end times via query params:
89
+ expert=1, start_time=HH:MM, end_time=HH:MM
90
+ """
91
+ changes: defaultdict[str, defaultdict[str, Changes]] = \
92
+ defaultdict(lambda: defaultdict(Changes))
93
+ changesets: dict[str, int] = {}
94
+ errors: list[str] = []
95
+ # Read expert mode and time params from query string
96
+ expert = request.args.get('expert', '0') in ('1', 'true', 'True')
97
+ start_time = request.args.get('start_time', '00:00')
98
+ end_time = request.args.get('end_time', '23:59')
99
+
100
+ today = datetime.date.today().isoformat()
101
+ date_str = (f"between {start_date} {start_time} and {end_date} {end_time}"
102
+ if end_date or expert else f"on {start_date or today}")
103
+
104
+ changeset_ids: list[str] = []
105
+ for name in [item.strip() for item in (user or "").split(",")
106
+ if item.strip()]:
107
+ cache_key = (f"changes_{name}_{start_date}_{end_date}_"
108
+ f"{start_time}_{end_time}")
109
+ cur_data = cache.get(cache_key) # type: ignore
110
+ if not cur_data:
111
+ try:
112
+ with limiter.limit("10 per minute"):
113
+ cur_data = get_changes(user=name,
114
+ start_date=start_date or today,
115
+ end_date=end_date,
116
+ start_time=start_time,
117
+ end_time=end_time)
118
+ # Only cache results when the range does not include today
119
+ if ((start_date and start_date != today) or
120
+ (end_date and end_date != today)):
121
+ cache.set(cache_key, cur_data) # type: ignore
122
+ except requests.HTTPError:
123
+ errors.append(
124
+ f"Can't determine changes for user {name} {date_str}.")
125
+ except RateLimitExceeded as msg:
126
+ errors.append("Rate limit exceeded while processing user "
127
+ f"{name}: {msg}")
128
+ if cur_data:
129
+ cur_changes, cur_ids = cur_data
130
+ changes[name] = cur_changes
131
+ changeset_ids.extend(cur_ids)
132
+
133
+ return render_template('form.html', user=user, start_date=start_date,
134
+ end_date=end_date, start_time=start_time,
135
+ end_time=end_time, expert=expert,
136
+ changes=changes, changesets=changesets,
137
+ errors=errors, date_str=date_str,
138
+ version=__version__, changeset_ids=changeset_ids)
139
+
140
+
141
+ def main():
142
+ """Run in debug mode."""
143
+ app.run(host="0.0.0.0", debug=True)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
2
+ <rect width="64" height="64" rx="12" fill="#232526"/>
3
+ <text x="50%" y="44%" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="20" font-weight="bold" fill="#4f8cff">WD</text>
4
+ <text x="50%" y="72%" text-anchor="middle" font-family="Segoe UI, Arial, sans-serif" font-size="20" font-weight="bold" fill="#4f8cff">YD</text>
5
+ </svg>
@@ -0,0 +1,192 @@
1
+ body {
2
+ font-family: 'Segoe UI', Arial, sans-serif;
3
+ background: linear-gradient(120deg, #232526 0%, #414345 100%);
4
+ color: #f3f3f3;
5
+ min-height: 100vh;
6
+ margin: 0;
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: center;
10
+ justify-content: center;
11
+ }
12
+
13
+ .container {
14
+ background: rgba(30, 30, 30, 0.92);
15
+ border-radius: 16px;
16
+ box-shadow: 0 4px 24px rgba(0,0,0,0.3);
17
+ padding: 2em 2.5em 1.5em 2.5em;
18
+ margin-top: 2em;
19
+ max-width: 800px;
20
+ width: 95%;
21
+ }
22
+
23
+ .error {
24
+ background: #ff4d4f;
25
+ color: #fff;
26
+ padding: 0.7em 1em;
27
+ border-radius: 8px;
28
+ margin-bottom: 1em;
29
+ font-weight: 600;
30
+ }
31
+
32
+ form {
33
+ display: flex;
34
+ flex-direction: row;
35
+ /* align-items: flex-end; */
36
+ gap: 1em;
37
+ width: 100%;
38
+ }
39
+
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 {
88
+ display: none;
89
+ }
90
+
91
+ input[type="text"],
92
+ input[type="date"],
93
+ input[type="datetime-local"],
94
+ input[type="checkbox"],
95
+ button {
96
+ border: none;
97
+ border-radius: 8px;
98
+ box-sizing: border-box;
99
+ font-family: 'Segoe UI', Arial, sans-serif;
100
+ font-size: 1.1em;
101
+ font-weight: 600;
102
+ /* min-height: 3em; */
103
+ outline: none;
104
+ }
105
+
106
+ input[type="text"] {
107
+ /* flex: 2 1 200px; */
108
+ padding: 0.7em 1em;
109
+ background: #2c2f34;
110
+ color: #f3f3f3;
111
+ }
112
+
113
+ input[type="datetime-local"],
114
+ input[type="date"] {
115
+ /* flex: 1 1 120px; */
116
+ padding: 0.7em 0.5em;
117
+ background: #2c2f34;
118
+ color: #f3f3f3;
119
+ }
120
+
121
+ input[type="date"] {
122
+ min-width: 120px;
123
+ max-width: 160px;
124
+ }
125
+
126
+ input[type="datetime-local"] {
127
+ min-width: 120px;
128
+ max-width: 260px;
129
+ }
130
+
131
+
132
+ button {
133
+ padding: 0.7em 1.5em;
134
+ background: #4f8cff;
135
+ color: #fff;
136
+ cursor: pointer;
137
+ transition: background 0.2s;
138
+ }
139
+ button:hover {
140
+ background: #2563eb;
141
+ }
142
+
143
+ h2 a {
144
+ color: #f3f3f3;
145
+ text-decoration: none;
146
+ font-weight: 700;
147
+ letter-spacing: 1px;
148
+ }
149
+ p a {
150
+ color: #4f8cff;
151
+ text-decoration: none;
152
+ }
153
+ a:hover {
154
+ color: #2563eb;
155
+ text-decoration: underline;
156
+ }
157
+
158
+ .note {
159
+ font-size: 0.9em;
160
+ color: #aaa;
161
+ margin-top: 1.5em;
162
+ }
163
+ .disclaimer {
164
+ font-size: 0.9em;
165
+ color: #777;
166
+ margin-top: 1.5em;
167
+ }
168
+
169
+ .footer {
170
+ text-align: center;
171
+ font-size: 0.85em;
172
+ color: #b0b0b0;
173
+ margin-top: 2em;
174
+ margin-bottom: 0.5em;
175
+ letter-spacing: 0.5px;
176
+ }
177
+
178
+ @media (max-width: 600px) {
179
+ .container {
180
+ padding: 1em 0.5em;
181
+ }
182
+ form {
183
+ flex-direction: column;
184
+ gap: 0.7em;
185
+ }
186
+ input[type="date"],
187
+ input[type="datetime-local"] {
188
+ width: 100%;
189
+ min-width: unset;
190
+ max-width: unset;
191
+ }
192
+ }
@@ -0,0 +1,213 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>What Did You Do?</title>
7
+ <link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
8
+ <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
9
+ <script>
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;
68
+ }
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);
76
+ </script>
77
+ </head>
78
+ <body>
79
+ <div class="container">
80
+ <h2><a href="/">What Did You Do?</a></h2>
81
+ <form id="mainForm" method="get" action="" onsubmit="event.preventDefault(); submitForm();">
82
+ <label for="user" class="hidden">User:</label>
83
+ <input type="text" id="user" name="user" value="{{ user or '' }}" placeholder="insert OSM username" required>
84
+
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>
110
+
111
+ <button type="submit">Show</button>
112
+ </form>
113
+ {% if user %}
114
+ {% for error in errors %}
115
+ <p class=error>{{ error }}</p>
116
+ {% endfor %}
117
+ {% for name, apps in changes.items() %}
118
+ {% set ns = namespace(changes=0, changesets=0) %}
119
+ {% for Change in apps.values() %}
120
+ {% set ns.changes = ns.changes + Change.changes %}
121
+ {% set ns.changesets = ns.changesets + Change.changesets %}
122
+ {% endfor %}
123
+ <ul>
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>
126
+ in {{ ns.changesets }} changeset{{ "s" if ns.changesets > 1 else "" }}
127
+ {{ date_str }}:</p>
128
+ {% for app, Change in apps.items() %}
129
+ <li>{{ Change.changes }} change{{ "s" if Change.changes > 1 else "" }} in
130
+ {{ Change.changesets }} changeset{{ "s" if Change.changesets > 1 else "" }}
131
+ with {{ app }}</li>
132
+ {% endfor %}
133
+ </ul>
134
+ {% endfor %}
135
+ {% else %}
136
+ <p>
137
+ How much did you contribute to <a href="https://www.openstreetmap.org/">OpenStreetMap</a> today? Or any other day?
138
+ </p>
139
+ <p>
140
+ To answer this question, enter your OSM username and the date you want to check above and click "Show".
141
+ </p>
142
+ <div class="note">
143
+ <h4>Tips</h4>
144
+ <ul>
145
+ <li>Do you want to have a bookmark that always shows your numbers for the current day?
146
+ Just use <code>/YOUR_USERNAME</code> as URL and omit the date!
147
+ </li>
148
+ <li>You can enter multiple usernames separated by commas to see their contributions at once.
149
+ Try this after a mapwalk with friends!
150
+ </li>
151
+ </ul>
152
+ </div>
153
+ <div class="disclaimer">
154
+ <h4>Disclaimers</h4>
155
+ <ul>
156
+ <li>Quality beats quantity. While it's nice to see big numbers here, please remember
157
+ that just one high-effort change will help more that any amount of mediocre changes.
158
+ </li>
159
+ <li>This service is rate-limited to 10 OSM API requests per minute and caches results
160
+ for 1 hour (except for the current day) to reduce load on the API.
161
+ Too restrictive? Tell me!
162
+ </li>
163
+ <li>This is a very simple service. It does exactly one job and tries to do it good.
164
+ The data displayed here is shown by many other services, but I didn't find one
165
+ that answers my question in this way with just a simple bookmark.
166
+ Suggestions are welcome! Get in touch via the issue tracker on the project page.
167
+ </li>
168
+ </ul>
169
+ </div>
170
+ {% endif %}
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>
211
+ </div>
212
+ </body>
213
+ </html>