Compare commits
30 Commits
6e4dd4bf96
...
purchase_s
Author | SHA1 | Date | |
---|---|---|---|
a30c54ef44
|
|||
d9fbb4b896
|
|||
4ff3692606
|
|||
8289c48896
|
|||
d1b9202337
|
|||
fde93cb875
|
|||
d1c3ac6079
|
|||
d921c2d8a6
|
|||
52513e1ed8
|
|||
cb380814a7
|
|||
5ef8c07f30
|
|||
9573c3b8ff
|
|||
c4354a1380
|
|||
a245b6ff0f
|
|||
6329d380b7
|
|||
76fbc39fed
|
|||
4b6734c173
|
|||
b505b5b430
|
|||
87553ebdc5
|
|||
ba4fc0cac5
|
|||
8cb0276215
|
|||
f9a51ee83d
|
|||
c9deba7d65
|
|||
c55fbe86b5
|
|||
0e93993498
|
|||
9fccdfbff0
|
|||
d78139a5b3
|
|||
7dc43fbf77
|
|||
5442926457
|
|||
db4c635260
|
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
|||||||
poetry install
|
poetry install
|
||||||
poetry env info
|
poetry env info
|
||||||
poetry run python manage.py migrate
|
poetry run python manage.py migrate
|
||||||
poetry run pytest
|
PROD=1 poetry run pytest
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: test
|
needs: test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,7 +1,7 @@
|
|||||||
__pycache__
|
__pycache__
|
||||||
.mypy_cache
|
.mypy_cache
|
||||||
.pytest_cache
|
.pytest_cache
|
||||||
.venv
|
.venv/
|
||||||
node_modules
|
node_modules
|
||||||
package-lock.json
|
package-lock.json
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
@ -8,3 +8,8 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
name: isort (python)
|
name: isort (python)
|
||||||
|
- repo: https://github.com/Riverside-Healthcare/djLint
|
||||||
|
rev: v1.34.0
|
||||||
|
hooks:
|
||||||
|
- id: djlint-reformat-django
|
||||||
|
- id: djlint-django
|
||||||
|
@ -23,6 +23,12 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration-color: #ff4400;
|
||||||
|
color: rgb(254, 185, 160);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
form label {
|
form label {
|
||||||
@apply dark:text-slate-400;
|
@apply dark:text-slate-400;
|
||||||
}
|
}
|
||||||
|
@ -91,6 +91,7 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
"date_purchased",
|
"date_purchased",
|
||||||
"date_refunded",
|
"date_refunded",
|
||||||
"date_finished",
|
"date_finished",
|
||||||
|
"status",
|
||||||
"price",
|
"price",
|
||||||
"price_currency",
|
"price_currency",
|
||||||
"ownership_type",
|
"ownership_type",
|
||||||
|
17
games/migrations/0033_alter_edition_unique_together.py
Normal file
17
games/migrations/0033_alter_edition_unique_together.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2023-11-28 13:43
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0032_alter_session_options_session_modified_at_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name="edition",
|
||||||
|
unique_together={("name", "platform", "year_released")},
|
||||||
|
),
|
||||||
|
]
|
26
games/migrations/0034_purchase_status.py
Normal file
26
games/migrations/0034_purchase_status.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2023-12-22 10:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("games", "0033_alter_edition_unique_together"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="purchase",
|
||||||
|
name="status",
|
||||||
|
field=models.IntegerField(
|
||||||
|
choices=[
|
||||||
|
(0, "Unplayed"),
|
||||||
|
(1, "Playing"),
|
||||||
|
(2, "Dropped"),
|
||||||
|
(3, "Finished"),
|
||||||
|
],
|
||||||
|
default=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
22
games/migrations/0035_auto_20231222_1109.py
Normal file
22
games/migrations/0035_auto_20231222_1109.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 4.2.7 on 2023-12-22 10:09
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
from games.models import Purchase
|
||||||
|
|
||||||
|
|
||||||
|
def set_default_state(apps, schema_editor):
|
||||||
|
Purchase.objects.filter(session__isnull=False).update(
|
||||||
|
status=Purchase.PurchaseState.PLAYING
|
||||||
|
)
|
||||||
|
Purchase.objects.filter(date_finished__isnull=False).update(
|
||||||
|
status=Purchase.PurchaseState.FINISHED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0034_purchase_status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [migrations.RunPython(set_default_state)]
|
@ -34,7 +34,7 @@ class Game(models.Model):
|
|||||||
|
|
||||||
class Edition(models.Model):
|
class Edition(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["name", "platform"]]
|
unique_together = [["name", "platform", "year_released"]]
|
||||||
|
|
||||||
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)
|
||||||
@ -133,6 +133,25 @@ class Purchase(models.Model):
|
|||||||
)
|
)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class PurchaseState(models.IntegerChoices):
|
||||||
|
UNPLAYED = (
|
||||||
|
0,
|
||||||
|
"Unplayed",
|
||||||
|
)
|
||||||
|
PLAYING = (1, "Playing")
|
||||||
|
DROPPED = (
|
||||||
|
2,
|
||||||
|
"Dropped",
|
||||||
|
)
|
||||||
|
FINISHED = (
|
||||||
|
3,
|
||||||
|
"Finished",
|
||||||
|
)
|
||||||
|
|
||||||
|
status = models.IntegerField(
|
||||||
|
choices=PurchaseState.choices, default=PurchaseState.UNPLAYED
|
||||||
|
)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
additional_info = [
|
additional_info = [
|
||||||
self.get_type_display() if self.type != Purchase.GAME else "",
|
self.get_type_display() if self.type != Purchase.GAME else "",
|
||||||
|
@ -1173,6 +1173,12 @@ select {
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration-color: #ff4400;
|
||||||
|
color: rgb(254, 185, 160);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
:is(.dark form label) {
|
:is(.dark form label) {
|
||||||
--tw-text-opacity: 1;
|
--tw-text-opacity: 1;
|
||||||
color: rgb(148 163 184 / var(--tw-text-opacity));
|
color: rgb(148 163 184 / var(--tw-text-opacity));
|
||||||
@ -1477,10 +1483,6 @@ th label {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md\:w-1\/2 {
|
|
||||||
width: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md\:w-auto {
|
.md\:w-auto {
|
||||||
width: auto;
|
width: auto;
|
||||||
}
|
}
|
||||||
|
@ -75,10 +75,6 @@ function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
|||||||
* @param {string} property - The property to retrieve the value from.
|
* @param {string} property - The property to retrieve the value from.
|
||||||
*/
|
*/
|
||||||
function getValueFromProperty(sourceElement, property) {
|
function getValueFromProperty(sourceElement, property) {
|
||||||
let source =
|
|
||||||
sourceElement instanceof HTMLSelectElement
|
|
||||||
? sourceElement.selectedOptions[0]
|
|
||||||
: sourceElement;
|
|
||||||
let source =
|
let source =
|
||||||
sourceElement instanceof HTMLSelectElement
|
sourceElement instanceof HTMLSelectElement
|
||||||
? sourceElement.selectedOptions[0]
|
? sourceElement.selectedOptions[0]
|
||||||
@ -205,7 +201,7 @@ export {
|
|||||||
syncSelectInputUntilChanged,
|
syncSelectInputUntilChanged,
|
||||||
getEl,
|
getEl,
|
||||||
conditionalElementHandler,
|
conditionalElementHandler,
|
||||||
disableElementsWhenFalse,
|
disableElementsWhenValueNotEqual,
|
||||||
disableElementsWhenTrue,
|
disableElementsWhenTrue,
|
||||||
getValueFromProperty,
|
getValueFromProperty,
|
||||||
};
|
};
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
|
||||||
<td>
|
<td>
|
||||||
<div class="basic-button-container">
|
<div class="basic-button-container" hx-boost="false">
|
||||||
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
|
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
|
||||||
<button class="basic-button"
|
<button class="basic-button"
|
||||||
data-target="{{ field.name }}"
|
data-target="{{ field.name }}"
|
||||||
|
@ -14,16 +14,23 @@
|
|||||||
<script src="{% static 'js/htmx.min.js' %}"></script>
|
<script src="{% static 'js/htmx.min.js' %}"></script>
|
||||||
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
<link rel="stylesheet" href="{% static 'base.css' %}" />
|
||||||
</head>
|
</head>
|
||||||
<body class="dark" hx-indicator="#indicator" hx-boost="true">
|
<body class="dark" hx-indicator="#indicator">
|
||||||
<img id="indicator"
|
<img id="indicator"
|
||||||
src="{% static 'icons/loading.png' %}"
|
src="{% static 'icons/loading.png' %}"
|
||||||
class="absolute right-3 top-3 animate-spin htmx-indicator" />
|
class="absolute right-3 top-3 animate-spin htmx-indicator"
|
||||||
|
height="24"
|
||||||
|
width="24"
|
||||||
|
alt="loading indicator" />
|
||||||
<div class="dark:bg-gray-800 min-h-screen">
|
<div class="dark:bg-gray-800 min-h-screen">
|
||||||
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
|
||||||
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
<div class="container flex flex-wrap items-center justify-between mx-auto">
|
||||||
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
||||||
<span class="text-4xl">
|
<span class="text-4xl">
|
||||||
<img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" />
|
<img src="{% static 'icons/schedule.png' %}"
|
||||||
|
height="48"
|
||||||
|
width="48"
|
||||||
|
alt="Timetracker Logo"
|
||||||
|
class="mr-4" />
|
||||||
</span>
|
</span>
|
||||||
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
|
||||||
</a>
|
</a>
|
||||||
|
@ -18,74 +18,82 @@
|
|||||||
</select>
|
</select>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-column flex-wrap justify-center">
|
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
||||||
<div class="md:w-1/2">
|
<table class="responsive-table">
|
||||||
<h1 class="text-5xl text-center my-6">Playtime</h1>
|
<tbody>
|
||||||
<table class="responsive-table">
|
<tr>
|
||||||
<tbody>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
|
</tr>
|
||||||
<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>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
|
</tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
|
<tr>
|
||||||
</tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td>
|
||||||
</tbody>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({{ longest_session_game }})</td>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
<div class="md:w-1/2">
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td>
|
||||||
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({{ highest_session_count_game }})</td>
|
||||||
<table class="responsive-table">
|
</tr>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</td>
|
||||||
<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">
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
|
{{ highest_session_average }} ({{ highest_session_average_game }})
|
||||||
</tr>
|
</td>
|
||||||
<tr>
|
</tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
|
</tbody>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
</table>
|
||||||
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
|
<h1 class="text-5xl text-center my-6">Purchases</h1>
|
||||||
</td>
|
<table class="responsive-table">
|
||||||
</tr>
|
<tbody>
|
||||||
<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">Total</td>
|
||||||
<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">{{ all_purchased_this_year.count }}</td>
|
||||||
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
|
||||||
<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">Backlog Decrease</td>
|
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<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">Unfinished</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
</tr>
|
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
<tr>
|
||||||
</div>
|
<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>
|
||||||
<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>
|
||||||
@ -129,6 +137,7 @@
|
|||||||
<tr>
|
<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 purchase-name truncate max-w-20char">Name</th>
|
||||||
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@ -136,9 +145,16 @@
|
|||||||
<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"
|
<a class="underline decoration-slate-500 sm:decoration-2"
|
||||||
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
|
href="{% url 'edit_purchase' purchase.id %}">
|
||||||
|
{% if purchase.type == 'dlc' %}
|
||||||
|
{{ purchase.name }} ({{ purchase.edition.name }} DLC)
|
||||||
|
{% else %}
|
||||||
|
{{ purchase.edition.name }}
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</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>
|
||||||
|
@ -61,7 +61,6 @@
|
|||||||
{% url 'start_game_session' game.id as add_session_link %}
|
{% 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 %}
|
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
|
||||||
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
|
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
|
||||||
|
|
||||||
</h1>
|
</h1>
|
||||||
<ul>
|
<ul>
|
||||||
{% for session in sessions %}
|
{% for session in sessions %}
|
||||||
|
@ -2,8 +2,8 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.db.models import Count, F, Prefetch, Sum
|
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
|
||||||
from django.db.models.functions import TruncDate
|
from django.db.models.functions import Extract, TruncDate
|
||||||
from django.http import (
|
from django.http import (
|
||||||
HttpRequest,
|
HttpRequest,
|
||||||
HttpResponse,
|
HttpResponse,
|
||||||
@ -30,11 +30,11 @@ from .models import Edition, Game, Platform, Purchase, Session
|
|||||||
|
|
||||||
def model_counts(request):
|
def model_counts(request):
|
||||||
return {
|
return {
|
||||||
"game_available": Game.objects.count() != 0,
|
"game_available": Game.objects.exists(),
|
||||||
"edition_available": Edition.objects.count() != 0,
|
"edition_available": Edition.objects.exists(),
|
||||||
"platform_available": Platform.objects.count() != 0,
|
"platform_available": Platform.objects.exists(),
|
||||||
"purchase_available": Purchase.objects.count() != 0,
|
"purchase_available": Purchase.objects.exists(),
|
||||||
"session_count": Session.objects.count(),
|
"session_count": Session.objects.exists(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -321,6 +321,22 @@ def stats(request, year: int = 0):
|
|||||||
if year == 0:
|
if year == 0:
|
||||||
year = timezone.now().year
|
year = timezone.now().year
|
||||||
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
|
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
|
||||||
|
this_year_sessions_with_durations = this_year_sessions.annotate(
|
||||||
|
duration=ExpressionWrapper(
|
||||||
|
F("timestamp_end") - F("timestamp_start"),
|
||||||
|
output_field=fields.DurationField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
|
||||||
|
this_year_games_with_session_counts = Game.objects.annotate(
|
||||||
|
session_count=Count(
|
||||||
|
"edition__purchase__session",
|
||||||
|
filter=Q(edition__purchase__session__timestamp_start__year=year),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
game_highest_session_count = this_year_games_with_session_counts.order_by(
|
||||||
|
"-session_count"
|
||||||
|
).first()
|
||||||
selected_currency = "CZK"
|
selected_currency = "CZK"
|
||||||
unique_days = (
|
unique_days = (
|
||||||
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||||
@ -344,8 +360,8 @@ def stats(request, year: int = 0):
|
|||||||
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
|
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
|
||||||
date_finished__isnull=True
|
date_finished__isnull=True
|
||||||
).filter(
|
).filter(
|
||||||
type=Purchase.GAME
|
Q(type=Purchase.GAME) | Q(type=Purchase.DLC)
|
||||||
) # do not count DLC etc.
|
) # do not count battle passes etc.
|
||||||
|
|
||||||
this_year_purchases_unfinished_percent = int(
|
this_year_purchases_unfinished_percent = int(
|
||||||
safe_division(
|
safe_division(
|
||||||
@ -355,6 +371,16 @@ def stats(request, year: int = 0):
|
|||||||
)
|
)
|
||||||
|
|
||||||
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||||
|
purchases_finished_this_year_with_playtime = purchases_finished_this_year.annotate(
|
||||||
|
total_playtime=Sum(
|
||||||
|
F("session__duration_calculated") + F("session__duration_manual")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for purchase in purchases_finished_this_year_with_playtime:
|
||||||
|
formatted_playtime = format_duration(purchase.total_playtime, "%2.0H")
|
||||||
|
setattr(purchase, "formatted_playtime", formatted_playtime)
|
||||||
|
|
||||||
purchases_finished_this_year_released_this_year = (
|
purchases_finished_this_year_released_this_year = (
|
||||||
purchases_finished_this_year.filter(edition__year_released=year).order_by(
|
purchases_finished_this_year.filter(edition__year_released=year).order_by(
|
||||||
"date_finished"
|
"date_finished"
|
||||||
@ -381,6 +407,14 @@ def stats(request, year: int = 0):
|
|||||||
)
|
)
|
||||||
.values("id", "name", "total_playtime")
|
.values("id", "name", "total_playtime")
|
||||||
)
|
)
|
||||||
|
highest_session_average_game = (
|
||||||
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
|
.annotate(
|
||||||
|
session_average=Avg("edition__purchase__session__duration_calculated")
|
||||||
|
)
|
||||||
|
.order_by("-session_average")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
|
||||||
for game in top_10_games_by_playtime:
|
for game in top_10_games_by_playtime:
|
||||||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
||||||
@ -418,7 +452,7 @@ def stats(request, year: int = 0):
|
|||||||
"spent_per_game": int(
|
"spent_per_game": int(
|
||||||
safe_division(total_spent, this_year_purchases_without_refunded.count())
|
safe_division(total_spent, this_year_purchases_without_refunded.count())
|
||||||
),
|
),
|
||||||
"all_finished_this_year": purchases_finished_this_year.order_by(
|
"all_finished_this_year": purchases_finished_this_year_with_playtime.order_by(
|
||||||
"date_finished"
|
"date_finished"
|
||||||
),
|
),
|
||||||
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
|
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
|
||||||
@ -444,6 +478,18 @@ def stats(request, year: int = 0):
|
|||||||
"date_purchased"
|
"date_purchased"
|
||||||
),
|
),
|
||||||
"backlog_decrease_count": backlog_decrease_count,
|
"backlog_decrease_count": backlog_decrease_count,
|
||||||
|
"longest_session_time": format_duration(
|
||||||
|
longest_session.duration if longest_session else timedelta(0),
|
||||||
|
"%2.0Hh %2.0mm",
|
||||||
|
),
|
||||||
|
"longest_session_game": longest_session.purchase.edition.name,
|
||||||
|
"highest_session_count": game_highest_session_count.session_count,
|
||||||
|
"highest_session_count_game": game_highest_session_count.name,
|
||||||
|
"highest_session_average": format_duration(
|
||||||
|
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
|
||||||
|
),
|
||||||
|
"highest_session_average_game": highest_session_average_game,
|
||||||
|
"title": f"{year} Stats",
|
||||||
}
|
}
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
|
41
poetry.lock
generated
41
poetry.lock
generated
@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aniso8601"
|
name = "aniso8601"
|
||||||
@ -143,6 +143,21 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""}
|
|||||||
argon2 = ["argon2-cffi (>=19.1.0)"]
|
argon2 = ["argon2-cffi (>=19.1.0)"]
|
||||||
bcrypt = ["bcrypt"]
|
bcrypt = ["bcrypt"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "django-debug-toolbar"
|
||||||
|
version = "4.2.0"
|
||||||
|
description = "A configurable set of panels that display various debug information about the current request/response."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
files = [
|
||||||
|
{file = "django_debug_toolbar-4.2.0-py3-none-any.whl", hash = "sha256:af99128c06e8e794479e65ab62cc6c7d1e74e1c19beb44dcbf9bad7a9c017327"},
|
||||||
|
{file = "django_debug_toolbar-4.2.0.tar.gz", hash = "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
django = ">=3.2.4"
|
||||||
|
sqlparse = ">=0.2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-extensions"
|
name = "django-extensions"
|
||||||
version = "3.2.3"
|
version = "3.2.3"
|
||||||
@ -580,13 +595,13 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "platformdirs"
|
name = "platformdirs"
|
||||||
version = "3.11.0"
|
version = "4.0.0"
|
||||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
|
{file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"},
|
||||||
{file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
|
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
@ -820,17 +835,17 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "setuptools"
|
name = "setuptools"
|
||||||
version = "68.2.2"
|
version = "69.0.2"
|
||||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
files = [
|
files = [
|
||||||
{file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"},
|
{file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"},
|
||||||
{file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"},
|
{file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
||||||
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
||||||
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
|
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
|
||||||
|
|
||||||
@ -934,19 +949,19 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "20.24.6"
|
version = "20.24.7"
|
||||||
description = "Virtual Python Environment builder"
|
description = "Virtual Python Environment builder"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
files = [
|
files = [
|
||||||
{file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"},
|
{file = "virtualenv-20.24.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"},
|
||||||
{file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"},
|
{file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
distlib = ">=0.3.7,<1"
|
distlib = ">=0.3.7,<1"
|
||||||
filelock = ">=3.12.2,<4"
|
filelock = ">=3.12.2,<4"
|
||||||
platformdirs = ">=3.9.1,<4"
|
platformdirs = ">=3.9.1,<5"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||||
@ -972,4 +987,4 @@ watchdog = ["watchdog (>=2.3)"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "49b33333953d875c6c2a26ffd1a1a2d21f75e06fe59e6619ba2900e39d2cf1bf"
|
content-hash = "e864dc8abf6c84e5bb16ac2aa937c2a70561d15f3e8a1459866b9d6507e8773e"
|
||||||
|
@ -25,6 +25,7 @@ djhtml = "^1.5.2"
|
|||||||
djlint = "^1.19.11"
|
djlint = "^1.19.11"
|
||||||
isort = "^5.11.4"
|
isort = "^5.11.4"
|
||||||
pre-commit = "^3.5.0"
|
pre-commit = "^3.5.0"
|
||||||
|
django-debug-toolbar = "^4.2.0"
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
profile = "black"
|
profile = "black"
|
||||||
|
@ -46,6 +46,7 @@ GRAPHENE = {"SCHEMA": "games.schema.schema"}
|
|||||||
if DEBUG:
|
if DEBUG:
|
||||||
INSTALLED_APPS.append("django_extensions")
|
INSTALLED_APPS.append("django_extensions")
|
||||||
INSTALLED_APPS.append("django.contrib.admin")
|
INSTALLED_APPS.append("django.contrib.admin")
|
||||||
|
INSTALLED_APPS.append("debug_toolbar")
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
@ -57,6 +58,11 @@ MIDDLEWARE = [
|
|||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if DEBUG:
|
||||||
|
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
|
||||||
|
INTERNAL_IPS = ["127.0.0.1"]
|
||||||
|
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
|
||||||
|
|
||||||
ROOT_URLCONF = "timetracker.urls"
|
ROOT_URLCONF = "timetracker.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
|
@ -28,3 +28,4 @@ urlpatterns = [
|
|||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns.append(path("admin/", admin.site.urls))
|
urlpatterns.append(path("admin/", admin.site.urls))
|
||||||
|
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))
|
||||||
|
Reference in New Issue
Block a user