45 Commits
1.1.2 ... 1.5.0

Author SHA1 Message Date
f31280c682 Version 1.5.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 19:31:17 +01:00
a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
c8a3212b77 CI: run migrations before tests
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-11-12 08:11:22 +01:00
d211326c3f Make sure empty stats are 0
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-12 08:01:12 +01:00
270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
015b6db2f7 Fix detecting manual durations
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-11 15:02:28 +01:00
667b161fff Remove deprecated USE_L10N
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-10 21:37:13 +01:00
5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
3b37f2c3f0 Fix edge case in format_duration
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
4517ff2b5a Fix ordering
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:43:17 +01:00
884ce13e26 Also prefill year between game and edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:20:12 +01:00
dd219bae9d Version 1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:11:43 +01:00
60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
b0be7b5887 Fix sort names getting mangled
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 15:41:46 +01:00
099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
a879360ebd UX improvements
All checks were successful
continuous-integration/drone/push Build is passing
* ignore English articles when sorting names
  * added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
  * new game: name and sort name after typing
  * new edition: name and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
ce3c4b55f0 Order devices alphabetically on new session form
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
cdc6ca1324 Fix potential division by zero
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
e7ed349356 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
5052ca7dbf Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
666dee33ba Model changes
All checks were successful
continuous-integration/drone/push Build is passing
* 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
2023-11-06 19:48:12 +01:00
e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
4552cf7616 Version 1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
e67aa3fda1 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
8423fd02b4 Extend stats range to 2018
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
2bd07e5f2d Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
f13ed8a078 Reorder imports in views.py
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
391fcc79a8 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
a5b2854bf6 Add a button to start session from game overview
All checks were successful
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
518c0ecd56 Add more time tests for fractional numbers
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
a6cd7a3430 Do not format as float if no precision specified
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
44 changed files with 1911 additions and 173 deletions

View File

@ -10,6 +10,7 @@ steps:
- python -m pip install poetry
- poetry install
- poetry env info
- poetry run python manage.py migrate
- poetry run pytest
- name: build-prod

View File

@ -1,3 +1,77 @@
## 1.5.0 / 2023-11-14 19:27+01:00
## New
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### 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)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 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
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements

View File

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

View File

@ -66,6 +66,12 @@ textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@media screen and (min-width: 768px) {
form input,
select,

View File

@ -44,7 +44,7 @@ def format_duration(
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = minutes = seconds = 0
days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds)
@ -55,7 +55,7 @@ def format_duration(
minutes, seconds = divmod(remainder, minute_seconds)
literals = {
"d": str(days),
"H": str(hours),
"H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes),
"s": str(seconds),
"r": str(seconds_total),
@ -66,9 +66,12 @@ def format_duration(
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if format_spec:
# Format the number according to the specifier
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string

9
common/utils.py Normal file
View File

@ -0,0 +1,9 @@
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

@ -6,7 +6,6 @@ custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
@ -15,10 +14,12 @@ class SessionForm(forms.ModelForm):
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name"),
widget=autofocus_select_widget,
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
@ -37,19 +38,32 @@ class SessionForm(forms.ModelForm):
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})"
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField(
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name")
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase
fields = [
@ -57,27 +71,49 @@ class PurchaseForm(forms.ModelForm):
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "wikidata"]
fields = ["name", "sort_name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}

View File

@ -0,0 +1,51 @@
# 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

@ -0,0 +1,141 @@
# 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

@ -0,0 +1,41 @@
# 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

@ -0,0 +1,34 @@
# 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

@ -0,0 +1,17 @@
# 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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,24 @@
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

@ -0,0 +1,18 @@
# 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

@ -0,0 +1,21 @@
# 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

@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -10,22 +10,70 @@ from django.db.models import F, Manager, Sum
class Game(models.Model):
name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50)
def __str__(self):
return self.name
class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
year_released = models.IntegerField(default=datetime.today().year)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
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)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model):
PHYSICAL = "ph"
@ -46,16 +94,38 @@ class Purchase(models.Model):
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
)
def __str__(self):
platform_info = self.platform
@ -63,10 +133,18 @@ class Purchase(models.Model):
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
super().save(*args, **kwargs)
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
def __str__(self):
return self.name
@ -89,13 +167,19 @@ class Session(models.Model):
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = ", manual" if self.duration_manual != None else ""
mark = ", manual" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
@ -107,7 +191,7 @@ class Session(models.Model):
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if not self.duration_manual in (None, 0, timedelta(0)):
if self.is_manual():
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
@ -117,6 +201,9 @@ class Session(models.Model):
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property
def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted()
@ -126,6 +213,12 @@ class Session(models.Model):
self.duration_calculated = self.timestamp_end - self.timestamp_start
else:
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)
@ -135,15 +228,17 @@ class Device(models.Model):
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

View File

@ -755,6 +755,10 @@ select {
position: absolute;
}
.relative {
position: relative;
}
.bottom-2 {
bottom: 0.5rem;
}
@ -791,14 +795,27 @@ select {
margin-bottom: 1rem;
}
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
@ -811,18 +828,14 @@ select {
margin-top: 1rem;
}
.ml-8 {
margin-left: 2rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.inline {
display: inline;
}
@ -839,10 +852,6 @@ select {
display: none;
}
.h-6 {
height: 1.5rem;
}
.h-4 {
height: 1rem;
}
@ -851,26 +860,34 @@ select {
height: 1.25rem;
}
.h-6 {
height: 1.5rem;
}
.min-h-screen {
min-height: 100vh;
}
.w-6 {
width: 1.5rem;
}
.w-full {
width: 100%;
}
.w-5 {
width: 1.25rem;
}
.w-6 {
width: 1.5rem;
}
.w-7 {
width: 1.75rem;
}
.w-auto {
width: auto;
}
.w-full {
width: 100%;
}
.max-w-screen-lg {
max-width: 1024px;
}
@ -913,6 +930,10 @@ select {
justify-content: space-between;
}
.gap-2 {
gap: 0.5rem;
}
.self-center {
align-self: center;
}
@ -935,6 +956,10 @@ select {
border-radius: 0.5rem;
}
.rounded-sm {
border-radius: 0.125rem;
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
@ -945,26 +970,26 @@ select {
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 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-violet-600 {
--tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.p-4 {
padding: 1rem;
}
@ -974,6 +999,11 @@ select {
padding-right: 0.5rem;
}
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
@ -992,8 +1022,8 @@ select {
padding-right: 1rem;
}
.pl-8 {
padding-left: 2rem;
.pt-1 {
padding-top: 0.25rem;
}
.text-center {
@ -1014,6 +1044,11 @@ select {
line-height: 2.5rem;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
@ -1042,6 +1077,11 @@ select {
font-style: italic;
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
@ -1182,6 +1222,15 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity));
}
:is(.dark form input:disabled),:is(.dark
select:disabled),:is(.dark
textarea:disabled) {
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) {
form input,
select,
@ -1277,16 +1326,16 @@ th label {
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 {
--tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
}
.hover\:bg-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-violet-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
@ -1312,11 +1361,6 @@ th label {
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
}
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-violet-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
@ -1334,6 +1378,10 @@ th label {
--tw-ring-offset-color: #ddd6fe;
}
.group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity));
@ -1389,6 +1437,10 @@ th label {
padding-left: 1rem;
}
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 {
text-decoration-thickness: 2px;
}
@ -1399,6 +1451,10 @@ th label {
display: block;
}
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto {
width: auto;
}

View File

@ -1,29 +1,24 @@
/**
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
import { syncSelectInputUntilChanged } from './utils.js';
sourceElement.addEventListener("change", sourceElementHandler);
targetElement.addEventListener("focus", targetElementHandler);
}
let syncData = [
{
"source": "#id_game",
"source_value": "dataset.name",
"target": "#id_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "textContent",
"target": "#id_sort_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "dataset.year",
"target": "#id_year_released",
"target_value": "value"
},
]
window.addEventListener("load", () => {
syncSelectInputUntilChanged("#id_game", "#id_name");
});
syncSelectInputUntilChanged(syncData, "form");

View File

@ -0,0 +1,12 @@
import { syncSelectInputUntilChanged } from './utils.js'
let syncData = [
{
"source": "#id_name",
"source_value": "value",
"target": "#id_sort_name",
"target_value": "value"
}
]
syncSelectInputUntilChanged(syncData, "form")

View File

@ -0,0 +1,31 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
let syncData = [
{
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
let myConfig = [
() => {
return getEl("#id_type").value == "game";
},
["#id_name", "#id_related_purchase"],
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
}
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
}

View File

@ -3,7 +3,7 @@
* @param {Date} date
* @returns {string}
*/
export function toISOUTCString(date) {
function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
@ -14,3 +14,122 @@ export function toISOUTCString(date) {
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1))
}
else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByName(selector)
}
}
/**
* @description Does something to elements when something happens.
* @param {() => boolean} condition The condition that is being tested.
* @param {string[]} targetElements
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
*/
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error("Element ${elementName} doesn't exist.");
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error("Element ${elementName} doesn't exist.");
} else {
callbackfn2(el);
}
});
}
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
@ -15,3 +16,10 @@
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
@ -10,13 +11,19 @@
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Purchase"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -25,19 +25,37 @@
<div class="w-full md:block md:w-auto">
<ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{% if game_available and platform_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% if purchase_available %}
<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 %}
<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>
{% if game_available and platform_available %}
<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>
{% endif %}
<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>
{% if edition_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
<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="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
<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 %}
<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>
{% endif %}
</ul>

View File

@ -1,10 +1,13 @@
<button
type="button"
{% comment %}
title
text
{% endcomment %}
<a
href="{{ link }}"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 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 text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
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"
>
<svg
{% comment %} <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@ -18,5 +21,6 @@
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{% endcomment %}
{{ text }}
</button>
</a>

View File

@ -0,0 +1,26 @@
{% comment %}
title
text
{% endcomment %}
<button
type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 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 text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{{ text }}
</button>

View File

@ -10,8 +10,8 @@
<div class="mx-auto text-center my-4">
<a
id="last-session-start"
href="{% url 'start_session' last.id %}"
hx-get="{% url 'start_session' last.id %}"
href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-indicator="#indicator"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
@ -19,7 +19,7 @@
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}"
>
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}

199
games/templates/stats.html Normal file
View File

@ -0,0 +1,199 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% block content %}
<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">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="flex flex-column flex-wrap justify-center">
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<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>
<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 font-mono">{{ this_year_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">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</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>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<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 'view_game' game.id %}">{{ game.name }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<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.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</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 ({{ year }} 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 }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</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>
{% endblock content %}

View File

@ -15,9 +15,7 @@
<h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ first_session.timestamp_start | date:"M Y"}}
{{ last_session.timestamp_start | date:"M Y"}}) </h2>
({{ playrange }}) </h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul>
@ -44,10 +42,30 @@
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %}
<li>
<ul>
{% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-6 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Sessions <span class="dark:text-slate-500">({{ sessions.count }})</span></h1>
</li>
{% endif %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500">
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
</h1>
<ul>
{% for session in sessions %}
<li class="sm:pl-2 flex items-center">

View File

@ -13,15 +13,25 @@ urlpatterns = [
path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"),
path(
"add-session-for-purchase/<int:purchase_id>",
views.add_session,
name="add_session_for_purchase",
),
path(
"update-session/by-session/<int:session_id>",
views.update_session,
name="update_session",
),
path(
"start-session/<int:last_session_id>",
views.start_session,
name="start_session",
"start-session-same-as-last/<int:last_session_id>",
views.start_session_same_as_last,
name="start_session_same_as_last",
),
path(
"start-session/<int:game_id>",
views.start_game_session,
name="start_game_session",
),
# path(
# "delete_session/by-id/<int:session_id>",
@ -29,7 +39,17 @@ urlpatterns = [
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"),
path(
"add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path("add-edition/", views.add_edition, name="add_edition"),
path(
"add-edition-for-game/<int:game_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
@ -68,4 +88,10 @@ urlpatterns = [
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
]

View File

@ -1,10 +1,14 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta
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.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 .forms import (
GameForm,
@ -27,21 +31,39 @@ def model_counts(request):
}
def add_session(request):
context = {}
initial = {}
def stats_dropdown_year_range(request):
result = {
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
return result
now = now_with_tz()
initial["timestamp_start"] = now
def add_session(request, purchase_id=None):
context = {}
initial = {"timestamp_start": now_with_tz()}
last = Session.objects.all().last()
if last != None:
initial["purchase"] = last.purchase
if request.method == "POST":
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session"
context["form"] = form
@ -55,6 +77,25 @@ def update_session(request, session_id=None):
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):
context = {}
session = Session.objects.get(id=session_id)
@ -67,6 +108,7 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
@ -76,9 +118,11 @@ def edit_purchase(request, purchase_id=None):
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
return render(request, "add.html", context)
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
@ -97,7 +141,17 @@ def view_game(request, game_id=None):
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
context["purchases"] = Purchase.objects.filter(edition__game_id=game_id)
game_purchases = (
Purchase.objects.filter(edition__game_id=game_id)
.filter(type=Purchase.GAME)
.order_by("date_purchased")
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
@ -110,12 +164,21 @@ def view_game(request, game_id=None):
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
context["last_session"] = context["sessions"].first()
context["first_session"] = context["sessions"].last()
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
context["playrange"] = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
@ -128,6 +191,7 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
@ -140,7 +204,25 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context)
def start_session(request, last_session_id: int):
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
@ -212,41 +294,226 @@ def list_sessions(
return render(request, "list_sessions.html", context)
def add_purchase(request):
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 = now_with_tz().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
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.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.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 = this_year_purchases_without_refunded.filter(
date_finished__isnull=True
).filter(
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
)
* 100
)
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.intersection(
purchases_finished_this_year
).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")
)
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.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year)
.count()
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_year_played_purchases.filter(
edition__year_released=year
).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,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded.count())
),
"all_finished_this_year": purchases_finished_this_year.order_by(
"date_finished"
),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"refunded_percent": int(
safe_division(
this_year_purchases_refunded.count(),
this_year_purchases_with_currency.count(),
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"backlog_decrease_count": backlog_decrease_count,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request, edition_id=None):
context = {}
now = datetime.now()
initial = {"date_purchased": now}
initial = {"date_purchased": now_with_tz()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
purchase = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
else:
form = PurchaseForm(initial=initial)
context["form"] = form
context["title"] = "Add New Purchase"
return render(request, "add.html", context)
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
def add_game(request):
context = {}
form = GameForm(request.POST or None)
if form.is_valid():
form.save()
game = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
return render(request, "add.html", context)
context["script_name"] = "add_game.js"
return render(request, "add_game.html", context)
def add_edition(request):
def add_edition(request, game_id=None):
context = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
form.save()
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)

View File

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

View File

@ -0,0 +1,89 @@
import django
import os
from django.test import TestCase
from django.urls import reverse
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session, Platform
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class PathWorksTest(TestCase):
def setUp(self) -> None:
pl = Platform(name="Test Platform")
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.testSession = s
return super().setUp()
def test_add_device_returns_200(self):
url = reverse("add_device")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_platform_returns_200(self):
url = reverse("add_platform")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_game_returns_200(self):
url = reverse("add_game")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_session_returns_200(self):
url = reverse("add_session")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_session_returns_200(self):
id = self.testSession.id
url = reverse("edit_session", args=[id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_view_game_returns_200(self):
url = reverse("view_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_game_returns_200(self):
url = reverse("edit_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_list_sessions_returns_200(self):
url = reverse("list_sessions")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -0,0 +1,38 @@
import django
import os
from django.test import TestCase
from django.db import models
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class FormatDurationTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.assertEqual(
s.duration_formatted(),
"02:40",
)

View File

@ -6,7 +6,6 @@ from common.time import format_duration
class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None:
return super().setUp()
def test_only_days(self):
@ -19,6 +18,21 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H hours")
self.assertEqual(result, "1 hours")
def test_only_hours_fractional(self):
delta = timedelta(hours=1)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "1.0 hours")
def test_less_than_hour_with_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "0.5 hours")
def test_less_than_hour_without_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%H hours")
self.assertEqual(result, "0 hours")
def test_overflow_hours(self):
delta = timedelta(hours=25)
result = format_duration(delta, "%H hours")
@ -69,6 +83,16 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%r seconds")
self.assertEqual(result, "0 seconds")
def test_specific(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%H:%m")
self.assertEqual(result, "2:40")
def test_specific_precise_if_unncessary(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%02.0H:%02.0m")
self.assertEqual(result, "02:40")
def test_all_at_once(self):
delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
result = format_duration(

View File

@ -68,6 +68,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"games.views.model_counts",
"games.views.stats_dropdown_year_range",
],
},
},
@ -149,5 +150,3 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else:
CSRF_TRUSTED_ORIGINS = []
USE_L10N = False