From c108d1a75791e249aedd8102d5c363eabf36a217 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Wed, 13 Nov 2024 22:28:21 +0100 Subject: first draft of calendar --- asset-sources/theme.scss | 19 +++++ fietsboek/__init__.py | 1 + fietsboek/jinja2.py | 21 +++++- fietsboek/routes.py | 5 ++ fietsboek/static/theme.css | 16 ++++ fietsboek/static/theme.css.map | 2 +- fietsboek/templates/profile.jinja2 | 48 +++++++++++- 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 @@ -64,6 +64,11 @@ def includeme(config): config.add_route("force-logout", "/me/force-logout") 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+}", 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 @@
-
+
{% if heatmap_url or tilehunt_url %}
{% endif %} @@ -143,10 +148,45 @@
-
+

{{ _("page.profile.graph.km_per_month") }}

+ + +
+

{{ calendar_month | format_date("MMMM YYYY") }}

+ + + + + {% for row in calendar_rows %} + + {% for cell in row %} + {% if cell %} + {% set day, tracks = cell %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} +
+

{{ day.day }}

+ {% if tracks %} +
    + {% for track in tracks %} +
  • {{ (track.length / 1000) | round(2) | format_decimal }} km
  • + {% endfor %} +
+ {% endif %} +
+
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() @@ -204,6 +194,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", -- cgit v1.2.3