aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2024-11-28 21:28:51 +0100
committerDaniel Schadt <kingdread@gmx.de>2024-11-28 21:28:51 +0100
commit880ba034082ebc288066f3c1ac54d8d5a3550123 (patch)
tree49f6e8f1be0ab3b78e0d4a7c4c852be1097da0f6
parent5866345dd92ffa5c0b41b5b13f16f1900075f4a6 (diff)
parente82233c57ba2a76dd2fbe10cec8effceac2a3e75 (diff)
downloadfietsboek-880ba034082ebc288066f3c1ac54d8d5a3550123.tar.gz
fietsboek-880ba034082ebc288066f3c1ac54d8d5a3550123.tar.bz2
fietsboek-880ba034082ebc288066f3c1ac54d8d5a3550123.zip
Merge branch 'calendar'
-rw-r--r--asset-sources/theme.scss76
-rw-r--r--fietsboek/__init__.py1
-rw-r--r--fietsboek/jinja2.py24
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin15785 -> 15962 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po54
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin14754 -> 14924 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po54
-rw-r--r--fietsboek/locale/fietslog.pot54
-rw-r--r--fietsboek/routes.py5
-rw-r--r--fietsboek/static/theme.css71
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/templates/profile.jinja260
-rw-r--r--fietsboek/util.py14
-rw-r--r--fietsboek/views/profile.py180
-rw-r--r--tests/unit/views/test_profile.py21
15 files changed, 531 insertions, 85 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss
index 25353d7..35a3df1 100644
--- a/asset-sources/theme.scss
+++ b/asset-sources/theme.scss
@@ -158,6 +158,82 @@ strong {
text-align: right;
}
+.calendar-title {
+ text-align: center;
+}
+.calendar-controls {
+ text-align: center;
+ margin-bottom: 50px;
+}
+.profile-calendar {
+ width: 100%;
+
+ thead {
+ text-align: center;
+ font-size: 120%;
+ }
+
+ .calendar-cell, .calendar-cell-empty {
+ height: 150px;
+ width: calc(100%/7);
+ }
+
+ .calendar-cell {
+ border: 1px solid gray;
+ vertical-align: top;
+
+ .calendar-date {
+ font-size: 140%;
+ font-weight: bold;
+ }
+
+ ul {
+ list-style: none;
+ text-align: center;
+ }
+ }
+
+ .cell-length-0 {
+ color: gray;
+ }
+ .cell-length-1 {
+ background-color: #8FF0A4;
+ ul {
+ font-size: 110%;
+ }
+ }
+ .cell-length-2 {
+ background-color: #75DD95;
+ ul {
+ font-size: 120%;
+ }
+ }
+ .cell-length-3 {
+ background-color: #5BC987;
+ ul {
+ font-size: 130%;
+ }
+ }
+ .cell-length-4 {
+ background-color: #40B678;
+ ul {
+ font-size: 140%;
+ }
+ }
+ .cell-length-5 {
+ color: white;
+ background-color: #26A269;
+ ul {
+ font-size: 150%;
+ }
+ }
+
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+}
+
/* Ensure a consistent width of the cells in the browse view. */
.browse-summary th, .browse-summary td {
width: 25%;
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index a5f0042..bc93408 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -179,6 +179,7 @@ def main(global_config, **settings):
jinja2_env = config.get_jinja2_environment()
jinja2_env.filters["format_decimal"] = mod_jinja2.filter_format_decimal
jinja2_env.filters["format_datetime"] = mod_jinja2.filter_format_datetime
+ jinja2_env.filters["format_date"] = mod_jinja2.filter_format_date
jinja2_env.filters["local_datetime"] = mod_jinja2.filter_local_datetime
jinja2_env.globals["embed_tile_layers"] = mod_jinja2.global_embed_tile_layers
jinja2_env.globals["list_languages"] = mod_jinja2.global_list_languages
diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py
index cb17742..5106c38 100644
--- a/fietsboek/jinja2.py
+++ b/fietsboek/jinja2.py
@@ -4,7 +4,7 @@ import datetime
import json
import jinja2
-from babel.dates import format_datetime
+from babel.dates import format_date, format_datetime
from babel.numbers import format_decimal
from jinja2.runtime import Context
from markupsafe import Markup
@@ -29,16 +29,34 @@ def filter_format_decimal(ctx: Context, value: float) -> str:
@jinja2.pass_context
-def filter_format_datetime(ctx: Context, value: datetime.datetime) -> str:
+def filter_format_datetime(ctx: Context, value: datetime.datetime, format: str="medium") -> str:
"""Format a datetime according to the locale.
:param ctx: The jinja context, passed automatically.
:param value: The value to format.
+ :param format: The format string, see https://babel.pocoo.org/en/latest/dates.html.
:return: The formatted date.
"""
+ # We redefine format because it's the same name as used by babel
+ # pylint: disable=redefined-builtin
request = ctx.get("request")
locale = request.localizer.locale_name
- return format_datetime(value, locale=locale)
+ return format_datetime(value, format=format, locale=locale)
+
+
+@jinja2.pass_context
+def filter_format_date(ctx: Context, value: datetime.date, format: str="medium") -> str:
+ """Format a date according to the locale.
+
+ :param ctx: The jinja context, passed automatically.
+ :param value: The value to format.
+ :param format: The format string, see https://babel.pocoo.org/en/latest/dates.html.
+ :return: The formatted date.
+ """
+ # pylint: disable=redefined-builtin
+ request = ctx.get("request")
+ locale = request.localizer.locale_name
+ return format_date(value, format=format, locale=locale)
@jinja2.pass_context
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index c2fd61b..204c8c5 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po
index 98f05e7..7c497de 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2024-04-17 21:42+0200\n"
+"POT-Creation-Date: 2024-11-16 23:44+0100\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.14.0\n"
+"Generated-By: Babel 2.15.0\n"
#: fietsboek/actions.py:267
msgid "email.verify_mail.subject"
@@ -29,11 +29,11 @@ msgstr ""
"\n"
"Falls Du kein Konto angelegt hast, ignoriere diese E-Mail."
-#: fietsboek/util.py:310
+#: fietsboek/util.py:324
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
-#: fietsboek/util.py:312
+#: fietsboek/util.py:326
msgid "password_constraint.length"
msgstr "Passwort zu kurz"
@@ -646,71 +646,83 @@ msgstr "Übersicht"
msgid "page.profile.tabbar.graphs"
msgstr "Diagramme"
-#: fietsboek/templates/profile.jinja2:83
+#: fietsboek/templates/profile.jinja2:74
+msgid "page.profile.tabbar.calendar"
+msgstr "Kalender"
+
+#: fietsboek/templates/profile.jinja2:88
msgid "page.profile.length"
msgstr "Länge"
-#: fietsboek/templates/profile.jinja2:87
+#: fietsboek/templates/profile.jinja2:92
msgid "page.profile.avg_length"
msgstr "durchschnittliche Länge"
-#: fietsboek/templates/profile.jinja2:91
+#: fietsboek/templates/profile.jinja2:96
msgid "page.profile.uphill"
msgstr "Bergauf"
-#: fietsboek/templates/profile.jinja2:95
+#: fietsboek/templates/profile.jinja2:100
msgid "page.profile.downhill"
msgstr "Bergab"
-#: fietsboek/templates/profile.jinja2:99
+#: fietsboek/templates/profile.jinja2:104
msgid "page.profile.moving_time"
msgstr "Fahrzeit"
-#: fietsboek/templates/profile.jinja2:103
+#: fietsboek/templates/profile.jinja2:108
msgid "page.profile.stopped_time"
msgstr "Haltezeit"
-#: fietsboek/templates/profile.jinja2:107
+#: fietsboek/templates/profile.jinja2:112
msgid "page.profile.avg_duration"
msgstr "durchschnittliche Dauer"
-#: fietsboek/templates/profile.jinja2:111
+#: fietsboek/templates/profile.jinja2:116
msgid "page.profile.max_speed"
msgstr "maximale Geschwindigkeit"
-#: fietsboek/templates/profile.jinja2:115
+#: fietsboek/templates/profile.jinja2:120
msgid "page.profile.avg_speed"
msgstr "durchschnittliche Geschwindigkeit"
-#: fietsboek/templates/profile.jinja2:119
+#: fietsboek/templates/profile.jinja2:124
msgid "page.profile.number_of_tracks"
msgstr "Anzahl der Strecken"
-#: fietsboek/templates/profile.jinja2:125
+#: fietsboek/templates/profile.jinja2:130
msgid "page.profile.longest_distance_track"
msgstr "Weiteste Strecke"
-#: fietsboek/templates/profile.jinja2:130
+#: fietsboek/templates/profile.jinja2:135
msgid "page.profile.shortest_distance_track"
msgstr "Kürzeste Strecke"
-#: fietsboek/templates/profile.jinja2:135
+#: fietsboek/templates/profile.jinja2:140
msgid "page.profile.longest_duration_track"
msgstr "Am Längsten Dauernde Strecke"
-#: fietsboek/templates/profile.jinja2:140
+#: fietsboek/templates/profile.jinja2:145
msgid "page.profile.shortest_duration_track"
msgstr "Am Kürzesten Dauernde Strecke"
-#: fietsboek/templates/profile.jinja2:147
+#: fietsboek/templates/profile.jinja2:152
msgid "page.profile.graph.km_per_month"
msgstr "Kilometer pro Monat"
-#: fietsboek/templates/profile.jinja2:171
+#: fietsboek/templates/profile.jinja2:161
+msgid "page.profile.calendar.previous"
+msgstr "Vorheriger Monat"
+
+#: fietsboek/templates/profile.jinja2:163
+msgid "page.profile.calendar.next"
+msgstr "Nächster Monat"
+
+#: fietsboek/templates/profile.jinja2:218
msgid "page.profile.heatmap"
msgstr "Heatmap"
-#: fietsboek/templates/profile.jinja2:176
+#: fietsboek/templates/profile.jinja2:223
msgid "page.profile.tilehunt"
msgstr "Kacheljäger"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index 1390daa..e688c2e 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po
index f4cacb5..d9f61d6 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.po
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2024-04-17 21:42+0200\n"
+"POT-Creation-Date: 2024-11-16 23:44+0100\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.14.0\n"
+"Generated-By: Babel 2.15.0\n"
#: fietsboek/actions.py:267
msgid "email.verify_mail.subject"
@@ -29,11 +29,11 @@ msgstr ""
"\n"
"If you did not create an account, ignore this email."
-#: fietsboek/util.py:310
+#: fietsboek/util.py:324
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
-#: fietsboek/util.py:312
+#: fietsboek/util.py:326
msgid "password_constraint.length"
msgstr "Password not long enough"
@@ -640,71 +640,83 @@ msgstr "Overview"
msgid "page.profile.tabbar.graphs"
msgstr "Graphs"
-#: fietsboek/templates/profile.jinja2:83
+#: fietsboek/templates/profile.jinja2:74
+msgid "page.profile.tabbar.calendar"
+msgstr "Calendar"
+
+#: fietsboek/templates/profile.jinja2:88
msgid "page.profile.length"
msgstr "Length"
-#: fietsboek/templates/profile.jinja2:87
+#: fietsboek/templates/profile.jinja2:92
msgid "page.profile.avg_length"
msgstr "Average Length"
-#: fietsboek/templates/profile.jinja2:91
+#: fietsboek/templates/profile.jinja2:96
msgid "page.profile.uphill"
msgstr "Uphill"
-#: fietsboek/templates/profile.jinja2:95
+#: fietsboek/templates/profile.jinja2:100
msgid "page.profile.downhill"
msgstr "Downhill"
-#: fietsboek/templates/profile.jinja2:99
+#: fietsboek/templates/profile.jinja2:104
msgid "page.profile.moving_time"
msgstr "Moving Time"
-#: fietsboek/templates/profile.jinja2:103
+#: fietsboek/templates/profile.jinja2:108
msgid "page.profile.stopped_time"
msgstr "Stopped Time"
-#: fietsboek/templates/profile.jinja2:107
+#: fietsboek/templates/profile.jinja2:112
msgid "page.profile.avg_duration"
msgstr "Average Duration"
-#: fietsboek/templates/profile.jinja2:111
+#: fietsboek/templates/profile.jinja2:116
msgid "page.profile.max_speed"
msgstr "Max Speed"
-#: fietsboek/templates/profile.jinja2:115
+#: fietsboek/templates/profile.jinja2:120
msgid "page.profile.avg_speed"
msgstr "Average Speed"
-#: fietsboek/templates/profile.jinja2:119
+#: fietsboek/templates/profile.jinja2:124
msgid "page.profile.number_of_tracks"
msgstr "Number of tracks"
-#: fietsboek/templates/profile.jinja2:125
+#: fietsboek/templates/profile.jinja2:130
msgid "page.profile.longest_distance_track"
msgstr "Longest Track"
-#: fietsboek/templates/profile.jinja2:130
+#: fietsboek/templates/profile.jinja2:135
msgid "page.profile.shortest_distance_track"
msgstr "Shortest Track"
-#: fietsboek/templates/profile.jinja2:135
+#: fietsboek/templates/profile.jinja2:140
msgid "page.profile.longest_duration_track"
msgstr "Most Time-Consuming Track"
-#: fietsboek/templates/profile.jinja2:140
+#: fietsboek/templates/profile.jinja2:145
msgid "page.profile.shortest_duration_track"
msgstr "Quickest Track"
-#: fietsboek/templates/profile.jinja2:147
+#: fietsboek/templates/profile.jinja2:152
msgid "page.profile.graph.km_per_month"
msgstr "Kilometers per month"
-#: fietsboek/templates/profile.jinja2:171
+#: fietsboek/templates/profile.jinja2:161
+msgid "page.profile.calendar.previous"
+msgstr "Previous month"
+
+#: fietsboek/templates/profile.jinja2:163
+msgid "page.profile.calendar.next"
+msgstr "Next month"
+
+#: fietsboek/templates/profile.jinja2:218
msgid "page.profile.heatmap"
msgstr "Heat Map"
-#: fietsboek/templates/profile.jinja2:176
+#: fietsboek/templates/profile.jinja2:223
msgid "page.profile.tilehunt"
msgstr "Tilehunt"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index a3dea70..b14f14e 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -8,14 +8,14 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2024-04-17 21:42+0200\n"
+"POT-Creation-Date: 2024-11-16 23:44+0100\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.14.0\n"
+"Generated-By: Babel 2.15.0\n"
#: fietsboek/actions.py:267
msgid "email.verify_mail.subject"
@@ -25,11 +25,11 @@ msgstr ""
msgid "email.verify.text"
msgstr ""
-#: fietsboek/util.py:310
+#: fietsboek/util.py:324
msgid "password_constraint.mismatch"
msgstr ""
-#: fietsboek/util.py:312
+#: fietsboek/util.py:326
msgid "password_constraint.length"
msgstr ""
@@ -634,71 +634,83 @@ msgstr ""
msgid "page.profile.tabbar.graphs"
msgstr ""
-#: fietsboek/templates/profile.jinja2:83
+#: fietsboek/templates/profile.jinja2:74
+msgid "page.profile.tabbar.calendar"
+msgstr ""
+
+#: fietsboek/templates/profile.jinja2:88
msgid "page.profile.length"
msgstr ""
-#: fietsboek/templates/profile.jinja2:87
+#: fietsboek/templates/profile.jinja2:92
msgid "page.profile.avg_length"
msgstr ""
-#: fietsboek/templates/profile.jinja2:91
+#: fietsboek/templates/profile.jinja2:96
msgid "page.profile.uphill"
msgstr ""
-#: fietsboek/templates/profile.jinja2:95
+#: fietsboek/templates/profile.jinja2:100
msgid "page.profile.downhill"
msgstr ""
-#: fietsboek/templates/profile.jinja2:99
+#: fietsboek/templates/profile.jinja2:104
msgid "page.profile.moving_time"
msgstr ""
-#: fietsboek/templates/profile.jinja2:103
+#: fietsboek/templates/profile.jinja2:108
msgid "page.profile.stopped_time"
msgstr ""
-#: fietsboek/templates/profile.jinja2:107
+#: fietsboek/templates/profile.jinja2:112
msgid "page.profile.avg_duration"
msgstr ""
-#: fietsboek/templates/profile.jinja2:111
+#: fietsboek/templates/profile.jinja2:116
msgid "page.profile.max_speed"
msgstr ""
-#: fietsboek/templates/profile.jinja2:115
+#: fietsboek/templates/profile.jinja2:120
msgid "page.profile.avg_speed"
msgstr ""
-#: fietsboek/templates/profile.jinja2:119
+#: fietsboek/templates/profile.jinja2:124
msgid "page.profile.number_of_tracks"
msgstr ""
-#: fietsboek/templates/profile.jinja2:125
+#: fietsboek/templates/profile.jinja2:130
msgid "page.profile.longest_distance_track"
msgstr ""
-#: fietsboek/templates/profile.jinja2:130
+#: fietsboek/templates/profile.jinja2:135
msgid "page.profile.shortest_distance_track"
msgstr ""
-#: fietsboek/templates/profile.jinja2:135
+#: fietsboek/templates/profile.jinja2:140
msgid "page.profile.longest_duration_track"
msgstr ""
-#: fietsboek/templates/profile.jinja2:140
+#: fietsboek/templates/profile.jinja2:145
msgid "page.profile.shortest_duration_track"
msgstr ""
-#: fietsboek/templates/profile.jinja2:147
+#: fietsboek/templates/profile.jinja2:152
msgid "page.profile.graph.km_per_month"
msgstr ""
-#: fietsboek/templates/profile.jinja2:171
+#: fietsboek/templates/profile.jinja2:161
+msgid "page.profile.calendar.previous"
+msgstr ""
+
+#: fietsboek/templates/profile.jinja2:163
+msgid "page.profile.calendar.next"
+msgstr ""
+
+#: fietsboek/templates/profile.jinja2:218
msgid "page.profile.heatmap"
msgstr ""
-#: fietsboek/templates/profile.jinja2:176
+#: fietsboek/templates/profile.jinja2:223
msgid "page.profile.tilehunt"
msgstr ""
diff --git a/fietsboek/routes.py b/fietsboek/routes.py
index 6b18c82..343fcd8 100644
--- a/fietsboek/routes.py
+++ b/fietsboek/routes.py
@@ -65,6 +65,11 @@ def includeme(config):
config.add_route("profile", "/user/{user_id}", factory="fietsboek.models.User.factory")
config.add_route(
+ "user-calendar-ym",
+ "/user/{user_id}/calendar/{year}/{month}",
+ factory="fietsboek.models.User.factory",
+ )
+ config.add_route(
"user-tile",
"/user/{user_id}/tile/{map}/{z:\\d+}/{x:\\d+}/{y:\\d+}",
factory="fietsboek.models.User.factory",
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index a4b0328..755a986 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -151,6 +151,77 @@ strong {
text-align: right;
}
+.calendar-title {
+ text-align: center;
+}
+
+.calendar-controls {
+ text-align: center;
+ margin-bottom: 50px;
+}
+
+.profile-calendar {
+ width: 100%;
+}
+.profile-calendar thead {
+ text-align: center;
+ font-size: 120%;
+}
+.profile-calendar .calendar-cell, .profile-calendar .calendar-cell-empty {
+ height: 150px;
+ width: 14.2857142857%;
+}
+.profile-calendar .calendar-cell {
+ border: 1px solid gray;
+ vertical-align: top;
+}
+.profile-calendar .calendar-cell .calendar-date {
+ font-size: 140%;
+ font-weight: bold;
+}
+.profile-calendar .calendar-cell ul {
+ list-style: none;
+ text-align: center;
+}
+.profile-calendar .cell-length-0 {
+ color: gray;
+}
+.profile-calendar .cell-length-1 {
+ background-color: #8FF0A4;
+}
+.profile-calendar .cell-length-1 ul {
+ font-size: 110%;
+}
+.profile-calendar .cell-length-2 {
+ background-color: #75DD95;
+}
+.profile-calendar .cell-length-2 ul {
+ font-size: 120%;
+}
+.profile-calendar .cell-length-3 {
+ background-color: #5BC987;
+}
+.profile-calendar .cell-length-3 ul {
+ font-size: 130%;
+}
+.profile-calendar .cell-length-4 {
+ background-color: #40B678;
+}
+.profile-calendar .cell-length-4 ul {
+ font-size: 140%;
+}
+.profile-calendar .cell-length-5 {
+ color: white;
+ background-color: #26A269;
+}
+.profile-calendar .cell-length-5 ul {
+ font-size: 150%;
+}
+.profile-calendar a {
+ text-decoration: none;
+ color: inherit;
+}
+
/* Ensure a consistent width of the cells in the browse view. */
.browse-summary th, .browse-summary td {
width: 25%;
diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map
index a1fd09f..8593cd3 100644
--- a/fietsboek/static/theme.css.map
+++ b/fietsboek/static/theme.css.map
@@ -1 +1 @@
-{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;AACA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
+{"version":3,"sourceRoot":"","sources":["../../asset-sources/theme.scss"],"names":[],"mappings":"AAAA;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EAqCE;EACA;EACA;EACA;EACA;EACA;;AAzCA;EACE;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAWJ;EACI;;;AAGJ;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAIJ;AACA;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE","file":"theme.css"} \ No newline at end of file
diff --git a/fietsboek/templates/profile.jinja2 b/fietsboek/templates/profile.jinja2
index 1e798c8..288db70 100644
--- a/fietsboek/templates/profile.jinja2
+++ b/fietsboek/templates/profile.jinja2
@@ -60,20 +60,25 @@
<ul class="nav nav-tabs" id="profileTabbar" role="tablist">
<li class="nav-item" role="presentation">
- <button class="nav-link active" id="tabOverviewButton" data-bs-toggle="tab" data-bs-target="#tabOverviewPane" type="button" role="tab" aria-controls="tabOverviewPane" aria-selected="true">
+ <button class="nav-link {% if not tab_focus or tab_focus == 'overview' %}active{% endif %}" id="tabOverviewButton" data-bs-toggle="tab" data-bs-target="#tabOverviewPane" type="button" role="tab" aria-controls="tabOverviewPane" aria-selected="{% if not tab_focus or tab_focus == 'overview' %}true{% else %}false{% endif %}">
{{ _("page.profile.tabbar.overview") }}
</button>
</li>
<li class="nav-item" role="presentation">
- <button class="nav-link" id="tabGraphsButton" data-bs-toggle="tab" data-bs-target="#tabGraphsPane" type="button" role="tab" aria-controls="tabGraphsPane" aria-selected="false">
+ <button class="nav-link {% if tab_focus == 'graph' %}active{% endif %}" id="tabGraphsButton" data-bs-toggle="tab" data-bs-target="#tabGraphsPane" type="button" role="tab" aria-controls="tabGraphsPane" aria-selected="{% if tab_focus == 'graph' %}true{% else %}false{% endif %}">
{{ _("page.profile.tabbar.graphs") }}
</button>
</li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link {% if tab_focus == 'calendar' %}active{% endif %}" id="tabCalendarButton" data-bs-toggle="tab" data-bs-target="#tabCalendarPane" type="button" role="tab" aria-controls="tabCalendarPane" aria-selected="{% if tab_focus == 'calendar' %}true{% else %}false{% endif %}">
+ {{ _("page.profile.tabbar.calendar") }}
+ </button>
+ </li>
</ul>
<div class="tab-content">
<!-- First tab -->
- <div class="tab-pane fade show active" id="tabOverviewPane" role="tabpanel" aria-labelledby="tabOverviewButton">
+ <div class="tab-pane {% if not tab_focus or tab_focus == 'overview' %}show active{% endif %}" id="tabOverviewPane" role="tabpanel" aria-labelledby="tabOverviewButton">
{% if heatmap_url or tilehunt_url %}
<div id="userMap" style="height: 600px; width: 100%;"></div>
{% endif %}
@@ -143,10 +148,52 @@
</div>
<!-- Second tab -->
- <div class="tab-pane fade" id="tabGraphsPane" role="tabpanel" aria-labelledby="tabGraphsButton">
+ <div class="tab-pane {% if tab_focus == 'graph' %}show active{% endif %}" id="tabGraphsPane" role="tabpanel" aria-labelledby="tabGraphsButton">
<h2 class="chart-title">{{ _("page.profile.graph.km_per_month") }}</h2>
<div style="position: relative; height: 500px; width: 75%; margin: auto;"><canvas id="graph-month-summary"></canvas></div>
</div>
+
+ <!-- Third tab -->
+ <div class="tab-pane {% if tab_focus == 'calendar' %}show active{% endif %}" id="tabCalendarPane" role="tabpanel" aria-labelledby="tabCalendarButton">
+ <h2 class="calendar-title">{{ calendar_month | format_date("MMMM YYYY") }}</h2>
+
+ <div class="calendar-controls">
+ <a href="{{ request.route_url('user-calendar-ym', user_id=user.id, year=calendar_prev.year, month=calendar_prev.month) }}">{{ _("page.profile.calendar.previous") }}</a>
+ |
+ <a href="{{ request.route_url('user-calendar-ym', user_id=user.id, year=calendar_next.year, month=calendar_next.month) }}">{{ _("page.profile.calendar.next") }}</a>
+ </div>
+
+ <table class="profile-calendar">
+ <thead>
+ <tr>
+ {% for day in range(7) %}
+ <td>{{ day_name(request, day) }}</td>
+ {% endfor %}
+ </tr>
+ </thead>
+ {% for row in calendar_rows %}
+ <tr>
+ {% for cell in row %}
+ {% if cell %}
+ {% set day, style, tracks = cell %}
+ <td class="calendar-cell {{ style }}">
+ <p class="calendar-date">{{ day.day }}</p>
+ {% if tracks %}
+ <ul>
+ {% for track in tracks %}
+ <li><a href="{{ request.route_url('details', track_id=track.id) }}">{{ (track.length / 1000) | round(2) | format_decimal }} km</a></li>
+ {% endfor %}
+ </ul>
+ {% endif %}
+ </td>
+ {% else %}
+ <td class="calendar-cell-empty"></td>
+ {% endif %}
+ {% endfor %}
+ </tr>
+ {% endfor %}
+ </table>
+ </div>
</div>
</div>
@@ -201,6 +248,11 @@
// selected at the start.
defaultLayer.addTo(map);
L.control.layers(baseLayers, overlayLayers).addTo(map);
+
+ // Fix leaflet being all weird if it's loaded on a hidden tab
+ document.querySelector("#tabOverviewButton").addEventListener("shown.bs.tab", event => {
+ map.invalidateSize();
+ });
})();
</script>
{% endblock %}
diff --git a/fietsboek/util.py b/fietsboek/util.py
index dcb9ab8..1d29600 100644
--- a/fietsboek/util.py
+++ b/fietsboek/util.py
@@ -244,6 +244,20 @@ def month_name(request: Request, month: int) -> str:
return locale.months["stand-alone"]["wide"][month]
+def day_name(request: Request, day: int) -> str:
+ """Returns the localized name for the day with the given number.
+
+ 0 is Monday, 6 is Sunday.
+
+ :param request: The pyramid request.
+ :param month: Number of the day, 0 = Monday, 6 = Sunday.
+ :return: The localized day name.
+ """
+ assert 0 <= day <= 6
+ locale = babel.Locale.parse(request.localizer.locale_name)
+ return locale.days["stand-alone"]["wide"][day]
+
+
def random_link_secret(nbytes: int = 20) -> str:
"""Safely generates a secret suitable for the link share.
diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py
index b8c5477..a002a81 100644
--- a/fietsboek/views/profile.py
+++ b/fietsboek/views/profile.py
@@ -7,6 +7,7 @@ import urllib.parse
from dataclasses import dataclass
from typing import Optional
+import sqlalchemy
from pyramid.httpexceptions import HTTPNotFound
from pyramid.request import Request
from pyramid.response import Response
@@ -15,7 +16,7 @@ from sqlalchemy import select
from sqlalchemy.orm import aliased
from .. import models, util
-from ..data import UserDataDir
+from ..data import DataManager, UserDataDir
from ..models.track import TrackType, TrackWithMetadata
from ..summaries import Summary
@@ -158,18 +159,8 @@ def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta:
return util.round_timedelta_to_multiple(value, datetime.timedelta(seconds=1))
-@view_config(
- route_name="profile",
- renderer="fietsboek:templates/profile.jinja2",
- request_method="GET",
- permission="profile.view",
-)
-def profile(request: Request) -> dict:
- """Shows the profile page.
-
- :param request: The pyramid request.
- :return: The template parameters.
- """
+def profile_data(request: Request) -> dict:
+ """Retrieves the profile data for the given request."""
total = CumulativeStats()
query = request.context.all_tracks_query()
@@ -205,6 +196,167 @@ def profile(request: Request) -> dict:
@view_config(
+ route_name="profile",
+ renderer="fietsboek:templates/profile.jinja2",
+ request_method="GET",
+ permission="profile.view",
+)
+def profile(request: Request) -> dict:
+ """Shows the profile page.
+
+ :param request: The pyramid request.
+ :return: The template parameters.
+ """
+ data = profile_data(request)
+ today = datetime.date.today()
+ data["calendar_rows"] = calendar_rows(
+ request.dbsession,
+ request.data_manager,
+ request.context,
+ today.year,
+ today.month,
+ )
+ data["calendar_month"] = today
+ data["calendar_prev"], data["calendar_next"] = prev_next_month(today)
+ data["day_name"] = util.day_name
+ return data
+
+
+@view_config(
+ route_name="user-calendar-ym",
+ renderer="fietsboek:templates/profile.jinja2",
+ request_method="GET",
+ permission="profile.view",
+)
+def user_calendar_ym(request: Request) -> dict:
+ """Shows the user's calendar.
+
+ :param request: The pyramid request.
+ :return: The template parameters.
+ """
+ data = profile_data(request)
+ date = datetime.date(int(request.matchdict["year"]), int(request.matchdict["month"]), 1)
+ data["calendar_rows"] = calendar_rows(
+ request.dbsession,
+ request.data_manager,
+ request.context,
+ date.year,
+ date.month,
+ )
+ data["calendar_month"] = date
+ data["calendar_prev"], data["calendar_next"] = prev_next_month(date)
+ data["tab_focus"] = "calendar"
+ data["day_name"] = util.day_name
+ return data
+
+
+def cell_style(tracks: list[TrackWithMetadata]) -> str:
+ """Returns the correct CSS style for a day with the given tracks.
+
+ The style is determined by the amount of kilometers cycled on that day.
+
+ :param tracks: The list of tracks.
+ :return: The CSS style for the calendar cell.
+ """
+ length = sum(track.length for track in tracks)
+ # kilometers
+ length = length / 1000
+ # Arbitrary cutoffs for the moment
+ if length == 0:
+ return "cell-length-0"
+ if length <= 25:
+ return "cell-length-1"
+ if length <= 50:
+ return "cell-length-2"
+ if length <= 75:
+ return "cell-length-3"
+ if length <= 100:
+ return "cell-length-4"
+ return "cell-length-5"
+
+
+def calendar_rows(
+ dbsession: "sqlalchemy.orm.session.Session",
+ data_manager: DataManager,
+ user: models.User,
+ year: int,
+ month: int,
+) -> list:
+ """Lays out the calendar for the given user and the given year/month.
+
+ :param dbsession: The database session.
+ :param user: The user for which to retrieve the calendar.
+ :param year: The year.
+ :apram month: The month.
+ :return: The calendar rows, ready to pass to the profile template.
+ """
+ # Four steps:
+ # 1. Get all tracks of the user
+ # 2. Build the calendar by getting the dates in the given month
+ # 3. Associcate the tracks with their respective date
+ # 4. Layout the calendar week-wise
+
+ # Step 1: Retrieve all tracks
+ query = user.all_tracks_query()
+ query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC)
+ tracks = [
+ TrackWithMetadata(track, data_manager)
+ for track in dbsession.execute(query).scalars()
+ ]
+
+ # Step 2: Build the calendar
+ days = []
+ current_day = datetime.date(year, month, 1)
+ while True:
+ days.append(current_day)
+ current_day += datetime.timedelta(days=1)
+ if current_day.month != int(month):
+ break
+
+ # Step 3: Associate days and tracks
+ days = [
+ (day, [track for track in tracks if track.date and track.date.date() == day])
+ for day in days
+ ]
+
+ # Step 3.5: Style the cells
+ days = [
+ (day, cell_style(tracks), tracks) for (day, tracks) in days
+ ]
+
+ # Step 4: Layout
+ rows = []
+ row: list[None | tuple[datetime.date, str, list[TrackWithMetadata]]] = []
+ while days:
+ next_day = days.pop(0)
+ # This should only matter in the first row, when the month does not
+ # start on a Monday. Otherwise, the range is always 0.
+ for _ in range(next_day[0].weekday() - len(row)):
+ row.append(None)
+ row.append(next_day)
+ if next_day[0].weekday() == 6:
+ rows.append(row)
+ row = []
+ # Append last if month doesn't end on a Sunday
+ if row:
+ row += [None] * (7 - len(row))
+ rows.append(row)
+
+ return rows
+
+
+def prev_next_month(date: datetime.date) -> tuple[datetime.date, datetime.date]:
+ """Return the previous and next months.
+
+ Months are normalized to the first day of the month.
+ """
+ date = date.replace(day=1)
+ prev = (date - datetime.timedelta(days=1)).replace(day=1)
+ nxt = (date + datetime.timedelta(days=32)).replace(day=1)
+ return (prev, nxt)
+
+
+@view_config(
route_name="user-tile",
request_method="GET",
permission="profile.view",
@@ -273,4 +425,4 @@ def json_summary(request: Request) -> Response:
return {y.year: {m.month: m.total_length for m in y} for y in summary}
-__all__ = ["profile", "user_tile", "json_summary"]
+__all__ = ["profile", "user_tile", "user_calendar_ym", "json_summary"]
diff --git a/tests/unit/views/test_profile.py b/tests/unit/views/test_profile.py
new file mode 100644
index 0000000..bc8e794
--- /dev/null
+++ b/tests/unit/views/test_profile.py
@@ -0,0 +1,21 @@
+import pytest
+import datetime
+
+from fietsboek.views import profile
+
+
+@pytest.mark.parametrize("current, prev_month, next_month", [
+ ((2024, 2, 1), (2024, 1, 1), (2024, 3, 1)),
+ ((2024, 1, 1), (2023, 12, 1), (2024, 2, 1)),
+ ((2024, 12, 1), (2024, 11, 1), (2025, 1, 1)),
+ ((2024, 5, 5), (2024, 4, 1), (2024, 6, 1)),
+ ((2024, 7, 31), (2024, 6, 1), (2024, 8, 1)),
+])
+def test_prev_next_month(current, prev_month, next_month):
+ current = datetime.date(*current)
+ prev_month = datetime.date(*prev_month)
+ next_month = datetime.date(*next_month)
+
+ actual_prev, actual_next = profile.prev_next_month(current)
+ assert actual_prev == prev_month
+ assert actual_next == next_month