aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--asset-sources/theme.scss19
-rw-r--r--fietsboek/__init__.py1
-rw-r--r--fietsboek/jinja2.py21
-rw-r--r--fietsboek/routes.py5
-rw-r--r--fietsboek/static/theme.css16
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/templates/profile.jinja248
-rw-r--r--fietsboek/views/profile.py146
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",