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",  | 
