diff options
-rw-r--r-- | asset-sources/theme.scss | 76 | ||||
-rw-r--r-- | fietsboek/__init__.py | 1 | ||||
-rw-r--r-- | fietsboek/jinja2.py | 24 | ||||
-rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.mo | bin | 15785 -> 15962 bytes | |||
-rw-r--r-- | fietsboek/locale/de/LC_MESSAGES/messages.po | 54 | ||||
-rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.mo | bin | 14754 -> 14924 bytes | |||
-rw-r--r-- | fietsboek/locale/en/LC_MESSAGES/messages.po | 54 | ||||
-rw-r--r-- | fietsboek/locale/fietslog.pot | 54 | ||||
-rw-r--r-- | fietsboek/routes.py | 5 | ||||
-rw-r--r-- | fietsboek/static/theme.css | 71 | ||||
-rw-r--r-- | fietsboek/static/theme.css.map | 2 | ||||
-rw-r--r-- | fietsboek/templates/profile.jinja2 | 60 | ||||
-rw-r--r-- | fietsboek/util.py | 14 | ||||
-rw-r--r-- | fietsboek/views/profile.py | 180 | ||||
-rw-r--r-- | tests/unit/views/test_profile.py | 21 |
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 Binary files differindex c2fd61b..204c8c5 100644 --- a/fietsboek/locale/de/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo 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 Binary files differindex 1390daa..e688c2e 100644 --- a/fietsboek/locale/en/LC_MESSAGES/messages.mo +++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo 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 |