1 Commits

Author SHA1 Message Date
241aa9dc13 Add manage page
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been skipped
2024-07-11 14:05:25 +02:00
57 changed files with 1160 additions and 4097 deletions

1
.envrc
View File

@ -1 +0,0 @@
use nix

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 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
View File

@ -8,5 +8,3 @@ db.sqlite3
/static/ /static/
dist/ dist/
.DS_Store .DS_Store
.python-version
.direnv

View File

@ -1,13 +1,10 @@
repos: repos:
# disable due to incomaptible formatting between - repo: https://github.com/psf/black
# black and ruff rev: 22.12.0
# TODO: replace with ruff when it works on NixOS hooks:
# - repo: https://github.com/psf/black - id: black
# rev: 24.8.0
# hooks:
# - id: black
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort
rev: 5.13.2 rev: 5.12.0
hooks: hooks:
- id: isort - id: isort
name: isort (python) name: isort (python)

View File

@ -1,11 +0,0 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}

26
.vscode/settings.json vendored
View File

@ -4,30 +4,8 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "strict", "python.analysis.typeCheckingMode": "basic",
"[python]": { "[python]": {
"editor.defaultFormatter": "charliermarsh.ruff", "editor.defaultFormatter": "ms-python.black-formatter"
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
}, },
"ruff.path": ["/nix/store/s3q6qc2954x62bkcs9dwaxyiqchl7j01-ruff-0.5.6/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",
"javascriptreact",
"typescript",
"javascript",
"vue-html",
"vue",
"php",
"markdown",
"coffeescript",
"svelte",
"astro",
"erb",
"django-html"
]
} }

View File

@ -1,27 +1,11 @@
## Unreleased ## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
## Improved ## Improved
* mark refunded purchases red on game overview * mark refunded purchases red on game overview
* increase session count on game overview when starting a new session * increase session count on game overview when starting a new session
* game overview:
* sort purchases also by date purchased (on top of date released)
* improve header format, make it more appealing
* ignore manual sessions when calculating session average
* stats: improve purchase name consistency
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
## Fixed ## Fixed
* Fix title not being displayed on the Recent sessions page * Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
## 1.5.2 / 2024-01-14 21:27+01:00 ## 1.5.2 / 2024-01-14 21:27+01:00

View File

@ -3,7 +3,6 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm: npm:
npm install npm install
@ -11,26 +10,17 @@ npm:
css: common/input.css css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate poetry run python manage.py migrate
init: dev: migrate
pyenv install -s $(PYTHON_VERSION) poetry run python manage.py runserver
pyenv local $(PYTHON_VERSION)
pip install poetry
poetry install
npm install
dev:
@npx concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"poetry run python -Wa manage.py runserver" \
"npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch"
caddy: caddy:
caddy run --watch caddy run --watch

View File

@ -1,15 +1,3 @@
# Timetracker # Timetracker
A simple game catalogue and play session tracker. A simple game catalogue and play session tracker.
# Development
The project uses `pyenv` to manage installed Python versions.
If you have `pyenv` installed, you can simply run:
```
make init
```
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
Afterwards, you can start the development server using `make dev`.

View File

@ -4,7 +4,7 @@
@font-face { @font-face {
font-family: "IBM Plex Mono"; font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2"); src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400; font-weight: 400;
font-style: normal; font-style: normal;
} }
@ -23,33 +23,18 @@
font-style: normal; font-style: normal;
} }
@font-face { a:hover {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans Condensed";
src: url("fonts/IBMPlexSansCondensed-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
/* a:hover {
text-decoration-color: #ff4400; text-decoration-color: #ff4400;
color: rgb(254, 185, 160); color: rgb(254, 185, 160);
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} */ }
form label { form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} }
.responsive-table { .responsive-table {
@apply dark:text-white mx-auto table-fixed; @apply dark:text-white mx-auto;
} }
.responsive-table tr:nth-child(even) { .responsive-table tr:nth-child(even) {
@ -70,20 +55,11 @@ form label {
} }
@layer utilities { @layer utilities {
.min-w-20char {
min-width: 20ch;
}
.max-w-20char { .max-w-20char {
max-width: 20ch; max-width: 20ch;
} }
.min-w-30char {
min-width: 30ch;
}
.max-w-30char {
max-width: 30ch;
}
.max-w-35char { .max-w-35char {
max-width: 35ch; max-width: 40ch;
} }
.max-w-40char { .max-w-40char {
max-width: 40ch; max-width: 40ch;
@ -126,6 +102,14 @@ textarea:disabled {
@apply mx-1; @apply mx-1;
} }
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container { .basic-button-container {
@apply flex space-x-2 justify-center; @apply flex space-x-2 justify-center;
} }
@ -133,39 +117,3 @@ textarea:disabled {
.basic-button { .basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out; @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
} }
.markdown-content ul {
list-style-type: disc;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ul,
.markdown-content ol {
list-style-position: outside;
padding-left: 1em;
}
.markdown-content ul ul,
.markdown-content ul ol,
.markdown-content ol ul,
.markdown-content ol ol {
list-style-type: circle;
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1em;
}
/* .truncate-container {
@apply inline-block relative;
a {
@apply inline-block truncate max-w-20char transition-all group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4;
}
} */

View File

@ -12,7 +12,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration( def format_duration(
duration: timedelta | int | float | None, format_string: str = "%H hours" duration: timedelta | int | None, format_string: str = "%H hours"
) -> str: ) -> str:
""" """
Format timedelta into the specified format_string. Format timedelta into the specified format_string.

View File

@ -1,32 +1,3 @@
from random import choices
from string import ascii_lowercase
from typing import Any
from django.template.loader import render_to_string
from django.utils.safestring import mark_safe
def Popover(
wrapped_content: str,
popover_content: str = "",
) -> str:
id = randomid()
if popover_content == "":
popover_content = wrapped_content
content = f"<span data-popover-target={id}>{wrapped_content}</span>"
result = mark_safe(
str(content)
+ render_to_string(
"components/popover.html",
{
"id": id,
"children": popover_content,
},
)
)
return result
def safe_division(numerator: int | float, denominator: int | float) -> int | float: def safe_division(numerator: int | float, denominator: int | float) -> int | float:
""" """
Divides without triggering division by zero exception. Divides without triggering division by zero exception.
@ -36,45 +7,3 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo
return numerator / denominator return numerator / denominator
except ZeroDivisionError: except ZeroDivisionError:
return 0 return 0
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.
Parameters:
obj (object): The object from which to retrieve the attribute.
attr_chain (str): The chain of attributes, separated by dots.
default: The default value to return if any attribute in the chain does not exist.
Returns:
The value of the nested attribute if it exists, otherwise the default value.
"""
attrs = attr_chain.split(".")
for attr in attrs:
try:
obj = getattr(obj, attr)
except AttributeError:
return default
return obj
def truncate(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[:length-len(ellipsis)]}{ellipsis}")
if len(input_string) > 30
else input_string
)
def truncate_with_popover(input_string: str) -> str:
if (truncated := truncate(input_string)) != input_string:
print(f"Not the same after: {truncated=}")
return Popover(wrapped_content=truncated, popover_content=input_string)
else:
print("Strings are the same!")
return input_string
def randomid(seed: str = "", length: int = 10) -> str:
return seed + "".join(choices(ascii_lowercase, k=length))

View File

@ -1,89 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from games.forms import DeviceForm
from games.models import Device
from games.views import dateformat
@login_required
def list_devices(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
devices = Device.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(devices, limit)
page_obj = paginator.get_page(page_number)
devices = page_obj.object_list
context = {
"title": "Manage devices",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Name",
"Type",
"Created",
"Actions",
],
"rows": [
[
device.name,
device.get_type_display(),
device.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_device", args=[device.pk]),
"text": "Edit",
},
{
"href": reverse("delete_device", args=[device.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for device in devices
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def edit_device(request: HttpRequest, device_id: int = 0) -> HttpResponse:
device = get_object_or_404(Device, id=device_id)
form = DeviceForm(request.POST or None, instance=device)
if form.is_valid():
form.save()
return redirect("list_devices")
context: dict[str, Any] = {"form": form, "title": "Edit device"}
return render(request, "add.html", context)
@login_required
def delete_device(request: HttpRequest, device_id: int) -> HttpResponse:
device = get_object_or_404(Device, id=device_id)
device.delete()
return redirect("list_sessions")

View File

@ -1,109 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import truncate_with_popover
from games.forms import EditionForm
from games.models import Edition
from games.views import dateformat
@login_required
def list_editions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
editions = Edition.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(editions, limit)
page_obj = paginator.get_page(page_number)
editions = page_obj.object_list
context = {
"title": "Manage editions",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Game",
"Name",
"Sort Name",
"Platform",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(edition.game.name),
truncate_with_popover(
edition.name
if edition.game.name != edition.name
else "(identical)"
),
truncate_with_popover(
edition.sort_name
if edition.sort_name is not None
and edition.game.name != edition.sort_name
else "(identical)"
),
truncate_with_popover(str(edition.platform)),
edition.year_released,
edition.wikidata,
edition.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_edition", args=[edition.pk]),
"text": "Edit",
},
{
"href": reverse(
"delete_edition", args=[edition.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for edition in editions
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def edit_device(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_editions")
context: dict[str, Any] = {"form": form, "title": "Edit edition"}
return render(request, "add.html", context)
@login_required
def delete_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
edition = get_object_or_404(Edition, id=edition_id)
edition.delete()
return redirect("list_editions")

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.urls import reverse from django.urls import reverse
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
@ -46,8 +45,8 @@ class EditionChoiceField(forms.ModelChoiceField):
class IncludePlatformSelect(forms.Select): class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs) option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"): if value:
option["attrs"]["data-platform"] = platform_id option["attrs"]["data-platform"] = value.instance.platform.id
return option return option

View File

@ -1,78 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import truncate_with_popover
from games.models import Game
from games.views import dateformat
@login_required
def list_games(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
games = Game.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(games, limit)
page_obj = paginator.get_page(page_number)
games = page_obj.object_list
context = {
"title": "Manage games",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Name",
"Sort Name",
"Year",
"Wikidata",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(game.name),
truncate_with_popover(
game.sort_name
if game.sort_name is not None and game.name != game.sort_name
else "(identical)"
),
game.year_released,
game.wikidata,
game.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_game", args=[game.pk]),
"text": "Edit",
},
{
"href": reverse("delete_game", args=[game.pk]),
"text": "Delete",
"color": "red",
},
]
},
),
]
for game in games
],
},
}
return render(request, "list_purchases.html", context)

View File

@ -1,25 +0,0 @@
# Generated by Django 5.1 on 2024-08-11 15:50
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_date_dropped_purchase_infinite"),
]
operations = [
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_DEFAULT,
to="games.device",
),
),
]

View File

@ -1,19 +0,0 @@
# Generated by Django 5.1 on 2024-08-11 16:48
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0035_alter_session_device'),
]
operations = [
migrations.AlterField(
model_name='edition',
name='platform',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform'),
),
]

View File

@ -2,7 +2,7 @@ from datetime import timedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import F, Sum from django.db.models import F, Manager, Sum
from django.utils import timezone from django.utils import timezone
from common.time import format_duration from common.time import format_duration
@ -15,31 +15,32 @@ class Game(models.Model):
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self): def __str__(self):
return self.name 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
class Platform(models.Model): self.sort_name = get_sort_name(self.name)
name = models.CharField(max_length=255) super().save(*args, **kwargs)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class Edition(models.Model): class Edition(models.Model):
class Meta: class Meta:
unique_together = [["name", "platform", "year_released"]] 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)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.SET_DEFAULT, null=True, blank=True, default=None "Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
) )
year_released = models.IntegerField(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) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
@ -48,6 +49,19 @@ class Edition(models.Model):
def __str__(self): def __str__(self):
return self.sort_name 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): class PurchaseQueryset(models.QuerySet):
def refunded(self): def refunded(self):
@ -95,9 +109,9 @@ class Purchase(models.Model):
objects = PurchaseQueryset().as_manager() objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey(Edition, on_delete=models.CASCADE) edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey( platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True "Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
) )
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
@ -112,7 +126,7 @@ class Purchase(models.Model):
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True) name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"self", "Purchase",
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
default=None, default=None,
null=True, null=True,
@ -124,11 +138,9 @@ class Purchase(models.Model):
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 "",
( f"{self.edition.platform} version on {self.platform}"
f"{self.edition.platform} version on {self.platform}" if self.platform != self.edition.platform
if self.platform != self.edition.platform else self.platform,
else self.platform
),
self.edition.year_released, self.edition.year_released,
self.get_ownership_type_display(), self.get_ownership_type_display(),
] ]
@ -147,6 +159,15 @@ class Purchase(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self): def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted()) return format_duration(self.total_duration_unformatted())
@ -157,32 +178,19 @@ class SessionQuerySet(models.QuerySet):
) )
return result["duration"] return result["duration"]
def calculated_duration_formatted(self):
return format_duration(self.calculated_duration_unformatted())
def calculated_duration_unformatted(self):
result = self.aggregate(duration=Sum(F("duration_calculated")))
return result["duration"]
def without_manual(self):
return self.exclude(duration_calculated__iexact=0)
def only_manual(self):
return self.filter(duration_calculated__iexact=0)
class Session(models.Model): class Session(models.Model):
class Meta: class Meta:
get_latest_by = "timestamp_start" get_latest_by = "timestamp_start"
purchase = models.ForeignKey(Purchase, on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey( device = models.ForeignKey(
"Device", "Device",
on_delete=models.SET_DEFAULT, on_delete=models.CASCADE,
null=True, null=True,
blank=True, blank=True,
default=None, default=None,
@ -206,7 +214,7 @@ class Session(models.Model):
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
calculated = timedelta(0) calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta): if self.is_manual():
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
@ -223,15 +231,12 @@ class Session(models.Model):
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs) -> None: def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
if not self.device: if not self.device:
default_device, _ = Device.objects.get_or_create( default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"} type=Device.UNKNOWN, defaults={"name": "Unknown"}

View File

@ -1,80 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from games.models import Platform
from games.views import dateformat
@login_required
def list_platforms(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
platforms = Platform.objects.order_by("-created_at")
page_obj = None
if int(limit) != 0:
paginator = Paginator(platforms, limit)
page_obj = paginator.get_page(page_number)
platforms = page_obj.object_list
context = {
"title": "Manage platforms",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Name",
"Group",
"Created",
"Actions",
],
"rows": [
[
platform.name,
platform.group,
platform.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse(
"edit_platform", args=[platform.pk]
),
"text": "Edit",
},
{
"href": reverse(
"delete_platform", args=[platform.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for platform in platforms
],
},
}
return render(request, "list_purchases.html", context)
@login_required
def delete_platform(request: HttpRequest, platform_id: int) -> HttpResponse:
platform = get_object_or_404(Platform, id=platform_id)
platform.delete()
return redirect("list_platforms")

View File

@ -1,100 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.loader import render_to_string
from django.urls import reverse
from common.utils import truncate_with_popover
from games.models import Purchase
from games.views import dateformat
@login_required
def list_purchases(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
purchases = Purchase.objects.order_by("-date_purchased")
page_obj = None
if int(limit) != 0:
paginator = Paginator(purchases, limit)
page_obj = paginator.get_page(page_number)
purchases = page_obj.object_list
context = {
"title": "Manage purchases",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Name",
"Platform",
"Price",
"Currency",
"Infinite",
"Purchased",
"Refunded",
"Finished",
"Dropped",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(purchase.edition.name),
purchase.platform,
purchase.price,
purchase.price_currency,
purchase.infinite,
purchase.date_purchased.strftime(dateformat),
(
purchase.date_refunded.strftime(dateformat)
if purchase.date_refunded
else "-"
),
(
purchase.date_finished.strftime(dateformat)
if purchase.date_finished
else "-"
),
(
purchase.date_dropped.strftime(dateformat)
if purchase.date_dropped
else "-"
),
purchase.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse(
"edit_purchase", args=[purchase.pk]
),
"text": "Edit",
},
{
"href": reverse(
"delete_purchase", args=[purchase.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for purchase in purchases
],
},
}
return render(request, "list_purchases.html", context)

View File

@ -1,93 +0,0 @@
from typing import Any
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.http import HttpRequest, HttpResponse
from django.shortcuts import render
from django.template.loader import render_to_string
from django.urls import reverse
from common.time import format_duration
from common.utils import truncate_with_popover
from games.models import Session
from games.views import (
dateformat,
datetimeformat,
durationformat,
durationformat_manual,
timeformat,
)
@login_required
def list_sessions(request: HttpRequest) -> HttpResponse:
context: dict[Any, Any] = {}
page_number = request.GET.get("page", 1)
limit = request.GET.get("limit", 10)
sessions = Session.objects.order_by("-timestamp_start")
page_obj = None
if int(limit) != 0:
paginator = Paginator(sessions, limit)
page_obj = paginator.get_page(page_number)
sessions = page_obj.object_list
context = {
"title": "Manage sessions",
"page_obj": page_obj or None,
"elided_page_range": (
page_obj.paginator.get_elided_page_range(
page_number, on_each_side=1, on_ends=1
)
if page_obj
else None
),
"data": {
"columns": [
"Name",
"Date",
"Duration",
"Duration (manual)",
"Device",
"Created",
"Actions",
],
"rows": [
[
truncate_with_popover(session.purchase.edition.name),
f"{session.timestamp_start.strftime(datetimeformat)}{f"{session.timestamp_end.strftime(timeformat)}" if session.timestamp_end else ""}",
(
format_duration(session.duration_calculated, durationformat)
if session.duration_calculated
else "-"
),
(
format_duration(session.duration_manual, durationformat_manual)
if session.duration_manual
else "-"
),
session.device,
session.created_at.strftime(dateformat),
render_to_string(
"components/button_group_sm.html",
{
"buttons": [
{
"href": reverse("edit_session", args=[session.pk]),
"text": "Edit",
},
{
"href": reverse(
"delete_session", args=[session.pk]
),
"text": "Delete",
"color": "red",
},
]
},
),
]
for session in sessions
],
},
}
return render(request, "list_purchases.html", context)

File diff suppressed because it is too large Load Diff

View File

@ -22,16 +22,6 @@
value="Submit & Create Session" /> value="Submit & Create Session" />
</td> </td>
</tr> </tr>
{% if purchase_id %}
<tr>
<td></td>
<td>
<a href="{% url 'delete_purchase' purchase_id %}"
class="text-red-600"
onclick="return confirm('Are you sure you want to delete this purchase?');">Delete</a>
</td>
</tr>
{% endif %}
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}

View File

@ -15,76 +15,96 @@
<script src="{% static 'js/htmx.min.js' %}"></script> <script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %} {% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
<script>
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark')
}
</script>
</head> </head>
<body hx-indicator="#indicator"> <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" height="24"
width="24" width="24"
alt="loading indicator" /> alt="loading indicator" />
<div class="flex flex-col min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
{% include "navbar.html" %} <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="flex flex-1 flex-col dark:bg-gray-800 pt-8"> <div class="container flex flex-wrap items-center justify-between mx-auto">
{% block content %} <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
No content here. <span class="text-4xl">
{% endblock content %} <img src="{% static 'icons/schedule.png' %}"
</div> height="48"
{% load version %} width="48"
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> alt="Timetracker Logo"
class="mr-4" />
</span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<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 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="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_purchase' %}">Purchase</a>
</li>
{% endif %}
{% if purchase_available %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_session' %}">Session</a>
</li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %}
<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>
</div>
</div>
</nav>
{% block content %}
No content here.
{% endblock content %}
</div> </div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %} {% block scripts %}
{% endblock scripts %} {% endblock scripts %}
<script>
var themeToggleDarkIcon = document.getElementById('theme-toggle-dark-icon');
var themeToggleLightIcon = document.getElementById('theme-toggle-light-icon');
// Change the icons inside the button based on previous settings
if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
themeToggleLightIcon.classList.remove('hidden');
} else {
themeToggleDarkIcon.classList.remove('hidden');
}
var themeToggleBtn = document.getElementById('theme-toggle');
themeToggleBtn.addEventListener('click', function () {
// toggle icons inside button
themeToggleDarkIcon.classList.toggle('hidden');
themeToggleLightIcon.classList.toggle('hidden');
// if set via local storage previously
if (localStorage.getItem('color-theme')) {
if (localStorage.getItem('color-theme') === 'light') {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
}
// if NOT set via local storage previously
} else {
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('color-theme', 'light');
} else {
document.documentElement.classList.add('dark');
localStorage.setItem('color-theme', 'dark');
}
}
});
</script>
</body> </body>
</html> </html>

View File

@ -1,9 +0,0 @@
components:
gamelink: "components/game_link.html"
popover: "components/popover.html"
table: "components/table.html"
table_row: "components/table_row.html"
table_td: "components/table_td.html"
simple_table: "components/simple_table.html"
button_group_sm: "components/button_group_sm.html"
button_group_button_sm: "components/button_group_button_sm.html"

View File

@ -1,4 +1,4 @@
{% comment %} {% comment %}
title title
text text
{% endcomment %} {% endcomment %}

View File

@ -1,20 +0,0 @@
{% var color=color|default:"gray" %}
<a href="{{ href }}"
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ text }}
</button>
{% elif color == "red" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
{{ text }}
</button>
{% elif color == "green" %}
<button type="button"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white">
{{ text }}
</button>
{% endif %}
</a>

View File

@ -1,5 +0,0 @@
<div class="inline-flex rounded-md shadow-sm" role="group">
{% for button in buttons %}
{% button_group_button_sm href=button.href text=button.text color=button.color %}
{% endfor %}
</div>

View File

@ -1,4 +1,4 @@
{% comment %} {% comment %}
title title
text text
{% endcomment %} {% endcomment %}

View File

@ -1,10 +0,0 @@
<span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game_id %}">
{% if children %}
{{ children }}
{% else %}
{{ name }}
{% endif %}
</a>
</span>

View File

@ -1,7 +0,0 @@
<div data-popover
id="{{ id }}"
role="tooltip"
class="absolute z-10 invisible inline-block text-sm text-white transition-opacity duration-300 bg-white border border-purple-200 rounded-lg shadow-sm opacity-0 dark:text-white dark:border-purple-600 dark:bg-purple-800">
<div class="px-3 py-2">{{ children }}</div>
<div data-popper-arrow></div>
</div>

View File

@ -1,51 +0,0 @@
<div class="shadow-md sm:rounded-lg" hx-boost="false">
<div class="relative overflow-x-auto">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
{% table_row data=row %}
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj %}
<nav class="flex items-center flex-column md:flex-row justify-between px-6 py-4"
aria-label="Table navigation">
<span class="text-sm font-normal text-gray-500 dark:text-gray-400 mb-4 md:mb-0 block w-full md:inline md:w-auto">Showing <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.start_index }}</span><span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.end_index }}</span> of <span class="font-semibold text-gray-900 dark:text-white">{{ page_obj.paginator.count }}</span></span>
<ul class="inline-flex -space-x-px rtl:space-x-reverse text-sm h-8">
<li>
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}"
class="flex items-center justify-center px-3 h-8 ms-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-s-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Previous</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-s-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Previous</a>
{% endif %}
{% for page in elided_page_range %}
<li>
{% if page != page_obj.number %}
<a href="?page={{ page }}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">{{ page }}</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-white border bg-gray-400 border-gray-300 dark:bg-gray-900 dark:border-gray-700 dark:text-gray-200">{{ page }}</a>
{% endif %}
</li>
{% endfor %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}"
class="flex items-center justify-center px-3 h-8 leading-tight text-gray-500 bg-white border border-gray-300 rounded-e-lg hover:bg-gray-100 hover:text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white">Next</a>
{% else %}
<a aria-current="page"
class="cursor-not-allowed flex items-center justify-center px-3 h-8 leading-tight text-gray-300 bg-white border border-gray-300 rounded-e-lg dark:bg-gray-800 dark:border-gray-700 dark:text-gray-600">Next</a>
{% endif %}
</li>
</ul>
</nav>
{% endif %}
</div>

View File

@ -1,12 +0,0 @@
<div class="relative overflow-x-auto shadow-md sm:rounded-lg">
<table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400">
<thead class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400">
<tr>
{% for column in columns %}<th scope="col" class="px-6 py-3">{{ column }}</th>{% endfor %}
</tr>
</thead>
<tbody>
{{ children }}
</tbody>
</table>
</div>

View File

@ -1,15 +0,0 @@
{% fragment as default_content %}
{% for td in data %}
{% if forloop.first %}
<th scope="row"
class="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">{{ td }}</th>
{% else %}
{% #table_td %}
{{ td }}
{% /table_td %}
{% endif %}
{% endfor %}
{% endfragment %}
<tr class="odd:bg-white odd:dark:bg-gray-900 even:bg-gray-50 even:dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600 border-b">
{{ children|default:default_content }}
</tr>

View File

@ -1 +0,0 @@
<td class="px-6 py-4 min-w-20-char max-w-20-char">{{ children }}</td>

View File

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="2xl:max-w-screen-2xl md:max-w-screen-md sm:max-w-screen-sm self-center">
{% simple_table columns=data.columns rows=data.rows page_obj=page_obj elided_page_range=elided_page_range %}
</div>
{% endblock content %}

View File

@ -1,74 +1,70 @@
{% extends "base.html" %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block title %} {% block title %}
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% block content %} {% block content %}
<div class="flex-col"> {% if dataset_count >= 1 %}
{% if dataset_count >= 1 %} {% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %} <div class="mx-auto text-center my-4">
<div class="mx-auto text-center my-4"> <a id="last-session-start"
<a id="last-session-start" href="{{ start_session_url }}"
href="{{ start_session_url }}" hx-get="{{ start_session_url }}"
hx-get="{{ start_session_url }}" hx-swap="afterbegin"
hx-swap="afterbegin" hx-target=".responsive-table tbody"
hx-target=".responsive-table tbody" onClick="document.querySelector('#last-session-start').classList.add('invisible')"
onClick="document.querySelector('#last-session-start').classList.add('invisible')" class="{% if last.timestamp_end == null %}invisible{% endif %}">
class="{% if last.timestamp_end == null %}invisible{% endif %}"> {% include 'components/button_start.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>
</a> </div>
</div> {% endif %}
{% endif %} {% if dataset_count != 0 %}
{% if dataset_count != 0 %} <table class="responsive-table">
<table class="responsive-table"> <thead>
<thead> <tr>
<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">Name</th> <th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th> <th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for session in dataset %}
{% for session in dataset %}
{% partialdef session-row inline=True %} {% partialdef session-row inline=True %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group"> <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char">
<span class="inline-block relative"> <a class="underline decoration-slate-500 sm:decoration-2"
<a class="underline decoration-slate-500 sm:decoration-2 inline-block truncate max-w-20char group-hover:absolute group-hover:max-w-none group-hover:-top-8 group-hover:-left-6 group-hover:min-w-60 group-hover:px-6 group-hover:py-3.5 group-hover:bg-purple-600 group-hover:rounded-sm group-hover:outline-dashed group-hover:outline-purple-400 group-hover:outline-4 group-hover:decoration-purple-900 group-hover:text-purple-100" href="{% url 'view_game' session.purchase.edition.game.id %}">
href="{% url 'view_game' session.purchase.edition.game.id %}"> {{ session.purchase.edition }}
{{ session.purchase.edition.name }}
</a> </a>
</span> </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> {{ session.timestamp_start | date:"d/m/Y H:i" }}
{{ session.timestamp_start | date:"d/m/Y H:i" }} </td>
</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> {% if not session.timestamp_end %}
{% if not session.timestamp_end %}
{% url 'list_sessions_end_session' session.id as end_session_url %} {% url 'list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}" <a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}" hx-get="{{ end_session_url }}"
hx-target="closest tr" hx-target="closest tr"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-indicator="#indicator" hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"> onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span> <span class="text-yellow-300">Finish now?</span>
</a> </a>
{% elif session.duration_manual %} {% elif session.duration_manual %}
-- --
{% else %} {% else %}
{{ session.timestamp_end | date:"d/m/Y H:i" }} {{ session.timestamp_end | date:"d/m/Y H:i" }}
{% endif %} {% endif %}
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ session.duration_formatted }}</td>
</tr> </tr>
{% endpartialdef %} {% endpartialdef %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div> <div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %} {% endif %}
</div>
{% endblock content %} {% endblock content %}

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block content %}
<table class="table table-sm table-zebra">
<thead>
<tr class="text-left">
<th></th>
<th>Name</th>
<th>Year</th>
<th>Wikidata ID</th>
<th>Created At</th>
<th>Manage</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr>
<th>{{ game.pk }}</th>
<td>{{ game.name }}</td>
<td>{{ game.year_released }}</td>
<td>{{ game.wikidata }}</td>
<td>{{ game.created_at }}</td>
<td>
<div class="join">
<button class="btn btn-primary btn-sm join-item">
Edit
</button>
<button class="btn btn-warning btn-sm join-item">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}

View File

@ -1,136 +0,0 @@
{% load static %}
<nav class="bg-white border-gray-200 dark:bg-gray-900 dark:border-gray-700">
<div class="max-w-screen-xl flex flex-wrap items-center justify-between mx-auto p-4">
<a href="{% url 'index' %}"
class="flex items-center space-x-3 rtl:space-x-reverse">
<img src="{% static 'icons/schedule.png' %}"
height="48"
width="48"
alt="Timetracker Logo"
class="mr-4" />
<span class="self-center text-2xl font-semibold whitespace-nowrap dark:text-white">Timetracker</span>
</a>
<button data-collapse-toggle="navbar-dropdown"
type="button"
class="inline-flex items-center p-2 w-10 h-10 justify-center text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600"
aria-controls="navbar-dropdown"
aria-expanded="false">
<span class="sr-only">Open main menu</span>
<svg class="w-5 h-5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 17 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M1 1h15M1 7h15M1 13h15" />
</svg>
</button>
<div class="hidden w-full md:block md:w-auto" id="navbar-dropdown">
<ul class="flex flex-col font-medium p-4 md:p-0 mt-4 border border-gray-100 rounded-lg bg-gray-50 md:space-x-8 rtl:space-x-reverse md:flex-row md:mt-0 md:border-0 md:bg-white dark:bg-gray-800 md:dark:bg-gray-900 dark:border-gray-700">
<li>
<a href="#"
class="block py-2 px-3 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0 md:dark:text-blue-500 dark:bg-blue-600 md:dark:bg-transparent"
aria-current="page">Home</a>
</li>
<li>
<button id="dropdownNavbarNewLink"
data-dropdown-toggle="dropdownNavbarNew"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
New
<svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownNavbarNew"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton">
<li>
<a href="{% url 'add_device' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Device</a>
</li>
<li>
<a href="{% url 'add_game' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Game</a>
</li>
<li>
<a href="{% url 'add_edition' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Edition</a>
</li>
<li>
<a href="{% url 'add_platform' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platform</a>
</li>
<li>
<a href="{% url 'add_purchase' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchase</a>
</li>
<li>
<a href="{% url 'add_session' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Session</a>
</li>
</ul>
</div>
</li>
<li>
<button id="dropdownNavbarManageLink"
data-dropdown-toggle="dropdownNavbarManage"
class="flex items-center justify-between w-full py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 md:w-auto dark:text-white md:dark:hover:text-blue-500 dark:focus:text-white dark:border-gray-700 dark:hover:bg-gray-700 md:dark:hover:bg-transparent">
Manage
<svg class="w-2.5 h-2.5 ms-2.5"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 10 6">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 4 4 4-4" />
</svg>
</button>
<!-- Dropdown menu -->
<div id="dropdownNavbarManage"
class="z-10 hidden font-normal bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-gray-700 dark:divide-gray-600">
<ul class="py-2 text-sm text-gray-700 dark:text-gray-400"
aria-labelledby="dropdownLargeButton">
<li>
<a href="{% url 'list_devices' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Devices</a>
</li>
<li>
<a href="{% url 'list_games' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Games</a>
</li>
<li>
<a href="{% url 'list_editions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Editions</a>
</li>
<li>
<a href="{% url 'list_platforms' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Platforms</a>
</li>
<li>
<a href="{% url 'list_purchases' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Purchases</a>
</li>
<li>
<a href="{% url 'list_sessions' %}"
class="block px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">Sessions</a>
</li>
</ul>
</div>
</li>
<li>
<a href="{% url 'stats_by_year' 0 %}"
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Stats</a>
</li>
<li>
<a href="{% url 'logout' %}"
class="block py-2 px-3 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-white md:dark:hover:text-blue-500 dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent">Log
out</a>
</li>
</ul>
</div>
</div>
</nav>

View File

@ -1,22 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
Login
{% endblock title %}
{% block content %}
<div class="flex items-center flex-col">
<h2 class="text-3xl text-white mb-8">Please log in to continue</h2>
<form method="post">
<table>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Login" />
</td>
</tr>
</form>
</table>
</div>
{% endblock content %}

View File

@ -3,15 +3,6 @@
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% load static %} {% load static %}
{% partialdef purchase-name %}
{% if purchase.type != 'game' %}
{% #gamelink game_id=purchase.edition.game.id %}
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
{% /gamelink %}
{% else %}
{% gamelink game_id=purchase.edition.game.id name=purchase.edition.name %}
{% endif %}
{% endpartialdef %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@ -42,71 +33,46 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr> </tr>
{% if total_games %} <tr>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td> </tr>
</tr>
{% endif %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr> </tr>
{% if all_finished_this_year_count %} <tr>
<tr> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td> </tr>
</tr>
{% endif %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Longest session</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">{{ longest_session_time }} ({{ longest_session_game }})</td>
{{ longest_session_time }} ({% gamelink game_id=longest_session_game.id name=longest_session_game.name %})
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Most sessions</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">{{ highest_session_count }} ({{ highest_session_count_game }})</td>
{{ highest_session_count }} ({% gamelink game_id=highest_session_count_game.id name=highest_session_count_game.name %})
</td>
</tr> </tr>
<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">Highest session average</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">
{{ highest_session_average }} ({% gamelink game_id=highest_session_average_game.id name=highest_session_average_game.name %}) {{ highest_session_average }} ({{ highest_session_average_game }})
</td> </td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">First play</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">First play</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">{{ first_play_name }} ({{ first_play_date }})</td>
{% gamelink game_id=first_play_game.id name=first_play_game.name %} ({{ first_play_date }})
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Last play</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">{{ last_play_name }} ({{ last_play_date }})</td>
{% gamelink game_id=last_play_game.id name=last_play_game.name %} ({{ last_play_date }})
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{% if month_playtime %}
<h1 class="text-5xl text-center my-6">Playtime per month</h1>
<table class="responsive-table">
<tbody>
{% for month in month_playtimes %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">{{ month.month | date:"F" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ month.playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h1 class="text-5xl text-center my-6">Purchases</h1> <h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
<tbody> <tbody>
@ -120,10 +86,6 @@
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%) {{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td> </td>
</tr> </tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ dropped_count }} ({{ dropped_percentage }}%)</td>
</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">Unfinished</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">
@ -151,7 +113,10 @@
<tbody> <tbody>
{% for game in top_10_games_by_playtime %} {% for game in top_10_games_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% gamelink game_id=game.id name=game.name %}</td> <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> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -174,104 +139,123 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% if all_finished_this_year %} <h1 class="text-5xl text-center my-6">Finished</h1>
<h1 class="text-5xl text-center my-6">Finished</h1> <table class="responsive-table">
<table class="responsive-table"> <thead>
<thead> <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> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{% if purchase.type == 'dlc' %}
{{ purchase.name }} ({{ purchase.edition.name }} DLC)
{% else %}
{{ purchase.edition.name }}
{% endif %}
</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> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for purchase in all_finished_this_year %} </table>
<tr> <h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <table class="responsive-table">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <thead>
</tr> <tr>
{% endfor %} <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
</tbody> <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</table> </tr>
{% endif %} </thead>
{% if this_year_finished_this_year %} <tbody>
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1> {% for purchase in this_year_finished_this_year %}
<table class="responsive-table">
<thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> <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> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for purchase in this_year_finished_this_year %} </table>
<tr> <h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <table class="responsive-table">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <thead>
</tr> <tr>
{% endfor %} <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
</tbody> <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</table> </tr>
{% endif %} </thead>
{% if purchased_this_year_finished_this_year %} <tbody>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1> {% for purchase in purchased_this_year_finished_this_year %}
<table class="responsive-table">
<thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> <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> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for purchase in purchased_this_year_finished_this_year %} </table>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <table class="responsive-table">
</tr> <thead>
{% endfor %} <tr>
</tbody> <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
</table> <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
{% endif %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
{% if purchased_unfinished %} </tr>
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1> </thead>
<table class="responsive-table"> <tbody>
<thead> {% for purchase in purchased_unfinished %}
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> <a class="underline decoration-slate-500 sm:decoration-2"
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type == "dlc" %}({{ 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> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for purchase in purchased_unfinished %} </table>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</td> <h1 class="text-5xl text-center my-6">All Purchases</h1>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> <table class="responsive-table">
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> <thead>
</tr> <tr>
{% endfor %} <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
</tbody> <th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
</table> <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
{% endif %} </tr>
{% if all_purchased_this_year %} </thead>
<h1 class="text-5xl text-center my-6">All Purchases</h1> <tbody>
<table class="responsive-table"> {% for purchase in all_purchased_this_year %}
<thead>
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th> <a class="underline decoration-slate-500 sm:decoration-2"
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> 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> </tr>
</thead> {% endfor %}
<tbody> </tbody>
{% for purchase in all_purchased_this_year %} </table>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{% partial purchase-name %}</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>
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -3,94 +3,21 @@
{{ title }} {{ title }}
{% endblock title %} {% endblock title %}
{% load static %} {% load static %}
{% load markdown_extras %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div id="game-info" class="mb-10"> <h1 class="text-4xl flex items-center">
<div class="flex gap-5 mb-3"> {{ game.name }}
<span class="text-wrap max-w-80 text-4xl"> <span class="dark:text-slate-500">(#{{ game.pk }})</span>
<span class="font-bold font-serif">{{ game.name }}</span>&nbsp;<span data-popover-target="popover-year" class="text-slate-500 text-2xl">{{ game.year_released }}</span> {% url 'edit_game' game.id as edit_url %}
{% #popover id="popover-year" %} {% include 'components/edit_button.html' with edit_url=edit_url %}
Original release year </h1>
{% /popover %} <h2 class="text-lg my-2 ml-2">
</span> {{ hours_sum }} <span class="dark:text-slate-500">total</span>
</div> {{ session_average }} <span class="dark:text-slate-500">avg</span>
<div class="flex gap-4 dark:text-slate-400 mb-3"> ({{ playrange }})
<span data-popover-target="popover-hours" class="flex gap-2 items-center"> </h2>
<svg xmlns="http://www.w3.org/2000/svg" <hr class="border-slate-500">
fill="none" <h1 class="text-3xl mt-4 mb-1">
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
{{ hours_sum }}
{% #popover id="popover-hours" %}
Total hours played
{% /popover %}
</span>
<span data-popover-target="popover-sessions"
class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5" />
</svg>
{{ session_count }}
{% #popover id="popover-sessions" %}
Number of sessions
{% /popover %}
</span>
<span data-popover-target="popover-average" class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 14.25v2.25m3-4.5v4.5m3-6.75v6.75m3-9v9M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" />
</svg>
{{ session_average_without_manual }}
{% #popover id="popover-average" %}
Average playtime per session
{% /popover %}
</span>
<span data-popover-target="popover-playrange"
class="flex gap-2 items-center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z" />
</svg>
{{ playrange }}
{% #popover id="popover-playrange" %}
Earliest and latest dates played
{% /popover %}
</span>
</div>
<div class="inline-flex rounded-md shadow-sm mb-3" role="group">
<a href="{% url 'edit_game' game.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white">
Edit
</button>
</a>
<a href="{% url 'delete_game' game.id %}">
<button type="button"
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-red-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white">
Delete
</button>
</a>
</div>
</div>
<h1 class="text-3xl mt-4 mb-1 font-condensed">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span> Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1> </h1>
<ul> <ul>
@ -100,16 +27,12 @@
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
width="48"
height="48"
alt="Wikidata Icon"
src="{% static 'icons/wikidata.png' %}" />
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% url 'edit_edition' edition.id as edit_url %} {% url 'edit_edition' edition.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<ul> <ul>
{% for purchase in edition.game_purchases %} {% for purchase in edition.game_purchases %}
@ -117,14 +40,14 @@
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }} {{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %} {% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %} {% url 'edit_purchase' purchase.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<ul> <ul>
{% for related_purchase in purchase.nongame_related_purchases %} {% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center"> <li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }}) {{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %} {% url 'edit_purchase' related_purchase.id as edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
@ -132,60 +55,58 @@
</ul> </ul>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center font-condensed"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span> <span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
{% if latest_session_id %} {% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %} <a
<a class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm" class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
title="Start new session" title="Start new session"
href="{{ add_session_link }}" href="{{ add_session_link }}"
hx-get="{{ add_session_link }}" hx-get="{{ add_session_link }}"
hx-vals="js:{session_count:getSessionCount()}" hx-vals="js:{session_count:getSessionCount()}"
hx-target="#session-list" hx-target="#session-list"
hx-swap="afterbegin">New</a> hx-swap="afterbegin"
{% endif %} >New</a>
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span> and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul id="session-list"> <ul id="session-list">
{% for session in sessions %} {% for session in sessions %}
{% partialdef session-info inline=True %} {% partialdef session-info inline=True %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1"> <li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1">
{{ session.timestamp_start | date:"d/m/Y H:i" }} {{ session.timestamp_start | date:"d/m/Y H:m" }}
{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %} ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) {% url 'edit_session' session.id as edit_url %}
{% url 'edit_session' session.id as edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
{% include "components/edit_button.html" with edit_url=edit_url %} {% if not session.timestamp_end %}
{% if not session.timestamp_end %}
{% url 'view_game_end_session' session.id as end_session_url %} {% url 'view_game_end_session' session.id as end_session_url %}
<a class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center" <a
href="{{ end_session_url }}" class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
hx-get="{{ end_session_url }}" href="{{ end_session_url }}"
hx-target="closest li" hx-get="{{ end_session_url }}"
hx-swap="outerHTML" hx-target="closest li"
hx-vals="js:{session_count:getSessionCount()}" hx-swap="outerHTML"
hx-indicator="#indicator"> hx-vals="js:{session_count:getSessionCount()}"
<svg xmlns="http://www.w3.org/2000/svg" hx-indicator="#indicator"
fill="#ffffff" >
class="h-3" <svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24">
x="0px" <path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path>
y="0px" </svg>
viewBox="0 0 24 24">
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z">
</path>
</svg>
</a> </a>
{% endif %}
</li> {% endif %}
<li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li> </li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">({{ session_count }})</div> <li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endpartialdef %} <div class="hidden" hx-swap-oob="innerHTML:#session-count">
{% endfor %} ({{ session_count }})
</ul> </div>
</div> {% endpartialdef %}
<script> {% endfor %}
</ul>
</div>
<script>
function getSessionCount() { function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+"); return document.getElementById('session-count').textContent.match("[0-9]+");
} }
</script> </script>
{% endblock content %} {% endblock content %}

View File

@ -1,10 +0,0 @@
import markdown
from django import template
from django.utils.safestring import mark_safe
register = template.Library()
@register.filter(name="markdown")
def markdown_format(text):
return mark_safe(markdown.markdown(text))

View File

@ -1,11 +0,0 @@
import random
import string
from django import template
register = template.Library()
@register.simple_tag
def randomid(seed: str = "") -> str:
return str(hash(seed + "".join(random.choices(string.ascii_lowercase, k=10))))

View File

@ -1,95 +1,35 @@
from django.urls import path from django.urls import path
from games import ( from games import views
deviceviews,
editionviews,
gameviews,
platformviews,
purchaseviews,
sessionviews,
views,
)
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("device/add", views.add_device, name="add_device"),
path( path(
"device/delete/<int:device_id>", deviceviews.delete_device, name="delete_device" "list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
), ),
path("device/edit/<int:device_id>", deviceviews.edit_device, name="edit_device"), path("add-game/", views.add_game, name="add_game"),
path("device/list", deviceviews.list_devices, name="list_devices"), path("add-platform/", views.add_platform, name="add_platform"),
path("edition/add", views.add_edition, name="add_edition"), path("add-session/", views.add_session, name="add_session"),
path( path(
"edition/add/for-game/<int:game_id>", "add-session-for-purchase/<int:purchase_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", views.edit_edition, name="edit_edition"),
path("edition/list", editionviews.list_editions, name="list_editions"),
path(
"edition/<int:edition_id>/delete",
editionviews.delete_edition,
name="delete_edition",
),
path("game/add", views.add_game, name="add_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/delete", views.delete_game, name="delete_game"),
path("game/list", gameviews.list_games, name="list_games"),
path("platform/add", views.add_platform, name="add_platform"),
path("platform/<int:platform_id>/edit", views.edit_platform, name="edit_platform"),
path(
"platform/<int:platform_id>/delete",
platformviews.delete_platform,
name="delete_platform",
),
path("platform/list", platformviews.list_platforms, name="list_platforms"),
path("purchase/add", views.add_purchase, name="add_purchase"),
path("purchase/<int:purchase_id>/edit", views.edit_purchase, name="edit_purchase"),
path(
"purchase/<int:purchase_id>/delete",
views.delete_purchase,
name="delete_purchase",
),
path(
"purchase/list",
purchaseviews.list_purchases,
name="list_purchases",
),
path(
"purchase/related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path(
"purchase/add/for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path("session/add", views.add_session, name="add_session"),
path(
"session/add/for-purchase/<int:purchase_id>",
views.add_session, views.add_session,
name="add_session_for_purchase", name="add_session_for_purchase",
), ),
path( path(
"session/add/from-game/<int:session_id>", "session/clone/from-game/<int:session_id>",
views.new_session_from_existing_session, views.new_session_from_existing_session,
{"template": "view_game.html#session-info"}, {"template": "view_game.html#session-info"},
name="view_game_start_session_from_session", name="view_game_start_session_from_session",
), ),
path( path(
"session/add/from-list/<int:session_id>", "session/clone/from-list/<int:session_id>",
views.new_session_from_existing_session, views.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"}, {"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session", name="list_sessions_start_session_from_session",
), ),
path("session/<int:session_id>/edit", views.edit_session, name="edit_session"),
path(
"session/<int:session_id>/delete",
views.delete_session,
name="delete_session",
),
path( path(
"session/end/from-game/<int:session_id>", "session/end/from-game/<int:session_id>",
views.end_session, views.end_session,
@ -102,38 +42,67 @@ urlpatterns = [
{"template": "list_sessions.html#session-row"}, {"template": "list_sessions.html#session-row"},
name="list_sessions_end_session", name="list_sessions_end_session",
), ),
path("session/list", sessionviews.list_sessions, name="list_sessions"), # path(
# "delete_session/by-id/<int:session_id>",
# views.delete_session,
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"),
path( path(
"session/list/by-purchase/<int:purchase_id>", "add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_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"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"),
path(
"list-sessions/by-purchase/<int:purchase_id>",
views.list_sessions, views.list_sessions,
{"filter": "purchase"}, {"filter": "purchase"},
name="list_sessions_by_purchase", name="list_sessions_by_purchase",
), ),
path( path(
"session/list/by-platform/<int:platform_id>", "list-sessions/by-platform/<int:platform_id>",
views.list_sessions, views.list_sessions,
{"filter": "platform"}, {"filter": "platform"},
name="list_sessions_by_platform", name="list_sessions_by_platform",
), ),
path( path(
"session/list/by-game/<int:game_id>", "list-sessions/by-game/<int:game_id>",
views.list_sessions, views.list_sessions,
{"filter": "game"}, {"filter": "game"},
name="list_sessions_by_game", name="list_sessions_by_game",
), ),
path( path(
"session/list/by-edition/<int:edition_id>", "list-sessions/by-edition/<int:edition_id>",
views.list_sessions, views.list_sessions,
{"filter": "edition"}, {"filter": "edition"},
name="list_sessions_by_edition", name="list_sessions_by_edition",
), ),
path( path(
"session/list/by-ownership/<str:ownership_type>", "list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions, views.list_sessions,
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_ownership_type", name="list_sessions_by_ownership_type",
), ),
path("stats/", views.stats_alltime, name="stats_alltime"), path("stats/", views.stats, name="stats_current_year"),
path( path(
"stats/<int:year>", "stats/<int:year>",
views.stats, views.stats,

View File

@ -1,21 +1,31 @@
from datetime import datetime
from typing import Any, Callable from typing import Any, Callable
import re
from django.contrib.auth.decorators import login_required from django.db.models import (
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields Avg,
from django.db.models.functions import TruncDate, TruncMonth Count,
from django.db.models.manager import BaseManager ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
)
from django.db.models.functions import TruncDate
from django.http import ( from django.http import (
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
HttpResponseBadRequest, HttpResponseBadRequest,
HttpResponseRedirect, HttpResponseRedirect,
) )
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.shortcuts import get_object_or_404
from common.time import format_duration from common.time import format_duration
from common.utils import safe_division, safe_getattr from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm, DeviceForm,
@ -27,14 +37,8 @@ from .forms import (
) )
from .models import Edition, Game, Platform, Purchase, Session from .models import Edition, Game, Platform, Purchase, Session
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def model_counts(request):
def model_counts(request: HttpRequest) -> dict[str, bool]:
return { return {
"game_available": Game.objects.exists(), "game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(), "edition_available": Edition.objects.exists(),
@ -44,15 +48,14 @@ def model_counts(request: HttpRequest) -> dict[str, bool]:
} }
def stats_dropdown_year_range(request: HttpRequest) -> dict[str, range]: def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)} result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result return result
@login_required def add_session(request, purchase_id=None):
def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
context = {} context = {}
initial: dict[str, Any] = {"timestamp_start": timezone.now()} initial = {"timestamp_start": timezone.now()}
last = Session.objects.last() last = Session.objects.last()
if last != None: if last != None:
@ -81,7 +84,7 @@ def add_session(request: HttpRequest, purchase_id: int = 0) -> HttpResponse:
def use_custom_redirect( def use_custom_redirect(
func: Callable[..., HttpResponse], func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]: ) -> Callable[..., HttpResponse]:
""" """
Will redirect to "return_path" session variable if set. Will redirect to "return_path" session variable if set.
@ -98,11 +101,10 @@ def use_custom_redirect(
return wrapper return wrapper
@login_required
@use_custom_redirect @use_custom_redirect
def edit_session(request: HttpRequest, session_id: int) -> HttpResponse: def edit_session(request, session_id=None):
context = {} context = {}
session = get_object_or_404(Session, id=session_id) session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session) form = SessionForm(request.POST or None, instance=session)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -112,27 +114,24 @@ def edit_session(request: HttpRequest, session_id: int) -> HttpResponse:
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
@login_required
@use_custom_redirect @use_custom_redirect
def edit_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: def edit_purchase(request, purchase_id=None):
context = {} context = {}
purchase = get_object_or_404(Purchase, id=purchase_id) purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase) form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_sessions") return redirect("list_sessions")
context["title"] = "Edit Purchase" context["title"] = "Edit Purchase"
context["form"] = form context["form"] = form
context["purchase_id"] = str(purchase_id)
context["script_name"] = "add_purchase.js" context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@login_required
@use_custom_redirect @use_custom_redirect
def edit_game(request: HttpRequest, game_id: int) -> HttpResponse: def edit_game(request, game_id=None):
context = {} context = {}
purchase = get_object_or_404(Game, id=game_id) purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase) form = GameForm(request.POST or None, instance=purchase)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -142,24 +141,14 @@ def edit_game(request: HttpRequest, game_id: int) -> HttpResponse:
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required def view_game(request, game_id=None):
def delete_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = get_object_or_404(Game, id=game_id)
game.delete()
return redirect("list_sessions")
@login_required
def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch: Prefetch[Purchase] = Prefetch( nongame_related_purchases_prefetch = Prefetch(
"related_purchases", "related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by( queryset=Purchase.objects.exclude(type=Purchase.GAME),
"date_purchased"
),
to_attr="nongame_related_purchases", to_attr="nongame_related_purchases",
) )
game_purchases_prefetch: Prefetch[Purchase] = Prefetch( game_purchases_prefetch = Prefetch(
"purchase_set", "purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch nongame_related_purchases_prefetch
@ -176,71 +165,54 @@ def view_game(request: HttpRequest, game_id: int) -> HttpResponse:
purchase__edition__game=game purchase__edition__game=game
) )
session_count = sessions.count() session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count() playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
) )
if sessions:
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
else:
playrange = "N/A"
latest_session = None
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H")) total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
total_hours_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
context = { context = {
"edition_count": editions.count(), "edition_count": editions.count(),
"editions": editions, "editions": editions,
"game": game, "game": game,
"playrange": playrange, "playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(), "purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round( "session_average": round(total_hours / int(session_count), 1),
safe_division(
total_hours_without_manual, int(session_count_without_manual)
),
1,
),
"session_count": session_count, "session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(), "sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"), "sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
"latest_session_id": safe_getattr(latest_session, "pk"), "latest_session_id": latest_session.pk,
} }
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@login_required
@use_custom_redirect @use_custom_redirect
def edit_platform(request: HttpRequest, platform_id: int) -> HttpResponse: def edit_platform(request, platform_id=None):
context = {} context = {}
platform = get_object_or_404(Platform, id=platform_id) purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=platform) form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid(): if form.is_valid():
form.save() form.save()
return redirect("list_platforms") return redirect("list_sessions")
context["title"] = "Edit Platform" context["title"] = "Edit Platform"
context["form"] = form context["form"] = form
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
@use_custom_redirect @use_custom_redirect
def edit_edition(request: HttpRequest, edition_id: int) -> HttpResponse: def edit_edition(request, edition_id=None):
context = {} context = {}
edition = get_object_or_404(Edition, id=edition_id) edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition) form = EditionForm(request.POST or None, instance=edition)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -250,7 +222,7 @@ def edit_edition(request: HttpRequest, edition_id: int) -> HttpResponse:
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request: HttpRequest) -> HttpResponse: def related_purchase_by_edition(request):
edition_id = request.GET.get("edition") edition_id = request.GET.get("edition")
if not edition_id: if not edition_id:
return HttpResponseBadRequest("Invalid edition_id") return HttpResponseBadRequest("Invalid edition_id")
@ -272,11 +244,8 @@ def clone_session_by_id(session_id: int) -> Session:
return clone return clone
@login_required
@use_custom_redirect @use_custom_redirect
def new_session_from_existing_session( def new_session_from_existing_session(request, session_id: int, template: str = ""):
request: HttpRequest, session_id: int, template: str = ""
) -> HttpResponse:
session = clone_session_by_id(session_id) session = clone_session_by_id(session_id)
if request.htmx: if request.htmx:
context = { context = {
@ -287,11 +256,8 @@ def new_session_from_existing_session(
return redirect("list_sessions") return redirect("list_sessions")
@login_required
@use_custom_redirect @use_custom_redirect
def end_session( def end_session(request, session_id: int, template: str = ""):
request: HttpRequest, session_id: int, template: str = ""
) -> HttpResponse:
session = get_object_or_404(Session, id=session_id) session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now() session.timestamp_end = timezone.now()
session.save() session.save()
@ -304,23 +270,21 @@ def end_session(
return redirect("list_sessions") return redirect("list_sessions")
@login_required # def delete_session(request, session_id=None):
def delete_session(request: HttpRequest, session_id: int = 0) -> HttpResponse: # session = Session.objects.get(id=session_id)
session = get_object_or_404(Session, id=session_id) # session.delete()
session.delete() # return redirect("list_sessions")
return redirect("list_sessions")
@login_required
def list_sessions( def list_sessions(
request: HttpRequest, request,
filter: str = "", filter="",
purchase_id: int = 0, purchase_id="",
platform_id: int = 0, platform_id="",
game_id: int = 0, game_id="",
edition_id: int = 0, edition_id="",
ownership_type: str = "", ownership_type: str = "",
) -> HttpResponse: ):
context = {} context = {}
context["title"] = "Sessions" context["title"] = "Sessions"
@ -343,6 +307,12 @@ def list_sessions(
elif filter == "ownership_type": elif filter == "ownership_type":
dataset = all_sessions.filter(purchase__ownership_type=ownership_type) dataset = all_sessions.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by(
"-timestamp_start"
)
context["title"] = "This year" context["title"] = "This year"
else: else:
dataset = all_sessions dataset = all_sessions
@ -357,225 +327,12 @@ def list_sessions(
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@login_required def stats(request, year: int = 0):
def stats_alltime(request: HttpRequest) -> HttpResponse:
year = "Alltime"
this_year_sessions = Session.objects.all().select_related("purchase__edition")
this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"),
output_field=fields.DurationField(),
)
)
longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count("edition__purchase__session"),
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.all()
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True)
.filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc.
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=True
)
)
this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=False
)
)
this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count()
)
this_year_purchases_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished_count,
this_year_purchases_without_refunded_count,
)
* 100
)
purchases_finished_this_year: BaseManager[Purchase] = Purchase.objects.finished()
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.all().order_by("date_finished")
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.all()
).order_by("date_finished")
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_calculated"))
.order_by("month")
)
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate(
session_average=Avg("edition__purchase__session__duration_calculated")
)
.order_by("-session_average")
.first()
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
this_year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
backlog_decrease_count = (
Purchase.objects.all().intersection(purchases_finished_this_year).count()
)
first_play_date = "N/A"
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game
first_play_date = first_session.timestamp_start.strftime("%x")
last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game
last_play_date = last_session.timestamp_start.strftime("%x")
all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count: int = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_2023_games": this_year_played_purchases.all().count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year_count,
all_purchased_this_year_count,
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_refunded_this_year_count": all_purchased_refunded_this_year_count,
"all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count,
"longest_session_time": (
format_duration(longest_session.duration, "%2.0Hh %2.0mm")
if longest_session
else 0
),
"longest_session_game": (
longest_session.purchase.edition.game if longest_session else None
),
"highest_session_count": (
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count if game_highest_session_count else None
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm"
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game,
"first_play_date": first_play_date,
"last_play_game": last_play_game,
"last_play_date": last_play_date,
"title": f"{year} Stats",
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
@login_required
def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
selected_year = request.GET.get("year") selected_year = request.GET.get("year")
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
return HttpResponseRedirect(reverse("stats_alltime")) year = timezone.now().year
this_year_sessions = Session.objects.filter( this_year_sessions = Session.objects.filter(
timestamp_start__year=year timestamp_start__year=year
).select_related("purchase__edition") ).select_related("purchase__edition")
@ -618,23 +375,13 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
this_year_purchases_refunded = this_year_purchases_with_currency.refunded() this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_dropped_nondropped = ( this_year_purchases_unfinished = (
this_year_purchases_without_refunded.filter(date_finished__isnull=True) this_year_purchases_without_refunded.filter(date_finished__isnull=True)
.filter(date_dropped__isnull=True)
.filter(infinite=False) .filter(infinite=False)
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) .filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC))
) # do not count battle passes etc. ) # do not count battle passes etc.
this_year_purchases_unfinished = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=True
)
)
this_year_purchases_dropped = (
this_year_purchases_unfinished_dropped_nondropped.filter(
date_dropped__isnull=False
)
)
this_year_purchases_without_refunded_count = ( this_year_purchases_without_refunded_count = (
this_year_purchases_without_refunded.count() this_year_purchases_without_refunded.count()
) )
@ -672,15 +419,6 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
) )
.values("id", "name", "total_playtime") .values("id", "name", "total_playtime")
) )
month_playtimes = (
this_year_sessions.annotate(month=TruncMonth("timestamp_start"))
.values("month")
.annotate(playtime=Sum("duration_calculated"))
.order_by("month")
)
for month in month_playtimes:
month["playtime"] = format_duration(month["playtime"], "%2.0H")
highest_session_average_game = ( highest_session_average_game = (
Game.objects.filter(edition__purchase__session__in=this_year_sessions) Game.objects.filter(edition__purchase__session__in=this_year_sessions)
.annotate( .annotate(
@ -709,26 +447,20 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
.count() .count()
) )
first_play_name = "N/A"
first_play_date = "N/A" first_play_date = "N/A"
last_play_name = "N/A"
last_play_date = "N/A" last_play_date = "N/A"
first_play_game = None
last_play_game = None
if this_year_sessions: if this_year_sessions:
first_session = this_year_sessions.earliest() first_session = this_year_sessions.earliest()
first_play_game = first_session.purchase.edition.game first_play_name = first_session.purchase.edition.name
first_play_date = first_session.timestamp_start.strftime("%x") first_play_date = first_session.timestamp_start.strftime("%x")
last_session = this_year_sessions.latest() last_session = this_year_sessions.latest()
last_play_game = last_session.purchase.edition.game last_play_name = last_session.purchase.edition.name
last_play_date = last_session.timestamp_start.strftime("%x") last_play_date = last_session.timestamp_start.strftime("%x")
all_purchased_this_year_count = this_year_purchases_with_currency.count() all_purchased_this_year_count = this_year_purchases_with_currency.count()
all_purchased_refunded_this_year_count = this_year_purchases_refunded.count() all_purchased_refunded_this_year_count = this_year_purchases_refunded.count()
this_year_purchases_dropped_count = this_year_purchases_dropped.count()
this_year_purchases_dropped_percentage = int(
safe_division(this_year_purchases_dropped_count, all_purchased_this_year_count)
* 100
)
context = { context = {
"total_hours": format_duration( "total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H" this_year_sessions.total_duration_unformatted(), "%2.0H"
@ -752,19 +484,21 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
"all_finished_this_year_count": purchases_finished_this_year.count(), "all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related( "this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition" "edition"
).order_by("date_finished"), ).order_by(
"date_finished"
),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related(
"edition" "edition"
).order_by("date_finished"), ).order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(), "total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"], "unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100), "unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished, "purchased_unfinished": this_year_purchases_unfinished,
"purchased_unfinished_count": this_year_purchases_unfinished_count, "purchased_unfinished_count": this_year_purchases_unfinished_count,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent, "unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"dropped_count": this_year_purchases_dropped_count,
"dropped_percentage": this_year_purchases_dropped_percentage,
"refunded_percent": int( "refunded_percent": int(
safe_division( safe_division(
all_purchased_refunded_this_year_count, all_purchased_refunded_this_year_count,
@ -779,52 +513,39 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse:
), ),
"all_purchased_this_year_count": all_purchased_this_year_count, "all_purchased_this_year_count": all_purchased_this_year_count,
"backlog_decrease_count": backlog_decrease_count, "backlog_decrease_count": backlog_decrease_count,
"longest_session_time": ( "longest_session_time": format_duration(
format_duration(longest_session.duration, "%2.0Hh %2.0mm") longest_session.duration, "%2.0Hh %2.0mm"
if longest_session )
else 0 if longest_session
), else 0,
"longest_session_game": ( "longest_session_game": longest_session.purchase.edition.name
longest_session.purchase.edition.game if longest_session else None if longest_session
), else "N/A",
"highest_session_count": ( "highest_session_count": game_highest_session_count.session_count
game_highest_session_count.session_count if game_highest_session_count
if game_highest_session_count else 0,
else 0 "highest_session_count_game": game_highest_session_count.name
), if game_highest_session_count
"highest_session_count_game": ( else "N/A",
game_highest_session_count if game_highest_session_count else None "highest_session_average": format_duration(
), highest_session_average_game.session_average, "%2.0Hh %2.0mm"
"highest_session_average": ( )
format_duration( if highest_session_average_game
highest_session_average_game.session_average, "%2.0Hh %2.0mm" else 0,
)
if highest_session_average_game
else 0
),
"highest_session_average_game": highest_session_average_game, "highest_session_average_game": highest_session_average_game,
"first_play_game": first_play_game, "first_play_name": first_play_name,
"first_play_date": first_play_date, "first_play_date": first_play_date,
"last_play_game": last_play_game, "last_play_name": last_play_name,
"last_play_date": last_play_date, "last_play_date": last_play_date,
"title": f"{year} Stats", "title": f"{year} Stats",
"month_playtimes": month_playtimes,
} }
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "stats.html", context) return render(request, "stats.html", context)
@login_required def add_purchase(request, edition_id=None):
def delete_purchase(request: HttpRequest, purchase_id: int) -> HttpResponse: context = {}
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
return redirect("list_sessions")
@login_required
def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
context: dict[str, Any] = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
if request.method == "POST": if request.method == "POST":
@ -858,9 +579,8 @@ def add_purchase(request: HttpRequest, edition_id: int = 0) -> HttpResponse:
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@login_required def add_game(request):
def add_game(request: HttpRequest) -> HttpResponse: context = {}
context: dict[str, Any] = {}
form = GameForm(request.POST or None) form = GameForm(request.POST or None)
if form.is_valid(): if form.is_valid():
game = form.save() game = form.save()
@ -877,9 +597,8 @@ def add_game(request: HttpRequest) -> HttpResponse:
return render(request, "add_game.html", context) return render(request, "add_game.html", context)
@login_required def add_edition(request, game_id=None):
def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse: context = {}
context: dict[str, Any] = {}
if request.method == "POST": if request.method == "POST":
form = EditionForm(request.POST or None) form = EditionForm(request.POST or None)
if form.is_valid(): if form.is_valid():
@ -894,7 +613,7 @@ def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return redirect("index") return redirect("index")
else: else:
if game_id: if game_id:
game = get_object_or_404(Game, id=game_id) game = Game.objects.get(id=game_id)
form = EditionForm( form = EditionForm(
initial={ initial={
"game": game, "game": game,
@ -912,9 +631,8 @@ def add_edition(request: HttpRequest, game_id: int = 0) -> HttpResponse:
return render(request, "add_edition.html", context) return render(request, "add_edition.html", context)
@login_required def add_platform(request):
def add_platform(request: HttpRequest) -> HttpResponse: context = {}
context: dict[str, Any] = {}
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -925,9 +643,8 @@ def add_platform(request: HttpRequest) -> HttpResponse:
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required def add_device(request):
def add_device(request: HttpRequest) -> HttpResponse: context = {}
context: dict[str, Any] = {}
form = DeviceForm(request.POST or None) form = DeviceForm(request.POST or None)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -938,6 +655,5 @@ def add_device(request: HttpRequest) -> HttpResponse:
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required def index(request):
def index(request: HttpRequest) -> HttpResponse: return redirect("list_sessions_recent")
return redirect("list_sessions")

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys

View File

@ -1,12 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.10",
"concurrently": "^8.2.2", "tailwindcss": "^3.3.3"
"npm-check-updates": "^16.14.20",
"tailwindcss": "^3.4.4"
},
"dependencies": {
"flowbite": "^2.4.1"
} }
} }

784
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,30 +7,29 @@ license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.dev.dependencies] [tool.poetry.group.main.dependencies]
mypy = "^1.10.1"
pyyaml = "^6.0.1"
pytest = "^8.2.2"
django-extensions = "^3.2.3"
djhtml = "^3.0.6"
djlint = "^1.34.1"
isort = "^5.13.2"
pre-commit = "^3.7.1"
django-debug-toolbar = "^4.4.2"
[tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
django = "^5.0.6" django = "^4.2.0"
gunicorn = "^22.0.0" gunicorn = "^20.1.0"
uvicorn = "^0.30.1" uvicorn = "^0.20.0"
graphene-django = "^3.2.2" graphene-django = "^3.1.5"
django-htmx = "^1.18.0" django-htmx = "^1.17.2"
django-template-partials = "^24.2" django-template-partials = "^23.4"
markdown = "^3.6"
[tool.poetry.group.dev.dependencies]
black = "^22.12.0"
mypy = "^0.991"
pyyaml = "^6.0"
pytest = "^7.2.0"
django-extensions = "^3.2.1"
werkzeug = "^2.2.2"
djhtml = "^1.5.2"
djlint = "^1.19.11"
isort = "^5.11.4"
pre-commit = "^3.5.0"
django-debug-toolbar = "^4.2.0"
slippers = "^0.6.2"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@ -1,18 +0,0 @@
{
pkgs ? import <nixpkgs> { },
}:
pkgs.mkShell {
buildInputs = with pkgs; [
nodejs
python3
poetry
ruff
];
shellHook = ''
python -m venv .venv
. .venv/bin/activate
poetry install
'';
}

View File

@ -1,21 +1,19 @@
const defaultTheme = require('tailwindcss/defaultTheme') const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = { module.exports = {
darkMode: 'class', darkMode: 'class',
content: ["./games/**/*.{html,js}", './node_modules/flowbite/**/*.js'], content: ["./games/**/*.{html,js}"],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans], 'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono], 'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif], 'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
'condensed': ['IBM Plex Sans Condensed', ...defaultTheme.fontFamily.sans], }
} },
}, },
}, plugins: [
plugins: [ require('@tailwindcss/typography'),
require('@tailwindcss/typography'), require('@tailwindcss/forms')
require('@tailwindcss/forms'), ],
require('flowbite/plugin')
],
} }

View File

@ -41,7 +41,6 @@ INSTALLED_APPS = [
"template_partials", "template_partials",
"graphene_django", "graphene_django",
"django_htmx", "django_htmx",
"slippers",
] ]
GRAPHENE = {"SCHEMA": "games.schema.schema"} GRAPHENE = {"SCHEMA": "games.schema.schema"}
@ -68,9 +67,6 @@ if DEBUG:
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"} DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
ROOT_URLCONF = "timetracker.urls" ROOT_URLCONF = "timetracker.urls"
LOGIN_URL = "/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/"
TEMPLATES = [ TEMPLATES = [
{ {
@ -86,10 +82,7 @@ TEMPLATES = [
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range", "games.views.stats_dropdown_year_range",
], ],
"builtins": [ "builtins": ["template_partials.templatetags.partials"],
"template_partials.templatetags.partials",
"slippers.templatetags.slippers",
],
}, },
}, },
] ]

View File

@ -13,10 +13,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView from django.views.generic import RedirectView
@ -24,10 +22,8 @@ from graphene_django.views import GraphQLView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker")), path("", RedirectView.as_view(url="/tracker")),
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
] ]
if settings.DEBUG: if settings.DEBUG: