aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2026-08-17 20:41:25 +0200
committerDaniel Schadt <kingdread@gmx.de>2026-08-17 20:41:25 +0200
commit2ea12050296d3e1ff7e4e9e73beed1b4d8d92c9d (patch)
treee8810fe05b1dbdbc3dafc71d73d5908aab43c726
parentef7d9dc50f03c60163215ab47664aa288272d435 (diff)
parentea162c20d5a9c7025d6f5c80b4c8eb1c306e1e68 (diff)
downloadfietsboek-2ea12050296d3e1ff7e4e9e73beed1b4d8d92c9d.tar.gz
fietsboek-2ea12050296d3e1ff7e4e9e73beed1b4d8d92c9d.tar.bz2
fietsboek-2ea12050296d3e1ff7e4e9e73beed1b4d8d92c9d.zip
Merge branch 'year-contribs'
-rw-r--r--asset-sources/theme.scss33
-rw-r--r--babel.cfg1
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.mobin20324 -> 20685 bytes
-rw-r--r--fietsboek/locale/de/LC_MESSAGES/messages.po34
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.mobin19139 -> 19490 bytes
-rw-r--r--fietsboek/locale/en/LC_MESSAGES/messages.po34
-rw-r--r--fietsboek/locale/fietslog.pot34
-rw-r--r--fietsboek/static/theme.css51
-rw-r--r--fietsboek/static/theme.css.map2
-rw-r--r--fietsboek/summaries.py175
-rw-r--r--fietsboek/templates/profile_overview.jinja250
-rw-r--r--fietsboek/views/profile.py5
12 files changed, 383 insertions, 36 deletions
diff --git a/asset-sources/theme.scss b/asset-sources/theme.scss
index ba05782..2faaffa 100644
--- a/asset-sources/theme.scss
+++ b/asset-sources/theme.scss
@@ -271,6 +271,39 @@ strong {
}
}
+.contribs-cell {
+ div {
+ border: 1px solid rgba(0, 0, 0, 0.175);
+ border-radius: calc(0.375rem - (1px));
+
+ width: 1.1em;
+ height: 1.1em;
+ }
+}
+
+.contribs-rowhead {
+ font-size: 90%;
+}
+
+.contribs-length-0 div {
+ background-color: #FFFFFF;
+}
+.contribs-length-1 div {
+ background-color: #8FF0A4;
+}
+.contribs-length-2 div {
+ background-color: #75DD95;
+}
+.contribs-length-3 div {
+ background-color: #5BC987;
+}
+.contribs-length-4 div {
+ background-color: #40B678;
+}
+.contribs-length-5 div {
+ background-color: #26A269;
+}
+
.browse-summary {
font-variant: lining-nums;
}
diff --git a/babel.cfg b/babel.cfg
index 6de4aaa..650a00a 100644
--- a/babel.cfg
+++ b/babel.cfg
@@ -4,3 +4,4 @@ jinja2 = jinja2.ext:babel_extract
[jinja2: **.jinja2]
[python: **.py]
+keywords = _ pluralize:1,2
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.mo b/fietsboek/locale/de/LC_MESSAGES/messages.mo
index 356be25..25b4a89 100644
--- a/fietsboek/locale/de/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/de/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/de/LC_MESSAGES/messages.po b/fietsboek/locale/de/LC_MESSAGES/messages.po
index 9367d28..bdd53a7 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: 2026-01-03 19:25+0100\n"
+"POT-Creation-Date: 2026-08-13 17:40+0200\n"
"PO-Revision-Date: 2022-07-02 17:35+0200\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.17.0\n"
+"Generated-By: Babel 2.18.0\n"
#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
@@ -61,6 +61,12 @@ msgstr "Maximalgeschwindigkeit"
msgid "pdf.table.avg_speed"
msgstr "Durchschnittsgeschwindigkeit"
+#: fietsboek/summaries.py:429
+msgid "page.profile.contribution_card.tooltip"
+msgid_plural "page.profile.contribution_card.tooltips"
+msgstr[0] "${count} Strecke am ${date}"
+msgstr[1] "${count} Strecken am ${date}"
+
#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr "Passwörter stimmen nicht überein"
@@ -1008,27 +1014,39 @@ msgstr "durchschnittliche Geschwindigkeit"
msgid "page.profile.number_of_tracks"
msgstr "Anzahl der Strecken"
-#: fietsboek/templates/profile_overview.jinja2:113
+#: fietsboek/templates/profile_overview.jinja2:112
+msgid "page.profile.contribution_card.title"
+msgstr "Streckenaktivität"
+
+#: fietsboek/templates/profile_overview.jinja2:147
+msgid "page.profile.contribution_card.legend.less"
+msgstr "Weniger km"
+
+#: fietsboek/templates/profile_overview.jinja2:154
+msgid "page.profile.contribution_card.legend.more"
+msgstr "Mehr km"
+
+#: fietsboek/templates/profile_overview.jinja2:161
msgid "page.profile.longest_distance_track"
msgstr "Weiteste Strecke"
-#: fietsboek/templates/profile_overview.jinja2:118
+#: fietsboek/templates/profile_overview.jinja2:166
msgid "page.profile.shortest_distance_track"
msgstr "Kürzeste Strecke"
-#: fietsboek/templates/profile_overview.jinja2:123
+#: fietsboek/templates/profile_overview.jinja2:171
msgid "page.profile.longest_duration_track"
msgstr "Am Längsten Dauernde Strecke"
-#: fietsboek/templates/profile_overview.jinja2:128
+#: fietsboek/templates/profile_overview.jinja2:176
msgid "page.profile.shortest_duration_track"
msgstr "Am Kürzesten Dauernde Strecke"
-#: fietsboek/templates/profile_overview.jinja2:149
+#: fietsboek/templates/profile_overview.jinja2:197
msgid "page.profile.heatmap"
msgstr "Heatmap"
-#: fietsboek/templates/profile_overview.jinja2:157
+#: fietsboek/templates/profile_overview.jinja2:205
msgid "page.profile.tilehunt"
msgstr "Kacheljäger"
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.mo b/fietsboek/locale/en/LC_MESSAGES/messages.mo
index f4b7bbf..b0c8a9c 100644
--- a/fietsboek/locale/en/LC_MESSAGES/messages.mo
+++ b/fietsboek/locale/en/LC_MESSAGES/messages.mo
Binary files differ
diff --git a/fietsboek/locale/en/LC_MESSAGES/messages.po b/fietsboek/locale/en/LC_MESSAGES/messages.po
index d52960b..e75d0e0 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: 2026-01-03 19:25+0100\n"
+"POT-Creation-Date: 2026-08-13 17:40+0200\n"
"PO-Revision-Date: 2023-04-03 20:42+0200\n"
"Last-Translator: \n"
"Language: en\n"
@@ -16,7 +16,7 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.17.0\n"
+"Generated-By: Babel 2.18.0\n"
#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
@@ -61,6 +61,12 @@ msgstr "Max Speed"
msgid "pdf.table.avg_speed"
msgstr "Average Speed"
+#: fietsboek/summaries.py:429
+msgid "page.profile.contribution_card.tooltip"
+msgid_plural "page.profile.contribution_card.tooltips"
+msgstr[0] "${count} track on ${date}"
+msgstr[1] "${count} tracks on ${date}"
+
#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr "Passwords don't match"
@@ -1002,27 +1008,39 @@ msgstr "Average Speed"
msgid "page.profile.number_of_tracks"
msgstr "Number of tracks"
-#: fietsboek/templates/profile_overview.jinja2:113
+#: fietsboek/templates/profile_overview.jinja2:112
+msgid "page.profile.contribution_card.title"
+msgstr "Track Activity"
+
+#: fietsboek/templates/profile_overview.jinja2:147
+msgid "page.profile.contribution_card.legend.less"
+msgstr "Fewer km"
+
+#: fietsboek/templates/profile_overview.jinja2:154
+msgid "page.profile.contribution_card.legend.more"
+msgstr "More km"
+
+#: fietsboek/templates/profile_overview.jinja2:161
msgid "page.profile.longest_distance_track"
msgstr "Longest Track"
-#: fietsboek/templates/profile_overview.jinja2:118
+#: fietsboek/templates/profile_overview.jinja2:166
msgid "page.profile.shortest_distance_track"
msgstr "Shortest Track"
-#: fietsboek/templates/profile_overview.jinja2:123
+#: fietsboek/templates/profile_overview.jinja2:171
msgid "page.profile.longest_duration_track"
msgstr "Most Time-Consuming Track"
-#: fietsboek/templates/profile_overview.jinja2:128
+#: fietsboek/templates/profile_overview.jinja2:176
msgid "page.profile.shortest_duration_track"
msgstr "Quickest Track"
-#: fietsboek/templates/profile_overview.jinja2:149
+#: fietsboek/templates/profile_overview.jinja2:197
msgid "page.profile.heatmap"
msgstr "Heat Map"
-#: fietsboek/templates/profile_overview.jinja2:157
+#: fietsboek/templates/profile_overview.jinja2:205
msgid "page.profile.tilehunt"
msgstr "Tilehunt"
diff --git a/fietsboek/locale/fietslog.pot b/fietsboek/locale/fietslog.pot
index 50a43a0..53f697f 100644
--- a/fietsboek/locale/fietslog.pot
+++ b/fietsboek/locale/fietslog.pot
@@ -8,14 +8,14 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2026-01-03 19:25+0100\n"
+"POT-Creation-Date: 2026-08-13 17:40+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
-"Generated-By: Babel 2.17.0\n"
+"Generated-By: Babel 2.18.0\n"
#: fietsboek/actions.py:266
msgid "email.verify_mail.subject"
@@ -57,6 +57,12 @@ msgstr ""
msgid "pdf.table.avg_speed"
msgstr ""
+#: fietsboek/summaries.py:429
+msgid "page.profile.contribution_card.tooltip"
+msgid_plural "page.profile.contribution_card.tooltips"
+msgstr[0] ""
+msgstr[1] ""
+
#: fietsboek/util.py:299
msgid "password_constraint.mismatch"
msgstr ""
@@ -994,27 +1000,39 @@ msgstr ""
msgid "page.profile.number_of_tracks"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:113
+#: fietsboek/templates/profile_overview.jinja2:112
+msgid "page.profile.contribution_card.title"
+msgstr ""
+
+#: fietsboek/templates/profile_overview.jinja2:147
+msgid "page.profile.contribution_card.legend.less"
+msgstr ""
+
+#: fietsboek/templates/profile_overview.jinja2:154
+msgid "page.profile.contribution_card.legend.more"
+msgstr ""
+
+#: fietsboek/templates/profile_overview.jinja2:161
msgid "page.profile.longest_distance_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:118
+#: fietsboek/templates/profile_overview.jinja2:166
msgid "page.profile.shortest_distance_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:123
+#: fietsboek/templates/profile_overview.jinja2:171
msgid "page.profile.longest_duration_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:128
+#: fietsboek/templates/profile_overview.jinja2:176
msgid "page.profile.shortest_duration_track"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:149
+#: fietsboek/templates/profile_overview.jinja2:197
msgid "page.profile.heatmap"
msgstr ""
-#: fietsboek/templates/profile_overview.jinja2:157
+#: fietsboek/templates/profile_overview.jinja2:205
msgid "page.profile.tilehunt"
msgstr ""
diff --git a/fietsboek/static/theme.css b/fietsboek/static/theme.css
index 65e6881..d12a9b7 100644
--- a/fietsboek/static/theme.css
+++ b/fietsboek/static/theme.css
@@ -80,14 +80,6 @@ strong {
flex-wrap: wrap;
}
-.track-image-preview {
- position: relative;
- width: min(317px, 100%);
- height: 178.3125px;
- border: 1px solid grey;
- margin-right: 5px;
- margin-bottom: 5px;
-}
.track-image-preview .delete-image {
position: absolute;
z-index: 5;
@@ -120,6 +112,14 @@ strong {
left: 50%;
transform: translate(-50%, -50%);
}
+.track-image-preview {
+ position: relative;
+ width: min(317px, 100%);
+ height: 178.3125px;
+ border: 1px solid grey;
+ margin-right: 5px;
+ margin-bottom: 5px;
+}
.track-description img {
max-width: 100%;
@@ -259,6 +259,41 @@ strong {
color: inherit;
}
+.contribs-cell div {
+ border: 1px solid rgba(0, 0, 0, 0.175);
+ border-radius: calc(0.375rem - 1px);
+ width: 1.1em;
+ height: 1.1em;
+}
+
+.contribs-rowhead {
+ font-size: 90%;
+}
+
+.contribs-length-0 div {
+ background-color: #FFFFFF;
+}
+
+.contribs-length-1 div {
+ background-color: #8FF0A4;
+}
+
+.contribs-length-2 div {
+ background-color: #75DD95;
+}
+
+.contribs-length-3 div {
+ background-color: #5BC987;
+}
+
+.contribs-length-4 div {
+ background-color: #40B678;
+}
+
+.contribs-length-5 div {
+ background-color: #26A269;
+}
+
.browse-summary {
font-variant: lining-nums;
}
diff --git a/fietsboek/static/theme.css.map b/fietsboek/static/theme.css.map
index 3ddb9bc..acade8d 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;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;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;EACA;;;AAGF;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;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;AAEA;EAJF;IAKI,eACE;;;AAIJ;EACE;;AAGF;EACE;;AAEA;EAHF;IAII;;;AAGF;EACE;EACA;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;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;EACA;;;AAGF;EACE;;;AAGF;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAIA;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;;AAlCJ;EAqCE;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACI;;;AAGJ;EACE;EACA;;;AAGF;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;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAEF;EACE;EACA;;;AAEF;EACE;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;AAGF;EACE;EACA;;AAIJ;EACE;;AAEF;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;;AACA;EACE;;AAGJ;EACE;EACA;;AACA;EACE;;AAIJ;EACE;EACA;;;AAKF;EACE;EACA;EAEA;EACA;;;AAIJ;EACE;;;AAGF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAGF;EACE;;;AAGF;AACA;EACE;;;AAGF;EACE;EACA;;AAEA;EAJF;IAKI,eACE;;;AAIJ;EACE;;AAGF;EACE;;AAEA;EAHF;IAII;;;AAGF;EACE;EACA;;AAIJ;EACE;EACA;;;AAIJ;EACE;;;AAGF;AACA;EACE;EACA;EACA;EACA;;;AAGF;EACE;;;AAGF;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/summaries.py b/fietsboek/summaries.py
index 0bee6ca..502e2f9 100644
--- a/fietsboek/summaries.py
+++ b/fietsboek/summaries.py
@@ -2,11 +2,18 @@
import datetime
from dataclasses import dataclass
-from typing import Optional
+from typing import Iterator, Optional
+
+from babel import Locale
+from babel.dates import format_date
+from pyramid.i18n import Localizer
+from pyramid.request import Request
from . import util
from .models.track import TrackWithMetadata
+ONE_WEEK = datetime.timedelta(days=7)
+
class Summary:
"""A summary of a user's tracks.
@@ -294,4 +301,168 @@ class CumulativeStats:
return util.round_to_seconds((self.moving_time + self.stopped_time) / self.count)
-__all__ = ["Summary", "YearSummary", "MonthSummary", "CumulativeStats"]
+class YearContributions:
+ """Github-contributions-tracker-like overview for tracks."""
+
+ def __init__(self, end_date: datetime.date):
+ self.end_date = end_date
+ self.start_date = end_date - 52 * ONE_WEEK
+ # Make sure the start of the range is on a Monday
+ self.start_date -= datetime.timedelta(days=end_date.weekday())
+ assert (self.start_date - self.end_date).days <= 53 * 7
+
+ self.tracks: dict[datetime.date, list[TrackWithMetadata]] = {}
+
+ def add(self, track: TrackWithMetadata):
+ """Adds a track to this overview.
+
+ :param track: The track to add.
+ """
+ if track.date is None:
+ return
+ date = track.date.date()
+ if self.start_date <= date <= self.end_date:
+ self.tracks.setdefault(date, []).append(track)
+
+ def rows(self) -> Iterator[Iterator[DayContributions]]:
+ """Returns the rows for the overview table.
+
+ Each row is a list of the cells. Seven rows will be returned, one for each weekday.
+
+ :return: An iterator over the rows to render.
+ """
+ for weekday in range(7):
+ row = []
+ day = self.start_date + datetime.timedelta(days=weekday)
+ while day <= self.end_date:
+ row.append(DayContributions(day, self.tracks.get(day, [])))
+ day += ONE_WEEK
+ yield iter(row)
+
+ def _rowheads(self, localizer: Localizer) -> Iterator[str | None]:
+ """Returns the row heads (i.e., weekday names) to be prepended to the rows.
+
+ :param localizer: The localizer to use.
+ :return: An iterator over the column headers.
+ """
+ loc = Locale.parse(localizer.locale_name)
+ days = loc.days["stand-alone"]["abbreviated"]
+ return iter(
+ [
+ days[0],
+ None,
+ None,
+ days[3],
+ None,
+ None,
+ days[6],
+ ]
+ )
+
+ def rows_with_heads(
+ self, localizer: Localizer
+ ) -> Iterator[tuple[str | None, Iterator[DayContributions]]]:
+ """Returns each row with its associated heading (weekday name).
+
+ :param localizer: The localizer to use.
+ :return: An iterator over (header, cells) pairs.
+ """
+ return zip(self._rowheads(localizer), self.rows())
+
+ def header_row(self, localizer: Localizer) -> Iterator[str | None]:
+ """Returns the header row.
+
+ Each string marks a month name (spanning three columns), and a ``None`` is an empty column.
+
+ :param localizer: The localizer to use.
+ :return: An iterator over the column headers.
+ """
+ locale = localizer.locale_name
+ day = self.start_date
+ while day <= self.end_date:
+ diq = day + datetime.timedelta(days=6)
+ if diq.day <= 7:
+ yield format_date(diq, "MMM", locale=locale)
+ day += 3 * ONE_WEEK
+ else:
+ yield None
+ day += ONE_WEEK
+
+
+class DayContributions:
+ """A single cell (representing one day) for the YearContributions."""
+
+ def __init__(self, day: datetime.date, tracks: list[TrackWithMetadata]):
+ self.day = day
+ self.tracks = tracks
+
+ def total_length(self) -> float:
+ """The total length of all tracks on this day, in meters."""
+ return sum(track.length for track in self.tracks)
+
+ def count(self) -> int:
+ """The number of tracks in this day."""
+ return len(self.tracks)
+
+ def cell_style(self) -> str:
+ """Returns the CSS style that should be applied to the cell.
+
+ :return: The CSS class.
+ """
+ length = self.total_length() / 1000
+ # Arbitrary cutoffs, should maybe be the same as for the calendar
+ if length == 0:
+ return "contribs-length-0"
+ if length <= 25:
+ return "contribs-length-1"
+ if length <= 50:
+ return "contribs-length-2"
+ if length <= 75:
+ return "contribs-length-3"
+ if length <= 100:
+ return "contribs-length-4"
+ return "contribs-length-5"
+
+ def tooltip(self, localizer: Localizer) -> str:
+ """Generates the tooltip for the cell, localized to the given language.
+
+ :param localizer: The localizer to use.
+ :return: The tooltip text.
+ """
+ formatted_date = format_date(self.day, locale=localizer.locale_name)
+ return localizer.pluralize(
+ "page.profile.contribution_card.tooltip",
+ "page.profile.contribution_card.tooltips",
+ self.count(),
+ mapping={
+ "count": self.count(),
+ "date": formatted_date,
+ },
+ )
+
+ def browse_url(self, request: Request) -> str:
+ """Returns a URL to the browse page with filters for this day applied.
+
+ :param request: The pyramid request.
+ :return: The URL.
+ """
+ datestring = self.day.strftime("%Y-%m-%d")
+ return request.route_url(
+ "browse",
+ _query={
+ "type[]": "ORGANIC",
+ "show-only[]": "user-tagged",
+ "min-date": datestring,
+ "max-date": datestring,
+ },
+ )
+
+
+__all__ = [
+ "Summary",
+ "YearSummary",
+ "MonthSummary",
+ "CumulativeStats",
+ "YearContributions",
+ "DayContributions",
+]
diff --git a/fietsboek/templates/profile_overview.jinja2 b/fietsboek/templates/profile_overview.jinja2
index eaa10c6..a088ba5 100644
--- a/fietsboek/templates/profile_overview.jinja2
+++ b/fietsboek/templates/profile_overview.jinja2
@@ -109,6 +109,56 @@
</tr>
</table>
+<h2>{{ _("page.profile.contribution_card.title") }}</h2>
+<div class="d-xl-flex justify-content-center" style="overflow: scroll;">
+ <table class="contributions">
+ <thead>
+ <tr>
+ <th></th>
+ {% for cell in year_contributions.header_row(request.localizer) %}
+ {% if cell is none %}
+ <th></th>
+ {% else %}
+ <th colspan="3" class="text-muted">{{ cell }}</th>
+ {% endif %}
+ {% endfor %}
+ </tr>
+ </thead>
+ <tbody>
+ {% for rowhead, row in year_contributions.rows_with_heads(request.localizer) %}
+ <tr>
+ <td class="text-muted contribs-rowhead">{{ rowhead | default("", true) }}</td>
+ {% for cell in row %}
+ <td class="contribs-cell {{ cell.cell_style() }}">
+ <a href="{{ cell.browse_url(request) }}">
+ <!-- Use a div because border-radius does not work on td -->
+ <div data-bs-toggle="tooltip" title="{{ cell.tooltip(request.localizer) }}"></div>
+ </a>
+ </td>
+ {% endfor %}
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+</div>
+
+<div class="d-flex justify-content-end mb-3">
+ <table>
+ <tbody>
+ <tr>
+ <td>{{ _("page.profile.contribution_card.legend.less") }}</td>
+ <td class="contribs-cell contribs-length-0"><div></div></td>
+ <td class="contribs-cell contribs-length-1"><div></div></td>
+ <td class="contribs-cell contribs-length-2"><div></div></td>
+ <td class="contribs-cell contribs-length-3"><div></div></td>
+ <td class="contribs-cell contribs-length-4"><div></div></td>
+ <td class="contribs-cell contribs-length-5"><div></div></td>
+ <td>{{ _("page.profile.contribution_card.legend.more") }}</td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+
{% if total.longest_distance_track %}
<h2>{{ _("page.profile.longest_distance_track") }}</h2>
{{ render_track_card(total.longest_distance_track) }}
diff --git a/fietsboek/views/profile.py b/fietsboek/views/profile.py
index 6d671b9..4f9abca 100644
--- a/fietsboek/views/profile.py
+++ b/fietsboek/views/profile.py
@@ -16,7 +16,7 @@ from sqlalchemy.orm import aliased
from .. import models, util
from ..data import UserDataDir
from ..models.track import TrackType, TrackWithMetadata
-from ..summaries import CumulativeStats, Summary
+from ..summaries import CumulativeStats, Summary, YearContributions
# 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
@@ -49,6 +49,7 @@ LOGGER = logging.getLogger(__name__)
def profile_data(request: Request) -> dict:
"""Retrieves the profile data for the given request."""
total = CumulativeStats()
+ year_contribs = YearContributions(datetime.date.today())
query = request.context.all_tracks_query()
query = select(aliased(models.Track, query)).where(query.c.type == TrackType.ORGANIC)
@@ -56,6 +57,7 @@ def profile_data(request: Request) -> dict:
for track in request.dbsession.execute(query).scalars():
meta = TrackWithMetadata(track)
total.add(meta)
+ year_contribs.add(meta)
total.moving_time = util.round_to_seconds(total.moving_time)
total.stopped_time = util.round_to_seconds(total.stopped_time)
@@ -79,6 +81,7 @@ def profile_data(request: Request) -> dict:
"mps_to_kph": util.mps_to_kph,
"heatmap_url": heatmap_url,
"tilehunt_url": tilehunt_url,
+ "year_contributions": year_contribs,
}