diff --git a/CHANGELOG.md b/CHANGELOG.md
index ff436bb..d52a5ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
+* Add all-time stats
## Improved
* mark refunded purchases red on game overview
diff --git a/games/static/base.css b/games/static/base.css
index e09485f..7adf3d9 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -1,5 +1,5 @@
/*
-! tailwindcss v3.4.4 | MIT License | https://tailwindcss.com
+! tailwindcss v3.4.7 | MIT License | https://tailwindcss.com
*/
/*
@@ -1246,18 +1246,6 @@ input:checked + .toggle-bg {
}
}
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0, 0, 0, 0);
- white-space: nowrap;
- border-width: 0;
-}
-
.visible {
visibility: visible;
}
@@ -1386,10 +1374,6 @@ input:checked + .toggle-bg {
margin-bottom: 2rem;
}
-.me-2 {
- margin-inline-end: 0.5rem;
-}
-
.ml-1 {
margin-left: 0.25rem;
}
@@ -1451,6 +1435,10 @@ input:checked + .toggle-bg {
height: 6rem;
}
+.h-3 {
+ height: 0.75rem;
+}
+
.h-4 {
height: 1rem;
}
@@ -1507,14 +1495,6 @@ input:checked + .toggle-bg {
width: 100%;
}
-.min-w-14 {
- min-width: 3.5rem;
-}
-
-.max-w-24 {
- max-width: 6rem;
-}
-
.max-w-80 {
max-width: 20rem;
}
@@ -1814,10 +1794,6 @@ input:checked + .toggle-bg {
padding: 0.25rem;
}
-.p-2 {
- padding: 0.5rem;
-}
-
.p-2\.5 {
padding: 0.625rem;
}
@@ -2555,11 +2531,6 @@ th label {
padding-right: 1.5rem;
}
-.group:hover .group-hover\:py-3 {
- padding-top: 0.75rem;
- padding-bottom: 0.75rem;
-}
-
.group:hover .group-hover\:py-3\.5 {
padding-top: 0.875rem;
padding-bottom: 0.875rem;
@@ -2733,11 +2704,6 @@ th label {
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity));
}
-.dark\:focus\:ring-blue-800:focus:is(.dark *) {
- --tw-ring-opacity: 1;
- --tw-ring-color: rgb(30 66 159 / var(--tw-ring-opacity));
-}
-
@media (min-width: 640px) {
.sm\:inline {
display: inline;
diff --git a/games/templates/base.html b/games/templates/base.html
index 0e24d55..bfc83de 100644
--- a/games/templates/base.html
+++ b/games/templates/base.html
@@ -81,8 +81,12 @@
{% if session_count > 0 %}
Stats
+ href="{% url 'stats_by_year' 0 %}">Stats
+ -
+ Overall
+
{% for year in stats_dropdown_year_range %}
-
Days
{{ unique_days }} ({{ unique_days_percent }}%) |
-
- Games |
- {{ total_games }} |
-
+ {% if total_games %}
+
+ Games |
+ {{ total_games }} |
+
+ {% endif %}
Games ({{ year }}) |
{{ total_2023_games }} |
-
- Finished |
- {{ all_finished_this_year_count }} |
-
+ {% if all_finished_this_year_count %}
+
+ Finished |
+ {{ all_finished_this_year_count }} |
+
+ {% endif %}
Finished ({{ year }}) |
{{ this_year_finished_this_year_count }} |
@@ -85,17 +89,19 @@
- Playtime per month
-
-
- {% for month in month_playtimes %}
-
- {{ month.month | date:"F" }} |
- {{ month.playtime }} |
-
- {% endfor %}
-
-
+ {% if month_playtime %}
+ Playtime per month
+
+
+ {% for month in month_playtimes %}
+
+ {{ month.month | date:"F" }} |
+ {{ month.playtime }} |
+
+ {% endfor %}
+
+
+ {% endif %}
Purchases
@@ -168,106 +174,119 @@
{% endfor %}
- Finished
-
-
-
- Name |
- Date |
-
-
-
- {% for purchase in all_finished_this_year %}
-
-
- {% partial purchase-name %}
- |
- {{ purchase.date_finished | date:"d/m/Y" }} |
-
- {% endfor %}
-
-
- Finished ({{ year }} games)
-
-
-
- Name |
- Date |
-
-
-
- {% for purchase in this_year_finished_this_year %}
-
-
- {% partial purchase-name %}
- |
- {{ purchase.date_finished | date:"d/m/Y" }} |
-
- {% endfor %}
-
-
- Bought and Finished ({{ year }})
-
-
-
- Name |
- Date |
-
-
-
- {% for purchase in purchased_this_year_finished_this_year %}
-
-
- {% partial purchase-name %}
- |
- {{ purchase.date_finished | date:"d/m/Y" }} |
-
- {% endfor %}
-
-
- Unfinished Purchases
-
-
-
- Name |
- Price ({{ total_spent_currency }}) |
- Date |
-
-
-
- {% for purchase in purchased_unfinished %}
+ {% if all_finished_this_year %}
+ Finished
+
+
-
- {% partial purchase-name %}
- |
- {{ purchase.price }} |
- {{ purchase.date_purchased | date:"d/m/Y" }} |
+ Name |
+ Date |
- {% endfor %}
-
-
+
+
+ {% for purchase in all_finished_this_year %}
+
+
+ {% partial purchase-name %}
+ |
+ {{ purchase.date_finished | date:"d/m/Y" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
- All Purchases
-
-
-
- Name |
- Price ({{ total_spent_currency }}) |
- Date |
-
-
-
- {% for purchase in all_purchased_this_year %}
+ {% if this_year_finished_this_year %}
+ Finished ({{ year }} games)
+
+
-
- {% partial purchase-name %}
- |
- {{ purchase.price }} |
- {{ purchase.date_purchased | date:"d/m/Y" }} |
+ Name |
+ Date |
- {% endfor %}
-
-
+
+
+ {% for purchase in this_year_finished_this_year %}
+
+
+ {% partial purchase-name %}
+ |
+ {{ purchase.date_finished | date:"d/m/Y" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if purchased_this_year_finished_this_year %}
+ Bought and Finished ({{ year }})
+
+
+
+ Name |
+ Date |
+
+
+
+ {% for purchase in purchased_this_year_finished_this_year %}
+
+
+ {% partial purchase-name %}
+ |
+ {{ purchase.date_finished | date:"d/m/Y" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if purchased_unfinished %}
+ Unfinished Purchases
+
+
+
+ Name |
+ Price ({{ total_spent_currency }}) |
+ Date |
+
+
+
+ {% for purchase in purchased_unfinished %}
+
+
+ {% partial purchase-name %}
+ |
+ {{ purchase.price }} |
+ {{ purchase.date_purchased | date:"d/m/Y" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
+
+ {% if all_purchased_this_year %}
+ All Purchases
+
+
+
+ Name |
+ Price ({{ total_spent_currency }}) |
+ Date |
+
+
+
+ {% for purchase in all_purchased_this_year %}
+
+
+ {% partial purchase-name %}
+ |
+ {{ purchase.price }} |
+ {{ purchase.date_purchased | date:"d/m/Y" }} |
+
+ {% endfor %}
+
+
+ {% endif %}
{% endblock content %}
diff --git a/games/urls.py b/games/urls.py
index 30c465e..43936fa 100644
--- a/games/urls.py
+++ b/games/urls.py
@@ -108,7 +108,7 @@ urlpatterns = [
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
- path("stats/", views.stats, name="stats_current_year"),
+ path("stats/", views.stats_alltime, name="stats_alltime"),
path(
"stats/",
views.stats,
diff --git a/games/views.py b/games/views.py
index bd2fb91..4ec16de 100644
--- a/games/views.py
+++ b/games/views.py
@@ -365,13 +365,227 @@ def list_sessions(
return render(request, "list_sessions.html", context)
+@login_required
+def stats_alltime(request):
+ year = "Alltime"
+ this_year_sessions = Session.objects.all().select_related("purchase__edition")
+ this_year_sessions_with_durations = this_year_sessions.annotate(
+ duration=ExpressionWrapper(
+ F("timestamp_end") - F("timestamp_start"),
+ output_field=fields.DurationField(),
+ )
+ )
+ longest_session = this_year_sessions_with_durations.order_by("-duration").first()
+ this_year_games = Game.objects.filter(
+ edition__purchase__session__in=this_year_sessions
+ ).distinct()
+ this_year_games_with_session_counts = this_year_games.annotate(
+ session_count=Count("edition__purchase__session"),
+ )
+ game_highest_session_count = this_year_games_with_session_counts.order_by(
+ "-session_count"
+ ).first()
+ selected_currency = "CZK"
+ unique_days = (
+ this_year_sessions.annotate(date=TruncDate("timestamp_start"))
+ .values("date")
+ .distinct()
+ .aggregate(dates=Count("date"))
+ )
+ this_year_played_purchases = Purchase.objects.filter(
+ session__in=this_year_sessions
+ ).distinct()
+
+ this_year_purchases = Purchase.objects.all()
+ this_year_purchases_with_currency = this_year_purchases.select_related(
+ "edition"
+ ).filter(price_currency__exact=selected_currency)
+ this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
+ date_refunded=None
+ )
+ this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
+
+ this_year_purchases_unfinished_dropped_nondropped = (
+ this_year_purchases_without_refunded.filter(date_finished__isnull=True)
+ .filter(infinite=False)
+ .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
+ ) # do not count battle passes etc.
+
+ this_year_purchases_unfinished = (
+ this_year_purchases_unfinished_dropped_nondropped.filter(
+ date_dropped__isnull=True
+ )
+ )
+ this_year_purchases_dropped = (
+ this_year_purchases_unfinished_dropped_nondropped.filter(
+ date_dropped__isnull=False
+ )
+ )
+
+ this_year_purchases_without_refunded_count = (
+ this_year_purchases_without_refunded.count()
+ )
+ this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
+ this_year_purchases_unfinished_percent = int(
+ safe_division(
+ this_year_purchases_unfinished_count,
+ this_year_purchases_without_refunded_count,
+ )
+ * 100
+ )
+
+ purchases_finished_this_year = Purchase.objects.finished()
+ purchases_finished_this_year_released_this_year = (
+ purchases_finished_this_year.all().order_by("date_finished")
+ )
+ purchased_this_year_finished_this_year = (
+ this_year_purchases_without_refunded.all()
+ ).order_by("date_finished")
+
+ this_year_spendings = this_year_purchases_without_refunded.aggregate(
+ total_spent=Sum(F("price"))
+ )
+ total_spent = this_year_spendings["total_spent"] or 0
+
+ games_with_playtime = (
+ Game.objects.filter(edition__purchase__session__in=this_year_sessions)
+ .annotate(
+ total_playtime=Sum(
+ F("edition__purchase__session__duration_calculated")
+ + F("edition__purchase__session__duration_manual")
+ )
+ )
+ .values("id", "name", "total_playtime")
+ )
+ month_playtimes = (
+ this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
+ .values("month")
+ .annotate(playtime=Sum("duration_calculated"))
+ .order_by("month")
+ )
+ for month in month_playtimes:
+ month["playtime"] = format_duration(month["playtime"], "%2.0H")
+
+ highest_session_average_game = (
+ Game.objects.filter(edition__purchase__session__in=this_year_sessions)
+ .annotate(
+ session_average=Avg("edition__purchase__session__duration_calculated")
+ )
+ .order_by("-session_average")
+ .first()
+ )
+ top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
+ for game in top_10_games_by_playtime:
+ game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
+
+ total_playtime_per_platform = (
+ this_year_sessions.values("purchase__platform__name")
+ .annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
+ .annotate(platform_name=F("purchase__platform__name"))
+ .values("platform_name", "total_playtime")
+ .order_by("-total_playtime")
+ )
+ for item in total_playtime_per_platform:
+ item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
+
+ backlog_decrease_count = (
+ Purchase.objects.all().intersection(purchases_finished_this_year).count()
+ )
+
+ first_play_name = "N/A"
+ first_play_date = "N/A"
+ last_play_name = "N/A"
+ last_play_date = "N/A"
+ if this_year_sessions:
+ first_session = this_year_sessions.earliest()
+ first_play_game = first_session.purchase.edition.game
+ first_play_date = first_session.timestamp_start.strftime("%x")
+ last_session = this_year_sessions.latest()
+ last_play_game = last_session.purchase.edition.game
+ last_play_date = last_session.timestamp_start.strftime("%x")
+
+ all_purchased_this_year_count = this_year_purchases_with_currency.count()
+ all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
+
+ this_year_purchases_dropped_count = this_year_purchases_dropped.count()
+ this_year_purchases_dropped_percentage = int(
+ safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
+ * 100
+ )
+ context = {
+ "total_hours": format_duration(
+ this_year_sessions.total_duration_unformatted(), "%2.0H"
+ ),
+ "total_2023_games": this_year_played_purchases.all().count(),
+ "top_10_games_by_playtime": top_10_games_by_playtime,
+ "year": year,
+ "total_playtime_per_platform": total_playtime_per_platform,
+ "total_spent": total_spent,
+ "total_spent_currency": selected_currency,
+ "spent_per_game": int(
+ safe_division(total_spent, this_year_purchases_without_refunded_count)
+ ),
+ "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
+ "total_sessions": this_year_sessions.count(),
+ "unique_days": unique_days["dates"],
+ "unique_days_percent": int(unique_days["dates"] / 365 * 100),
+ "purchased_unfinished_count": this_year_purchases_unfinished_count,
+ "unfinished_purchases_percent": this_year_purchases_unfinished_percent,
+ "dropped_count": this_year_purchases_dropped_count,
+ "dropped_percentage": this_year_purchases_dropped_percentage,
+ "refunded_percent": int(
+ safe_division(
+ all_purchased_refunded_this_year_count,
+ all_purchased_this_year_count,
+ )
+ * 100
+ ),
+ "all_purchased_refunded_this_year": this_year_purchases_refunded,
+ "all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
+ "all_purchased_this_year_count": all_purchased_this_year_count,
+ "backlog_decrease_count": backlog_decrease_count,
+ "longest_session_time": (
+ format_duration(longest_session.duration, "%2.0Hh %2.0mm")
+ if longest_session
+ else 0
+ ),
+ "longest_session_game": (
+ longest_session.purchase.edition.game if longest_session else None
+ ),
+ "highest_session_count": (
+ game_highest_session_count.session_count
+ if game_highest_session_count
+ else 0
+ ),
+ "highest_session_count_game": (
+ game_highest_session_count if game_highest_session_count else None
+ ),
+ "highest_session_average": (
+ format_duration(
+ highest_session_average_game.session_average, "%2.0Hh %2.0mm"
+ )
+ if highest_session_average_game
+ else 0
+ ),
+ "highest_session_average_game": highest_session_average_game,
+ "first_play_game": first_play_game,
+ "first_play_date": first_play_date,
+ "last_play_game": last_play_game,
+ "last_play_date": last_play_date,
+ "title": f"{year} Stats",
+ }
+
+ request.session["return_path"] = request.path
+ return render(request, "stats.html", context)
+
+
@login_required
def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
- year = timezone.now().year
+ return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).select_related("purchase__edition")