Compare commits

...

2 Commits

Author SHA1 Message Date
Lukáš Kucharczyk 714f0d97a9
Reformat
Django CI/CD / test (push) Successful in 1m0s Details
Django CI/CD / build-and-push (push) Successful in 2m10s Details
2024-08-04 22:40:43 +02:00
Lukáš Kucharczyk d622ddfbf3
Add all-time stats 2024-08-04 22:40:37 +02:00
7 changed files with 381 additions and 177 deletions

View File

@ -5,6 +5,7 @@
* Require login by default * Require login by default
* Add stats for dropped purchases, monthly playtimes * Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases * Allow deleting purchases
* Add all-time stats
## Improved ## Improved
* mark refunded purchases red on game overview * mark refunded purchases red on game overview

View File

@ -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 { .visible {
visibility: visible; visibility: visible;
} }
@ -1386,10 +1374,6 @@ input:checked + .toggle-bg {
margin-bottom: 2rem; margin-bottom: 2rem;
} }
.me-2 {
margin-inline-end: 0.5rem;
}
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
@ -1451,6 +1435,10 @@ input:checked + .toggle-bg {
height: 6rem; height: 6rem;
} }
.h-3 {
height: 0.75rem;
}
.h-4 { .h-4 {
height: 1rem; height: 1rem;
} }
@ -1507,14 +1495,6 @@ input:checked + .toggle-bg {
width: 100%; width: 100%;
} }
.min-w-14 {
min-width: 3.5rem;
}
.max-w-24 {
max-width: 6rem;
}
.max-w-80 { .max-w-80 {
max-width: 20rem; max-width: 20rem;
} }
@ -1814,10 +1794,6 @@ input:checked + .toggle-bg {
padding: 0.25rem; padding: 0.25rem;
} }
.p-2 {
padding: 0.5rem;
}
.p-2\.5 { .p-2\.5 {
padding: 0.625rem; padding: 0.625rem;
} }
@ -2555,11 +2531,6 @@ th label {
padding-right: 1.5rem; 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 { .group:hover .group-hover\:py-3\.5 {
padding-top: 0.875rem; padding-top: 0.875rem;
padding-bottom: 0.875rem; padding-bottom: 0.875rem;
@ -2733,11 +2704,6 @@ th label {
--tw-ring-color: rgb(63 131 248 / var(--tw-ring-opacity)); --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) { @media (min-width: 640px) {
.sm\:inline { .sm\:inline {
display: inline; display: inline;

View File

@ -81,8 +81,12 @@
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group"> <li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'stats_current_year' %}">Stats</a> href="{% url 'stats_by_year' 0 %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block"> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'stats_by_year' 0 %}">Overall</a>
</li>
{% for year in stats_dropdown_year_range %} {% for year in stats_dropdown_year_range %}
<li> <li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"

View File

@ -44,18 +44,22 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr> </tr>
{% if total_games %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr> </tr>
{% endif %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr> </tr>
{% if all_finished_this_year_count %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
</tr> </tr>
{% endif %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
@ -85,6 +89,7 @@
</tbody> </tbody>
</table> </table>
{% if month_playtime %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1> <h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table"> <table class="responsive-table">
<tbody> <tbody>
@ -96,6 +101,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
<h1 class="text-5xl text-center my-6">Purchases</h1> <h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
@ -168,6 +174,8 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if all_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished</h1> <h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -187,6 +195,9 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% if this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1> <h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -206,6 +217,9 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% if purchased_this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1> <h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -225,7 +239,9 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% if purchased_unfinished %}
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1> <h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -247,7 +263,9 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
{% if all_purchased_this_year %}
<h1 class="text-5xl text-center my-6">All Purchases</h1> <h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -269,5 +287,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -108,7 +108,7 @@
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed">
Sessions Sessions
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span> <span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
{% if latest_session_id %} {% if latest_session_id %}
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %} {% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
<a <a
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm" class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
@ -119,7 +119,7 @@
hx-target="#session-list" hx-target="#session-list"
hx-swap="afterbegin" hx-swap="afterbegin"
>New</a> >New</a>
{% endif %} {% endif %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span> and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul id="session-list"> <ul id="session-list">

View File

@ -108,7 +108,7 @@ urlpatterns = [
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_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( path(
"stats/<int:year>", "stats/<int:year>",
views.stats, views.stats,

View File

@ -365,13 +365,227 @@ def list_sessions(
return render(request, "list_sessions.html", context) 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 @login_required
def stats(request, year: int = 0): def stats(request, year: int = 0):
selected_year = request.GET.get("year") selected_year = request.GET.get("year")
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = timezone.now().year return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).select_related("purchase__edition") ).select_related("purchase__edition")