From fefcb809fabb5a5438ca529405b2574f42272a72 Mon Sep 17 00:00:00 2001 From: Daniel Schadt Date: Thu, 30 Jan 2025 22:01:36 +0100 Subject: add a per-year/per-month tooltip to the home page --- fietsboek/__init__.py | 1 + fietsboek/jinja2.py | 11 +++ fietsboek/locale/de/LC_MESSAGES/messages.mo | Bin 15962 -> 16196 bytes fietsboek/locale/de/LC_MESSAGES/messages.po | 34 +++++-- fietsboek/locale/en/LC_MESSAGES/messages.mo | Bin 14924 -> 15148 bytes fietsboek/locale/en/LC_MESSAGES/messages.po | 34 +++++-- fietsboek/locale/fietslog.pot | 38 +++++--- fietsboek/summaries.py | 142 +++++++++++++++++++++++++++- fietsboek/templates/home.jinja2 | 19 +++- fietsboek/util.py | 9 ++ fietsboek/views/profile.py | 119 +---------------------- 11 files changed, 258 insertions(+), 149 deletions(-) diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py index bc93408..3d6c125 100644 --- a/fietsboek/__init__.py +++ b/fietsboek/__init__.py @@ -181,6 +181,7 @@ def main(global_config, **settings): 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.filters["round_to_seconds"] = mod_jinja2.filter_round_to_seconds jinja2_env.globals["embed_tile_layers"] = mod_jinja2.global_embed_tile_layers jinja2_env.globals["list_languages"] = mod_jinja2.global_list_languages jinja2_env.globals["list_transformers"] = transformers.list_transformers diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py index 7559f26..5872db5 100644 --- a/fietsboek/jinja2.py +++ b/fietsboek/jinja2.py @@ -93,6 +93,17 @@ def filter_local_datetime(ctx: Context, value: datetime.datetime) -> Markup: ) +def filter_round_to_seconds(value: datetime.datetime) -> datetime.datetime: + """Rounds a timedelta to second accuracy. + + Uses :func:`.util.round_to_seconds`. + + :param value: The value to round. + :return: The rounded value. + """ + return util.round_to_seconds(value) + + def global_embed_tile_layers(request: Request) -> Markup: """Renders the available tile servers for the current user, as a JSON object. diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo index 204c8c5..9de152c 100644 Binary files a/fietsboek/locale/de/LC_MESSAGES/messages.mo and b/fietsboek/locale/de/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po index 7c497de..ccb46a3 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-11-16 23:44+0100\n" +"POT-Creation-Date: 2025-01-30 21:50+0100\n" "PO-Revision-Date: 2022-07-02 17:35+0200\n" "Last-Translator: FULL NAME \n" "Language: de\n" @@ -29,11 +29,11 @@ msgstr "" "\n" "Falls Du kein Konto angelegt hast, ignoriere diese E-Mail." -#: fietsboek/util.py:324 +#: fietsboek/util.py:333 msgid "password_constraint.mismatch" msgstr "Passwörter stimmen nicht überein" -#: fietsboek/util.py:326 +#: fietsboek/util.py:335 msgid "password_constraint.length" msgstr "Passwort zu kurz" @@ -53,11 +53,11 @@ msgstr "Bergauf" msgid "tooltip.table.downhill" msgstr "Bergab" -#: fietsboek/models/track.py:607 +#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "Fahrzeit" -#: fietsboek/models/track.py:608 +#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "Haltezeit" @@ -517,24 +517,40 @@ msgstr "Absenden" msgid "page.upload.form.cancel" msgstr "Abbrechen" +#: fietsboek/templates/home.jinja2:5 +msgid "tooltip.table.max_length" +msgstr "Maximallänge" + #: fietsboek/templates/home.jinja2:6 +msgid "tooltip.table.avg_length" +msgstr "Durchschnittslänge" + +#: fietsboek/templates/home.jinja2:9 +msgid "tooltip.table.max_duration" +msgstr "Maximaldauer" + +#: fietsboek/templates/home.jinja2:10 +msgid "tooltip.table.avg_duration" +msgstr "Durchschnittsdauer" + +#: fietsboek/templates/home.jinja2:17 msgid "page.home.title" msgstr "Startseite" -#: fietsboek/templates/home.jinja2:17 +#: fietsboek/templates/home.jinja2:28 msgid "page.home.unfinished_uploads" msgstr "" "Es sind noch nicht abgeschlossene Uploads vorhanden. Klicke auf die " "Links, um sie fortzusetzen:" -#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:44 fietsboek/templates/home.jinja2:53 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "%(num)d Strecke" msgstr[1] "%(num)d Strecken" -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.total" msgstr "Gesamt" diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo index e688c2e..5f8edc6 100644 Binary files a/fietsboek/locale/en/LC_MESSAGES/messages.mo and b/fietsboek/locale/en/LC_MESSAGES/messages.mo differ diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po index d9f61d6..981d134 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-11-16 23:44+0100\n" +"POT-Creation-Date: 2025-01-30 21:50+0100\n" "PO-Revision-Date: 2023-04-03 20:42+0200\n" "Last-Translator: \n" "Language: en\n" @@ -29,11 +29,11 @@ msgstr "" "\n" "If you did not create an account, ignore this email." -#: fietsboek/util.py:324 +#: fietsboek/util.py:333 msgid "password_constraint.mismatch" msgstr "Passwords don't match" -#: fietsboek/util.py:326 +#: fietsboek/util.py:335 msgid "password_constraint.length" msgstr "Password not long enough" @@ -53,11 +53,11 @@ msgstr "Uphill" msgid "tooltip.table.downhill" msgstr "Downhill" -#: fietsboek/models/track.py:607 +#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "Moving Time" -#: fietsboek/models/track.py:608 +#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "Stopped Time" @@ -513,22 +513,38 @@ msgstr "Upload" msgid "page.upload.form.cancel" msgstr "Cancel" +#: fietsboek/templates/home.jinja2:5 +msgid "tooltip.table.max_length" +msgstr "Max Length" + #: fietsboek/templates/home.jinja2:6 +msgid "tooltip.table.avg_length" +msgstr "Average Length" + +#: fietsboek/templates/home.jinja2:9 +msgid "tooltip.table.max_duration" +msgstr "Max Duration" + +#: fietsboek/templates/home.jinja2:10 +msgid "tooltip.table.avg_duration" +msgstr "Average Duration" + +#: fietsboek/templates/home.jinja2:17 msgid "page.home.title" msgstr "Home" -#: fietsboek/templates/home.jinja2:17 +#: fietsboek/templates/home.jinja2:28 msgid "page.home.unfinished_uploads" msgstr "You have unfinished uploads. Click on the links below to resume them:" -#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:44 fietsboek/templates/home.jinja2:53 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "%(num)d track" msgstr[1] "%(num)d tracks" -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.total" msgstr "Total" diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot index b14f14e..60c77a5 100644 --- a/fietsboek/locale/fietslog.pot +++ b/fietsboek/locale/fietslog.pot @@ -1,14 +1,14 @@ # Translations template for PROJECT. -# Copyright (C) 2024 ORGANIZATION +# Copyright (C) 2025 ORGANIZATION # This file is distributed under the same license as the PROJECT project. -# FIRST AUTHOR , 2024. +# FIRST AUTHOR , 2025. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-11-16 23:44+0100\n" +"POT-Creation-Date: 2025-01-30 21:50+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -25,11 +25,11 @@ msgstr "" msgid "email.verify.text" msgstr "" -#: fietsboek/util.py:324 +#: fietsboek/util.py:333 msgid "password_constraint.mismatch" msgstr "" -#: fietsboek/util.py:326 +#: fietsboek/util.py:335 msgid "password_constraint.length" msgstr "" @@ -49,11 +49,11 @@ msgstr "" msgid "tooltip.table.downhill" msgstr "" -#: fietsboek/models/track.py:607 +#: fietsboek/models/track.py:607 fietsboek/templates/home.jinja2:7 msgid "tooltip.table.moving_time" msgstr "" -#: fietsboek/models/track.py:608 +#: fietsboek/models/track.py:608 fietsboek/templates/home.jinja2:8 msgid "tooltip.table.stopped_time" msgstr "" @@ -507,22 +507,38 @@ msgstr "" msgid "page.upload.form.cancel" msgstr "" +#: fietsboek/templates/home.jinja2:5 +msgid "tooltip.table.max_length" +msgstr "" + #: fietsboek/templates/home.jinja2:6 -msgid "page.home.title" +msgid "tooltip.table.avg_length" +msgstr "" + +#: fietsboek/templates/home.jinja2:9 +msgid "tooltip.table.max_duration" +msgstr "" + +#: fietsboek/templates/home.jinja2:10 +msgid "tooltip.table.avg_duration" msgstr "" #: fietsboek/templates/home.jinja2:17 +msgid "page.home.title" +msgstr "" + +#: fietsboek/templates/home.jinja2:28 msgid "page.home.unfinished_uploads" msgstr "" -#: fietsboek/templates/home.jinja2:31 fietsboek/templates/home.jinja2:38 -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:44 fietsboek/templates/home.jinja2:53 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.summary.track" msgid_plural "page.home.summary.tracks" msgstr[0] "" msgstr[1] "" -#: fietsboek/templates/home.jinja2:82 +#: fietsboek/templates/home.jinja2:97 msgid "page.home.total" msgstr "" diff --git a/fietsboek/summaries.py b/fietsboek/summaries.py index 9721de2..a0bd7d0 100644 --- a/fietsboek/summaries.py +++ b/fietsboek/summaries.py @@ -1,8 +1,11 @@ """Module for a yearly/monthly track summary.""" -from typing import Dict, List +import datetime +from dataclasses import dataclass +from typing import Dict, List, Optional -from fietsboek.models.track import TrackWithMetadata +from . import util +from .models.track import TrackWithMetadata class Summary: @@ -56,6 +59,13 @@ class Summary: """ return sum(track.length for track in self.all_tracks()) + def stats(self) -> "CumulativeStats": + """Returns the stats for all tracks in this summary. + + :return: The stats. + """ + return CumulativeStats.from_tracks(self.all_tracks()) + class YearSummary: """A summary over a single year. @@ -108,6 +118,13 @@ class YearSummary: """ return sum(track.length for track in self.all_tracks()) + def stats(self) -> "CumulativeStats": + """Returns the stats for all tracks in this summary. + + :return: The stats. + """ + return CumulativeStats.from_tracks(self.all_tracks()) + class MonthSummary: """A summary over a single month. @@ -154,3 +171,124 @@ class MonthSummary: :return: The total length in meters. """ return sum(track.length for track in self.all_tracks()) + + def stats(self) -> "CumulativeStats": + """Returns the stats for all tracks in this summary. + + :return: The stats. + """ + return CumulativeStats.from_tracks(self.all_tracks()) + + +@dataclass +class CumulativeStats: + """Cumulative user stats. + + The values start out with default values, and tracks can be merged in via + :meth:`add`. + """ + + # pylint: disable=too-many-instance-attributes + + count: int = 0 + """Number of tracks added.""" + + length: float = 0.0 + """Total length, in meters.""" + + uphill: float = 0.0 + """Total uphill, in meters.""" + + downhill: float = 0.0 + """Total downhill, in meters.""" + + moving_time: datetime.timedelta = datetime.timedelta(0) + """Total time spent moving.""" + + stopped_time: datetime.timedelta = datetime.timedelta(0) + """Total time standing still.""" + + max_speed: float = 0.0 + """Overall maximum speed, in m/s.""" + + longest_distance_track: Optional[TrackWithMetadata] = None + """The track with the longest distance.""" + + shortest_distance_track: Optional[TrackWithMetadata] = None + """The track with the shortest distance.""" + + longest_duration_track: Optional[TrackWithMetadata] = None + """The track with the longest time.""" + + shortest_duration_track: Optional[TrackWithMetadata] = None + """The track with the shortest time.""" + + @classmethod + def from_tracks(cls, tracks: List[TrackWithMetadata]) -> "CumulativeStats": + """Create a new stats collection from this list of tracks. + + :param tracks: List of tracks. + :return: The stats. + """ + stats = cls() + for track in tracks: + stats.add(track) + return stats + + def add(self, track: TrackWithMetadata): + """Adds a track to this stats collection. + + :param track: The track to add, with accompanying metadata. + """ + self.count += 1 + self.length += track.length + self.uphill += track.uphill + self.downhill += track.downhill + self.moving_time += track.moving_time + self.stopped_time += track.stopped_time + self.max_speed = max(self.max_speed, track.max_speed) + + if self.longest_distance_track is None or self.longest_distance_track.length < track.length: + self.longest_distance_track = track + + if ( + self.shortest_distance_track is None + or self.shortest_distance_track.length > track.length + ): + self.shortest_distance_track = track + + if ( + self.longest_duration_track is None + or self.longest_duration_track.duration < track.duration + ): + self.longest_duration_track = track + + if ( + self.shortest_duration_track is None + or self.shortest_duration_track.duration > track.duration + ): + self.shortest_duration_track = track + + @property + def avg_speed(self) -> float: + """Average speed, in m/s.""" + if not self.moving_time: + return 0.0 + return self.length / self.moving_time.total_seconds() + + @property + def avg_length(self) -> float: + """Average length, in m.""" + if not self.count: + return 0 + return self.length / self.count + + @property + def avg_duration(self) -> datetime.timedelta: + """Average duration of a track. + + Note that this property is automatically rounded to seconds. + """ + if not self.count: + return datetime.timedelta() + return util.round_to_seconds((self.moving_time + self.stopped_time) / self.count) diff --git a/fietsboek/templates/home.jinja2 b/fietsboek/templates/home.jinja2 index e9e1d42..c723b3c 100644 --- a/fietsboek/templates/home.jinja2 +++ b/fietsboek/templates/home.jinja2 @@ -1,5 +1,16 @@ {% extends "layout.jinja2" %} +{% macro render_html_tooltip(stats) -%} + + + + + + + +
{{ _("tooltip.table.max_length") }}{{ (stats.longest_distance_track.length / 1000) | round(2) | format_decimal }} km
{{ _("tooltip.table.avg_length") }}{{ (stats.avg_length / 1000) | round(2) | format_decimal }} km
{{ _("tooltip.table.moving_time") }}{{ stats.moving_time | round_to_seconds }}
{{ _("tooltip.table.stopped_time") }}{{ stats.stopped_time | round_to_seconds }}
{{ _("tooltip.table.max_duration") }}{{ stats.longest_duration_track.duration | round_to_seconds }}
{{ _("tooltip.table.avg_duration") }}{{ stats.avg_duration | round_to_seconds }}
+{%- endmacro %} + {% block content %}
@@ -27,14 +38,18 @@ {% for year in summary %} - {{ year.year }} + + {{ year.year }} + {{ ngettext("page.home.summary.track", "page.home.summary.tracks", year|length) }} — {{ (year.total_length / 1000) | round(2) | format_decimal }} km
{% for month in year %} - {{ month_name(request, month.month) }} + + {{ month_name(request, month.month) }} + {{ ngettext("page.home.summary.track", "page.home.summary.tracks", month|length) }} — {{ (month.total_length / 1000) | round(2) | format_decimal }} km
diff --git a/fietsboek/util.py b/fietsboek/util.py index 1d29600..47e7b7f 100644 --- a/fietsboek/util.py +++ b/fietsboek/util.py @@ -110,6 +110,15 @@ def round_timedelta_to_multiple( return datetime.timedelta(seconds=lower) + multiples +def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: + """Round a timedelta to full seconds. + + :param value: The input value. + :return: The rounded value. + """ + return round_timedelta_to_multiple(value, datetime.timedelta(seconds=1)) + + def guess_gpx_timezone(gpx: gpxpy.gpx.GPX) -> datetime.tzinfo: """Guess which timezone the GPX file was recorded in. diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py index 38bdcdc..1208e93 100644 --- a/fietsboek/views/profile.py +++ b/fietsboek/views/profile.py @@ -4,8 +4,6 @@ import datetime import logging import sqlite3 import urllib.parse -from dataclasses import dataclass -from typing import Optional import sqlalchemy from pyramid.httpexceptions import HTTPNotFound @@ -18,7 +16,7 @@ from sqlalchemy.orm import aliased from .. import models, util from ..data import DataManager, UserDataDir from ..models.track import TrackType, TrackWithMetadata -from ..summaries import Summary +from ..summaries import CumulativeStats, Summary # A well-made transparent tile is actually pretty small (only 116 bytes), which # is even smaller than our HTTP 404 page. So not only is it more efficient @@ -48,117 +46,6 @@ EMPTY_TILE = bytes([ LOGGER = logging.getLogger(__name__) -@dataclass -class CumulativeStats: - """Cumulative user stats. - - The values start out with default values, and tracks can be merged in via - :meth:`add`. - """ - - # pylint: disable=too-many-instance-attributes - - count: int = 0 - """Number of tracks added.""" - - length: float = 0.0 - """Total length, in meters.""" - - uphill: float = 0.0 - """Total uphill, in meters.""" - - downhill: float = 0.0 - """Total downhill, in meters.""" - - moving_time: datetime.timedelta = datetime.timedelta(0) - """Total time spent moving.""" - - stopped_time: datetime.timedelta = datetime.timedelta(0) - """Total time standing still.""" - - max_speed: float = 0.0 - """Overall maximum speed, in m/s.""" - - longest_distance_track: Optional[TrackWithMetadata] = None - """The track with the longest distance.""" - - shortest_distance_track: Optional[TrackWithMetadata] = None - """The track with the shortest distance.""" - - longest_duration_track: Optional[TrackWithMetadata] = None - """The track with the longest time.""" - - shortest_duration_track: Optional[TrackWithMetadata] = None - """The track with the shortest time.""" - - def add(self, track: TrackWithMetadata): - """Adds a track to this stats collection. - - :param track: The track to add, with accompanying metadata. - """ - self.count += 1 - self.length += track.length - self.uphill += track.uphill - self.downhill += track.downhill - self.moving_time += track.moving_time - self.stopped_time += track.stopped_time - self.max_speed = max(self.max_speed, track.max_speed) - - if self.longest_distance_track is None or self.longest_distance_track.length < track.length: - self.longest_distance_track = track - - if ( - self.shortest_distance_track is None - or self.shortest_distance_track.length > track.length - ): - self.shortest_distance_track = track - - if ( - self.longest_duration_track is None - or self.longest_duration_track.duration < track.duration - ): - self.longest_duration_track = track - - if ( - self.shortest_duration_track is None - or self.shortest_duration_track.duration > track.duration - ): - self.shortest_duration_track = track - - @property - def avg_speed(self) -> float: - """Average speed, in m/s.""" - if not self.moving_time: - return 0.0 - return self.length / self.moving_time.total_seconds() - - @property - def avg_length(self) -> float: - """Average length, in m.""" - if not self.count: - return 0 - return self.length / self.count - - @property - def avg_duration(self) -> datetime.timedelta: - """Average duration of a track. - - Note that this property is automatically rounded to seconds. - """ - if not self.count: - return datetime.timedelta() - return round_to_seconds((self.moving_time + self.stopped_time) / self.count) - - -def round_to_seconds(value: datetime.timedelta) -> datetime.timedelta: - """Round a timedelta to full seconds. - - :param value: The input value. - :return: The rounded value. - """ - return util.round_timedelta_to_multiple(value, datetime.timedelta(seconds=1)) - - def profile_data(request: Request) -> dict: """Retrieves the profile data for the given request.""" total = CumulativeStats() @@ -170,8 +57,8 @@ def profile_data(request: Request) -> dict: meta = TrackWithMetadata(track, request.data_manager) total.add(meta) - total.moving_time = round_to_seconds(total.moving_time) - total.stopped_time = round_to_seconds(total.stopped_time) + total.moving_time = util.round_to_seconds(total.moving_time) + total.stopped_time = util.round_to_seconds(total.stopped_time) user_id = request.context.id heatmap_url = None -- cgit v1.2.3