2 Commits

Author SHA1 Message Date
cd6ea6e903 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:19:16 +01:00
22018fd2ba Add yearly stats page
Fixes #15
2023-11-01 20:18:39 +01:00
22 changed files with 80 additions and 846 deletions

View File

@ -1,47 +1,3 @@
## Unreleased
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00 ## 1.2.0 / 2023-11-01 20:18+01:00
### New ### New

View File

@ -6,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.3.0 ENV VERSION_NUMBER 1.2.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -1,9 +0,0 @@
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0

View File

@ -19,8 +19,6 @@ class SessionForm(forms.ModelForm):
widget=autofocus_select_widget, widget=autofocus_select_widget,
) )
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta: class Meta:
widgets = { widgets = {
"timestamp_start": custom_datetime_widget, "timestamp_start": custom_datetime_widget,
@ -52,7 +50,6 @@ class PurchaseForm(forms.ModelForm):
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@ -60,7 +57,6 @@ class PurchaseForm(forms.ModelForm):
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",
@ -71,9 +67,7 @@ class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField( game = forms.ModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
) )
platform = forms.ModelChoiceField( platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
queryset=Platform.objects.order_by("name"), required=False
)
class Meta: class Meta:
model = Edition model = Edition
@ -83,7 +77,7 @@ class EditionForm(forms.ModelForm):
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "year_released", "wikidata"] fields = ["name", "wikidata"]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}

View File

@ -1,51 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,141 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -1,24 +0,0 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -10,40 +10,23 @@ from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
year_released = models.IntegerField(null=True, blank=True, default=None) wikidata = models.CharField(max_length=50)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name return self.name
class Edition(models.Model): class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
platform = models.ForeignKey( platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None year_released = models.IntegerField(default=datetime.today().year)
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name return self.name
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" PHYSICAL = "ph"
DIGITAL = "di" DIGITAL = "di"
@ -64,15 +47,10 @@ class Purchase(models.Model):
(PIRATED, "Pirated"), (PIRATED, "Pirated"),
] ]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE) edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey( platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0) price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField( ownership_type = models.CharField(
@ -88,7 +66,7 @@ class Purchase(models.Model):
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255)
def __str__(self): def __str__(self):
return self.name return self.name
@ -111,13 +89,7 @@ class Session(models.Model):
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey( device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -154,12 +126,6 @@ class Session(models.Model):
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)
@ -169,17 +135,15 @@ class Device(models.Model):
HANDHELD = "ha" HANDHELD = "ha"
MOBILE = "mo" MOBILE = "mo"
SBC = "sbc" SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [ DEVICE_TYPES = [
(PC, "PC"), (PC, "PC"),
(CONSOLE, "Console"), (CONSOLE, "Console"),
(HANDHELD, "Handheld"), (HANDHELD, "Handheld"),
(MOBILE, "Mobile"), (MOBILE, "Mobile"),
(SBC, "Single-board computer"), (SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

View File

@ -755,10 +755,6 @@ select {
position: absolute; position: absolute;
} }
.relative {
position: relative;
}
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
@ -795,6 +791,11 @@ select {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.my-5 {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
.my-6 { .my-6 {
margin-top: 1.5rem; margin-top: 1.5rem;
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
@ -804,10 +805,6 @@ select {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -832,10 +829,6 @@ select {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -880,10 +873,6 @@ select {
width: 1.75rem; width: 1.75rem;
} }
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -970,11 +959,6 @@ select {
border-color: rgb(100 116 139 / var(--tw-border-opacity)); border-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-green-600 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
@ -999,11 +983,6 @@ select {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@ -1022,10 +1001,6 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -1077,11 +1052,6 @@ select {
font-style: italic; font-style: italic;
} }
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
@ -1317,11 +1287,6 @@ th label {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
@ -1369,10 +1334,6 @@ th label {
--tw-ring-offset-color: #ddd6fe; --tw-ring-offset-color: #ddd6fe;
} }
.group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) { :is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
@ -1438,10 +1399,6 @@ th label {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }

View File

@ -25,37 +25,19 @@
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group"> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> {% if game_available and platform_available %}
{% if purchase_available %} <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li> {% endif %}
{% endif %} {% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{% if game_available and platform_available %} {% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li> {% if purchase_available %}
{% endif %} <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
{% if edition_available %} {% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -6,72 +6,22 @@
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center"> <h1 class="text-5xl text-center my-6">Stats for {{ year }}</h1>
<form method="get" class="text-center"> <table class="responsive-table">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label> <thead>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2"> <tr>
{% for year_item in stats_dropdown_year_range %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Total hours</th>
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option> <th class="px-2 sm:px-4 md:px-6 md:py-2">Total games</th>
{% endfor %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Total 2023 games</th>
</select> </tr>
</form> <tbody>
</div> <tr>
<div class="flex flex-column flex-wrap justify-center"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
<div class="md:w-1/2"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
<h1 class="text-5xl text-center my-6">Playtime</h1> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
<table class="responsive-table"> </tr>
<tbody> </tbody>
<tr> </table>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<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>
</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 font-mono">{{ total_games }}</td>
</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 font-mono">{{ total_2023_games }}</td>
</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 font-mono">{{ all_finished_this_year.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
</div>
</div>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1> <h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -81,14 +31,14 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for game in top_10_games_by_playtime %} {% for purchase in top_10_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }} <a href="{% url 'view_game' purchase.edition.game.id %}">{{ purchase.edition.name }}
</a> </a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -104,81 +54,11 @@
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }} </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished (2023 games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -15,7 +15,9 @@
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span> {{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2> ({{ first_session.timestamp_start | date:"M Y"}}
{{ last_session.timestamp_start | date:"M Y"}}) </h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul> <ul>

View File

@ -73,7 +73,6 @@ 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( path(
"stats/<int:year>", "stats/<int:year>",
views.stats, views.stats,

View File

@ -1,15 +1,11 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Sum, F, Count
from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.time import now as now_with_tz
from common.time import format_duration
from django.conf import settings
from django.shortcuts import redirect, render
from .forms import ( from .forms import (
GameForm, GameForm,
PlatformForm, PlatformForm,
@ -31,10 +27,6 @@ def model_counts(request):
} }
def stats_dropdown_year_range(request):
return {"stats_dropdown_year_range": range(2018, 2024)}
def add_session(request): def add_session(request):
context = {} context = {}
initial = {} initial = {}
@ -63,25 +55,6 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def use_custom_redirect(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
"""
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect
def edit_session(request, session_id=None): def edit_session(request, session_id=None):
context = {} context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
@ -94,7 +67,6 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None): def edit_purchase(request, purchase_id=None):
context = {} context = {}
purchase = Purchase.objects.get(id=purchase_id) purchase = Purchase.objects.get(id=purchase_id)
@ -107,7 +79,6 @@ def edit_purchase(request, purchase_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_game(request, game_id=None): def edit_game(request, game_id=None):
context = {} context = {}
purchase = Game.objects.get(id=game_id) purchase = Game.objects.get(id=game_id)
@ -139,21 +110,12 @@ def view_game(request, game_id=None):
# here first and last is flipped # here first and last is flipped
# because sessions are ordered from newest to oldest # because sessions are ordered from newest to oldest
# so the most recent are on top # so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y") context["last_session"] = context["sessions"].first()
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y") context["first_session"] = context["sessions"].last()
context["playrange"] = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
context["sessions_with_notes"] = context["sessions"].exclude(note="") context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None): def edit_platform(request, platform_id=None):
context = {} context = {}
purchase = Platform.objects.get(id=platform_id) purchase = Platform.objects.get(id=platform_id)
@ -166,7 +128,6 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None): def edit_edition(request, edition_id=None):
context = {} context = {}
edition = Edition.objects.get(id=edition_id) edition = Edition.objects.get(id=edition_id)
@ -179,7 +140,6 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = ( last_session = (
Session.objects.filter(purchase__edition__game_id=game_id) Session.objects.filter(purchase__edition__game_id=game_id)
@ -269,89 +229,29 @@ def list_sessions(
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int = 0): def stats(request, year: int):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
first_day_of_year = datetime(year, 1, 1) first_day_of_year = datetime(year, 1, 1)
last_day_of_year = datetime(year + 1, 1, 1) year_sessions = Session.objects.filter(timestamp_start__gte=first_day_of_year)
year_sessions = Session.objects.filter(timestamp_start__year=year) year_purchases = Purchase.objects.filter(session__in=year_sessions).distinct()
unique_days = ( year_purchases_with_playtime = year_purchases.annotate(
year_sessions.annotate(date=TruncDate("timestamp_start")) total_playtime=Sum(
.values("date") F("session__duration_calculated") + F("session__duration_manual")
.distinct()
.aggregate(dates=Count("date"))
)
year_played_purchases = Purchase.objects.filter(
session__in=year_sessions
).distinct()
selected_currency = "CZK"
all_purchased_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.order_by("date_purchased")
)
all_purchased_without_refunded_this_year = all_purchased_this_year.not_refunded()
all_purchased_refunded_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.refunded()
.order_by("date_purchased")
)
purchased_unfinished = all_purchased_without_refunded_this_year.filter(
date_finished__isnull=True
)
unfinished_purchases_percent = int(
safe_division(
purchased_unfinished.count(), all_purchased_refunded_this_year.count()
) )
* 100
) )
top_10_by_playtime = year_purchases_with_playtime.order_by("-total_playtime")[:10]
all_finished_this_year = Purchase.objects.filter(date_finished__year=year).order_by( for purchase in top_10_by_playtime:
"date_finished" purchase.formatted_playtime = format_duration(purchase.total_playtime, "%2.0H")
)
this_year_finished_this_year = (
Purchase.objects.filter(date_finished__year=year)
.filter(edition__year_released=year)
.order_by("date_finished")
)
purchased_this_year_finished_this_year = (
all_purchased_without_refunded_this_year.filter(
date_finished__year=year
).order_by("date_finished")
)
this_year_spendings = all_purchased_without_refunded_this_year.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"]
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
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 = ( total_playtime_per_platform = (
year_sessions.values("purchase__platform__name") year_sessions.values("purchase__platform__name") # Group by platform name
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual"))) .annotate(
.annotate(platform_name=F("purchase__platform__name")) total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
.values("platform_name", "total_playtime") ) # Sum the duration_calculated for each group
.order_by("-total_playtime") .annotate(platform_name=F("purchase__platform__name")) # Rename the field
.values(
"platform_name", "total_playtime"
) # Select the renamed field and total_playtime
.order_by("-total_playtime") # Optional: Order by the renamed platform name
) )
for item in total_playtime_per_platform: for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H") item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
@ -360,39 +260,14 @@ def stats(request, year: int = 0):
"total_hours": format_duration( "total_hours": format_duration(
year_sessions.total_duration_unformatted(), "%2.0H" year_sessions.total_duration_unformatted(), "%2.0H"
), ),
"total_games": year_played_purchases.count(), "total_games": year_purchases.count(),
"total_2023_games": year_played_purchases.filter( "total_2023_games": year_purchases.filter(edition__year_released=year).count(),
edition__year_released=year "top_10_by_playtime_formatted": top_10_by_playtime,
).count(), "top_10_by_playtime": top_10_by_playtime,
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year, "year": year,
"total_playtime_per_platform": total_playtime_per_platform, "total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": all_purchased_without_refunded_this_year,
"spent_per_game": int(
safe_division(total_spent, all_purchased_without_refunded_this_year.count())
),
"all_finished_this_year": all_finished_this_year,
"this_year_finished_this_year": this_year_finished_this_year,
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
"total_sessions": year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": purchased_unfinished,
"unfinished_purchases_percent": unfinished_purchases_percent,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year.count(),
all_purchased_this_year.count(),
)
* 100
),
"all_purchased_refunded_this_year": all_purchased_refunded_this_year,
"all_purchased_this_year": all_purchased_this_year,
} }
request.session["return_path"] = request.path
return render(request, "stats.html", context) return render(request, "stats.html", context)

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.3.0" version = "1.2.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"

View File

@ -68,7 +68,6 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
}, },
}, },