1 Commits

Author SHA1 Message Date
600c0d284c Initial working API
All checks were successful
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-18 21:09:27 +01:00
35 changed files with 227 additions and 562 deletions

View File

@ -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
PROD=1 poetry run pytest poetry run pytest
build-and-push: build-and-push:
needs: test needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest

2
.gitignore vendored
View File

@ -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

View File

@ -8,8 +8,3 @@ 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

View File

@ -23,12 +23,6 @@
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;
} }

View File

@ -91,7 +91,6 @@ 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",

View File

@ -1 +0,0 @@
from .game import Mutation as GameMutation

View File

@ -1,29 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class UpdateGameMutation(graphene.Mutation):
class Arguments:
id = graphene.ID(required=True)
name = graphene.String()
year_released = graphene.Int()
wikidata = graphene.String()
game = graphene.Field(Game)
def mutate(self, info, id, name=None, year_released=None, wikidata=None):
game_instance = GameModel.objects.get(pk=id)
if name is not None:
game_instance.name = name
if year_released is not None:
game_instance.year_released = year_released
if wikidata is not None:
game_instance.wikidata = wikidata
game_instance.save()
return UpdateGameMutation(game=game_instance)
class Mutation(graphene.ObjectType):
update_game = UpdateGameMutation.Field()

View File

@ -1,6 +0,0 @@
from .device import Query as DeviceQuery
from .edition import Query as EditionQuery
from .game import Query as GameQuery
from .platform import Query as PlatformQuery
from .purchase import Query as PurchaseQuery
from .session import Query as SessionQuery

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Device
from games.models import Device as DeviceModel
class Query(graphene.ObjectType):
devices = graphene.List(Device)
def resolve_devices(self, info, **kwargs):
return DeviceModel.objects.all()

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Edition
from games.models import Game as EditionModel
class Query(graphene.ObjectType):
editions = graphene.List(Edition)
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()

View File

@ -1,18 +0,0 @@
import graphene
from games.graphql.types import Game
from games.models import Game as GameModel
class Query(graphene.ObjectType):
games = graphene.List(Game)
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
def resolve_games(self, info, **kwargs):
return GameModel.objects.all()
def resolve_game_by_name(self, info, name):
try:
return GameModel.objects.get(name=name)
except GameModel.DoesNotExist:
return None

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Platform
from games.models import Platform as PlatformModel
class Query(graphene.ObjectType):
platforms = graphene.List(Platform)
def resolve_platforms(self, info, **kwargs):
return PlatformModel.objects.all()

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Purchase
from games.models import Purchase as PurchaseModel
class Query(graphene.ObjectType):
purchases = graphene.List(Purchase)
def resolve_purchases(self, info, **kwargs):
return PurchaseModel.objects.all()

View File

@ -1,11 +0,0 @@
import graphene
from games.graphql.types import Session
from games.models import Session as SessionModel
class Query(graphene.ObjectType):
sessions = graphene.List(Session)
def resolve_sessions(self, info, **kwargs):
return SessionModel.objects.all()

View File

@ -1,44 +0,0 @@
from graphene_django import DjangoObjectType
from games.models import Device as DeviceModel
from games.models import Edition as EditionModel
from games.models import Game as GameModel
from games.models import Platform as PlatformModel
from games.models import Purchase as PurchaseModel
from games.models import Session as SessionModel
class Game(DjangoObjectType):
class Meta:
model = GameModel
fields = "__all__"
class Edition(DjangoObjectType):
class Meta:
model = EditionModel
fields = "__all__"
class Purchase(DjangoObjectType):
class Meta:
model = PurchaseModel
fields = "__all__"
class Session(DjangoObjectType):
class Meta:
model = SessionModel
fields = "__all__"
class Platform(DjangoObjectType):
class Meta:
model = PlatformModel
fields = "__all__"
class Device(DjangoObjectType):
class Meta:
model = DeviceModel
fields = "__all__"

View File

@ -1,17 +0,0 @@
# 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")},
),
]

View File

@ -1,26 +0,0 @@
# 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,
),
),
]

View File

@ -1,22 +0,0 @@
# 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)]

View File

@ -34,7 +34,7 @@ class Game(models.Model):
class Edition(models.Model): class Edition(models.Model):
class Meta: class Meta:
unique_together = [["name", "platform", "year_released"]] unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -133,25 +133,6 @@ 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 "",

View File

@ -1,30 +1,82 @@
import graphene import graphene
from graphene_django import DjangoObjectType
from games.graphql.mutations import GameMutation from .models import Device as DeviceModel
from games.graphql.queries import ( from .models import Edition as EditionModel
DeviceQuery, from .models import Game as GameModel
EditionQuery, from .models import Platform as PlatformModel
GameQuery, from .models import Purchase as PurchaseModel
PlatformQuery, from .models import Session as SessionModel
PurchaseQuery,
SessionQuery,
)
class Query( class Game(DjangoObjectType):
GameQuery, class Meta:
EditionQuery, model = GameModel
DeviceQuery, fields = "__all__"
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType): class Edition(DjangoObjectType):
pass class Meta:
model = EditionModel
fields = "__all__"
schema = graphene.Schema(query=Query, mutation=Mutation) class Purchase(DjangoObjectType):
class Meta:
model = PurchaseModel
fields = "__all__"
class Session(DjangoObjectType):
class Meta:
model = SessionModel
fields = "__all__"
class Platform(DjangoObjectType):
class Meta:
model = PlatformModel
fields = "__all__"
class Device(DjangoObjectType):
class Meta:
model = DeviceModel
fields = "__all__"
class Query(graphene.ObjectType):
games = graphene.List(Game)
game_by_name = graphene.Field(Game, name=graphene.String(required=True))
purchases = graphene.List(Purchase)
editions = graphene.List(Edition)
sessions = graphene.List(Session)
platforms = graphene.List(Platform)
devices = graphene.List(Device)
def resolve_games(self, info, **kwargs):
return GameModel.objects.all()
def resolve_game_by_name(self, info, name):
try:
return GameModel.objects.get(name=name)
except GameModel.DoesNotExist:
return None
def resolve_editions(self, info, **kwargs):
return EditionModel.objects.all()
def resolve_purchases(self, info, **kwargs):
return PurchaseModel.objects.all()
def resolve_sessions(self, info, **kwargs):
return SessionModel.objects.all()
def resolve_platforms(self, info, **kwargs):
return PlatformModel.objects.all()
def resolve_devices(self, info, **kwargs):
return DeviceModel.objects.all()
schema = graphene.Schema(query=Query)

View File

@ -1173,12 +1173,6 @@ 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));
@ -1483,6 +1477,10 @@ th label {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }

View File

@ -1,24 +1,24 @@
import { syncSelectInputUntilChanged } from "./utils.js"; import { syncSelectInputUntilChanged } from './utils.js';
let syncData = [ let syncData = [
{ {
source: "#id_game", "source": "#id_game",
source_value: "dataset.name", "source_value": "dataset.name",
target: "#id_name", "target": "#id_name",
target_value: "value", "target_value": "value"
}, },
{ {
source: "#id_game", "source": "#id_game",
source_value: "textContent", "source_value": "textContent",
target: "#id_sort_name", "target": "#id_sort_name",
target_value: "value", "target_value": "value"
}, },
{ {
source: "#id_game", "source": "#id_game",
source_value: "dataset.year", "source_value": "dataset.year",
target: "#id_year_released", "target": "#id_year_released",
target_value: "value", "target_value": "value"
}, },
]; ]
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");

View File

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

View File

@ -2,7 +2,7 @@ import {
syncSelectInputUntilChanged, syncSelectInputUntilChanged,
getEl, getEl,
disableElementsWhenTrue, disableElementsWhenTrue,
disableElementsWhenValueNotEqual, disableElementsWhenFalse,
} from "./utils.js"; } from "./utils.js";
let syncData = [ let syncData = [
@ -21,11 +21,7 @@ function setupElementHandlers() {
"#id_name", "#id_name",
"#id_related_purchase", "#id_related_purchase",
]); ]);
disableElementsWhenValueNotEqual( disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
"#id_type",
["game", "dlc"],
["#id_date_finished"]
);
} }
document.addEventListener("DOMContentLoaded", setupElementHandlers); document.addEventListener("DOMContentLoaded", setupElementHandlers);
@ -34,14 +30,14 @@ getEl("#id_type").onchange = () => {
setupElementHandlers(); setupElementHandlers();
}; };
document.body.addEventListener("htmx:beforeRequest", function (event) { document.body.addEventListener('htmx:beforeRequest', function(event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request // Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === "id_edition") { if (event.target.id === 'id_edition') {
var idEditionValue = document.getElementById("id_edition").value; var idEditionValue = document.getElementById('id_edition').value;
// Condition to check - replace this with your actual logic // Condition to check - replace this with your actual logic
if (idEditionValue != "") { if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request event.preventDefault(); // This cancels the HTMX request
} }
} }
}); });

View File

@ -7,14 +7,10 @@ for (let button of document.querySelectorAll("[data-target]")) {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (type == "now") { if (type == "now") {
targetElement.value = toISOUTCString(new Date()); targetElement.value = toISOUTCString(new Date);
} else if (type == "copy") { } else if (type == "copy") {
const oppositeName = const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
targetElement.name == "timestamp_start" document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") { } else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text"; if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local"; else targetElement.type = "datetime-local";

View File

@ -75,10 +75,7 @@ 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 = let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) { if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey]; return source.dataset[datasetKey];
@ -96,11 +93,13 @@ function getValueFromProperty(sourceElement, property) {
*/ */
function getEl(selector) { function getEl(selector) {
if (selector.startsWith("#")) { if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1)); return document.getElementById(selector.slice(1))
} else if (selector.startsWith(".")) { }
return document.getElementsByClassName(selector); else if (selector.startsWith(".")) {
} else { return document.getElementsByClassName(selector)
return document.getElementsByTagName(selector); }
else {
return document.getElementsByTagName(selector)
} }
} }
@ -117,7 +116,7 @@ function getEl(selector) {
function conditionalElementHandler(...configs) { function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => { configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) { if (condition()) {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error(`Element ${elementName} doesn't exist.`); console.error(`Element ${elementName} doesn't exist.`);
@ -126,7 +125,7 @@ function conditionalElementHandler(...configs) {
} }
}); });
} else { } else {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error(`Element ${elementName} doesn't exist.`); console.error(`Element ${elementName} doesn't exist.`);
@ -138,44 +137,16 @@ function conditionalElementHandler(...configs) {
}); });
} }
function disableElementsWhenValueNotEqual( function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([ return conditionalElementHandler([
() => { () => {
let target = getEl(targetSelect); return getEl(targetSelect).value != targetValue;
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
}, },
elementList, elementList,
(el) => { (el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled"; el.disabled = "disabled";
}, },
(el) => { (el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = ""; el.disabled = "";
}, },
]); ]);
@ -196,12 +167,4 @@ function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
]); ]);
} }
export { export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};

View File

@ -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" hx-boost="false"> <div class="basic-button-container">
<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 }}"

View File

@ -14,23 +14,16 @@
<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"> <body class="dark" hx-indicator="#indicator" hx-boost="true">
<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' %}" <img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" />
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>

View File

@ -18,82 +18,74 @@
</select> </select>
</form> </form>
</div> </div>
<h1 class="text-5xl text-center my-6">Playtime</h1> <div class="flex flex-column flex-wrap justify-center">
<table class="responsive-table"> <div class="md:w-1/2">
<tbody> <h1 class="text-5xl text-center my-6">Playtime</h1>
<tr> <table class="responsive-table">
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td> <tbody>
<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">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">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">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">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">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">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</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 ({{ 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">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">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</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 ({{ 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">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">Longest session</td> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ longest_session_time }} ({{ longest_session_game }})</td> </tbody>
</tr> </table>
<tr> </div>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td> <div class="md:w-1/2">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ highest_session_count }} ({{ highest_session_count_game }})</td> <h1 class="text-5xl text-center my-6">Purchases</h1>
</tr> <table class="responsive-table">
<tr> <tbody>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Highest session average</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">Total</td>
{{ highest_session_average }} ({{ highest_session_average_game }}) <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</td> </tr>
</tr> <tr>
</tbody> <td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
</table> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<h1 class="text-5xl text-center my-6">Purchases</h1> {{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
<table class="responsive-table"> </td>
<tbody> </tr>
<tr> <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">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
</tr> {{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
<tr> </td>
<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"> <tr>
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%) <td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr> </tr>
<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">Spendings ({{ total_spent_currency }})</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">{{ total_spent }} ({{ spent_per_game }}/game)</td>
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%) </tr>
</td> </tbody>
</tr> </table>
<tr> </div>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td> </div>
<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>
@ -137,7 +129,6 @@
<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>
@ -145,16 +136,9 @@
<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 %}"> href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
{% 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>

View File

@ -61,6 +61,7 @@
{% 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 %}

View File

@ -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 Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import Extract, TruncDate from django.db.models.functions import 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.exists(), "game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.exists(), "edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.exists(), "platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.exists(), "purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.exists(), "session_count": Session.objects.count(),
} }
@ -321,22 +321,6 @@ 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"))
@ -360,8 +344,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(
Q(type=Purchase.GAME) | Q(type=Purchase.DLC) type=Purchase.GAME
) # do not count battle passes etc. ) # do not count DLC etc.
this_year_purchases_unfinished_percent = int( this_year_purchases_unfinished_percent = int(
safe_division( safe_division(
@ -371,16 +355,6 @@ 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"
@ -407,14 +381,6 @@ 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")
@ -452,7 +418,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_with_playtime.order_by( "all_finished_this_year": purchases_finished_this_year.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(
@ -478,18 +444,6 @@ 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
View File

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. # This file is automatically @generated by Poetry 1.7.0 and should not be changed by hand.
[[package]] [[package]]
name = "aniso8601" name = "aniso8601"
@ -143,21 +143,6 @@ 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"
@ -595,13 +580,13 @@ files = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.0.0" version = "3.11.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-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, {file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
{file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, {file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
] ]
[package.extras] [package.extras]
@ -835,17 +820,17 @@ files = [
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "69.0.2" version = "68.2.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-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"},
{file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"},
] ]
[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 (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "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 (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "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"]
@ -949,19 +934,19 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
[[package]] [[package]]
name = "virtualenv" name = "virtualenv"
version = "20.24.7" version = "20.24.6"
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.7-py3-none-any.whl", hash = "sha256:a18b3fd0314ca59a2e9f4b556819ed07183b3e9a3702ecfe213f593d44f7b3fd"}, {file = "virtualenv-20.24.6-py3-none-any.whl", hash = "sha256:520d056652454c5098a00c0f073611ccbea4c79089331f60bf9d7ba247bb7381"},
{file = "virtualenv-20.24.7.tar.gz", hash = "sha256:69050ffb42419c91f6c1284a7b24e0475d793447e35929b488bf6a0aade39353"}, {file = "virtualenv-20.24.6.tar.gz", hash = "sha256:02ece4f56fbf939dbbc33c0715159951d6bf14aaf5457b092e4548e1382455af"},
] ]
[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,<5" platformdirs = ">=3.9.1,<4"
[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)"]
@ -987,4 +972,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 = "e864dc8abf6c84e5bb16ac2aa937c2a70561d15f3e8a1459866b9d6507e8773e" content-hash = "49b33333953d875c6c2a26ffd1a1a2d21f75e06fe59e6619ba2900e39d2cf1bf"

View File

@ -25,7 +25,6 @@ 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"

View File

@ -46,7 +46,6 @@ 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",
@ -58,11 +57,6 @@ 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 = [

View File

@ -28,4 +28,3 @@ 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")))