diff options
-rw-r--r-- | asset-sources/theme.scss | 19 | ||||
-rw-r--r-- | fietsboek/__init__.py | 1 | ||||
-rw-r--r-- | fietsboek/jinja2.py | 21 | ||||
-rw-r--r-- | fietsboek/routes.py | 5 | ||||
-rw-r--r-- | fietsboek/static/theme.css | 16 | ||||
-rw-r--r-- | fietsboek/static/theme.css.map | 2 | ||||
-rw-r--r-- | fietsboek/templates/profile.jinja2 | 48 | ||||
-rw-r--r-- | fietsboek/views/profile.py | 146 |
8 files changed, 237 insertions, 21 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss index 25353d7..f5b33da 100644 --- a/asset-sources/theme.scss +++ b/asset-sources/theme.scss @@ -158,6 +158,25 @@ strong { text-align: right; } +.profile-calendar { + width: 100%; + + .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; + } + } +} + /* 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..3832e66 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,31 @@ 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. """ 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. + """ + request = ctx.get("request") + locale = request.localizer.locale_name + return format_date(value, format=format, locale=locale) @jinja2.pass_context 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..afbf63e 100644 --- a/fietsboek/static/theme.css +++ b/fietsboek/static/theme.css @@ -151,6 +151,22 @@ strong { text-align: right; } +.profile-calendar { + width: 100%; +} +.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; +} + /* 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..a84d95b 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;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;;AAKN;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..a559e63 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 %} {% if disable_noncalendar %}disabled{% 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 %} {% if disable_noncalendar %}disabled{% 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,45 @@ </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>{{ calendar_month | format_date("MMMM YYYY") }}</h2> + + <div> + <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"> + {% for row in calendar_rows %} + <tr> + {% for cell in row %} + {% if cell %} + {% set day, tracks = cell %} + <td class="calendar-cell"> + <p class="calendar-date">{{ day.day }}</p> + {% if tracks %} + <ul> + {% for track in tracks %} + <li>{{ (track.length / 1000) | round(2) | format_decimal }} km</li> + {% endfor %} + </ul> + {% endif %} + </td> + {% else %} + <td class="calendar-cell-empty"></td> + {% endif %} + {% endfor %} + </tr> + {% endfor %} + </table> + </div> </div> </div> diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index b8c5477..2134b7c 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -15,7 +15,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 +158,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 +195,136 @@ 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) + 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["disable_noncalendar"] = True + return data + + +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.date() == day]) + for day in days + ] + + # Step 4: Layout + rows = [] + row = [] + 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) -> 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", |