1 Commits

Author SHA1 Message Date
a30c54ef44 initial commit
All checks were successful
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Has been skipped
2023-12-22 12:42:40 +01:00
31 changed files with 844 additions and 1216 deletions

View File

@ -17,7 +17,7 @@ jobs:
poetry install poetry install
poetry env info poetry env info
poetry run python manage.py migrate poetry run python manage.py migrate
# PROD=1 poetry run pytest 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

@ -7,5 +7,3 @@ package-lock.json
db.sqlite3 db.sqlite3
/static/ /static/
dist/ dist/
.DS_Store
.python-version

View File

@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 24.3.0 rev: 22.12.0
hooks: hooks:
- id: black - id: black
- repo: https://github.com/pycqa/isort - repo: https://github.com/pycqa/isort

View File

@ -1,33 +1,8 @@
## Unreleased ## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
## Improved ## Improved
* mark refunded purchases red on game overview * game overview: improve how editions and purchases are displayed
* 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
* stats: improve purchase name consistency
* session list: use display name instead of sort name
## Fixed
* Fix title not being displayed on the Recent sessions page
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition * add purchase: only allow choosing purchases of selected edition
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 1.5.1 / 2023-11-14 21:10+01:00 ## 1.5.1 / 2023-11-14 21:10+01:00

View File

@ -1,6 +1,6 @@
FROM python:3.12.0-slim-bullseye FROM python:3.12.0-slim-bullseye
ENV VERSION_NUMBER=1.5.2 \ ENV VERSION_NUMBER=1.5.1 \
PROD=1 \ PROD=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \ PYTHONFAULTHANDLER=1 \

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

@ -117,31 +117,3 @@ th label {
.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;
}

View File

@ -7,24 +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, attr_chain, default=None):
"""
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

View File

@ -1,6 +1,5 @@
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
@ -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
@ -84,7 +83,6 @@ class PurchaseForm(forms.ModelForm):
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget, "date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@ -93,8 +91,7 @@ class PurchaseForm(forms.ModelForm):
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"date_finished", "date_finished",
"date_dropped", "status",
"infinite",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "ownership_type",

View File

@ -1,23 +0,0 @@
# Generated by Django 4.2.7 on 2024-01-03 21:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_dropped",
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name="purchase",
name="infinite",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.7 on 2023-12-22 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0033_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="status",
field=models.IntegerField(
choices=[
(0, "Unplayed"),
(1, "Playing"),
(2, "Dropped"),
(3, "Finished"),
],
default=0,
),
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2023-12-22 10:09
from django.db import migrations
from games.models import Purchase
def set_default_state(apps, schema_editor):
Purchase.objects.filter(session__isnull=False).update(
status=Purchase.PurchaseState.PLAYING
)
Purchase.objects.filter(date_finished__isnull=False).update(
status=Purchase.PurchaseState.FINISHED
)
class Migration(migrations.Migration):
dependencies = [
("games", "0034_purchase_status"),
]
operations = [migrations.RunPython(set_default_state)]

View File

@ -18,6 +18,19 @@ class Game(models.Model):
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
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model): class Edition(models.Model):
class Meta: class Meta:
@ -36,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):
@ -90,8 +116,6 @@ class Purchase(models.Model):
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True) date_finished = models.DateField(blank=True, null=True)
date_dropped = models.DateField(blank=True, null=True)
infinite = models.BooleanField(default=False)
price = models.IntegerField(default=0) price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD") price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField( ownership_type = models.CharField(
@ -109,6 +133,25 @@ class Purchase(models.Model):
) )
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
class PurchaseState(models.IntegerChoices):
UNPLAYED = (
0,
"Unplayed",
)
PLAYING = (1, "Playing")
DROPPED = (
2,
"Dropped",
)
FINISHED = (
3,
"Finished",
)
status = models.IntegerField(
choices=PurchaseState.choices, default=PurchaseState.UNPLAYED
)
def __str__(self): def __str__(self):
additional_info = [ additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "", self.get_type_display() if self.type != Purchase.GAME else "",

View File

@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.4.3 | MIT License | https://tailwindcss.com ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -32,11 +32,9 @@
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default. 6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/ */
html, html {
:host {
line-height: 1.5; line-height: 1.5;
/* 1 */ /* 1 */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@ -46,14 +44,12 @@ html,
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal; font-variation-settings: normal;
/* 6 */ /* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
} }
/* /*
@ -125,10 +121,8 @@ strong {
} }
/* /*
1. Use the user's configured `mono` font-family by default. 1. Use the user's configured `mono` font family by default.
2. Use the user's configured `mono` font-feature-settings by default. 2. Correct the odd `em` font sizing in all browsers.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/ */
code, code,
@ -137,12 +131,8 @@ samp,
pre { pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em; font-size: 1em;
/* 4 */ /* 2 */
} }
/* /*
@ -211,8 +201,6 @@ textarea {
/* 1 */ /* 1 */
line-height: inherit; line-height: inherit;
/* 1 */ /* 1 */
letter-spacing: inherit;
/* 1 */
color: inherit; color: inherit;
/* 1 */ /* 1 */
margin: 0; margin: 0;
@ -236,9 +224,9 @@ select {
*/ */
button, button,
input:where([type='button']), [type='button'],
input:where([type='reset']), [type='reset'],
input:where([type='submit']) { [type='submit'] {
-webkit-appearance: button; -webkit-appearance: button;
/* 1 */ /* 1 */
background-color: transparent; background-color: transparent;
@ -579,26 +567,10 @@ select {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='checkbox']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='radio']:checked { [type='radio']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
} }
@media (forced-colors: active) {
[type='radio']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -613,14 +585,6 @@ select {
background-repeat: no-repeat; background-repeat: no-repeat;
} }
@media (forced-colors: active) {
[type='checkbox']:indeterminate {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
border-color: transparent; border-color: transparent;
background-color: currentColor; background-color: currentColor;
@ -689,10 +653,6 @@ select {
--tw-backdrop-opacity: ; --tw-backdrop-opacity: ;
--tw-backdrop-saturate: ; --tw-backdrop-saturate: ;
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
} }
::backdrop { ::backdrop {
@ -743,10 +703,6 @@ select {
--tw-backdrop-opacity: ; --tw-backdrop-opacity: ;
--tw-backdrop-saturate: ; --tw-backdrop-saturate: ;
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
--tw-contain-size: ;
--tw-contain-layout: ;
--tw-contain-paint: ;
--tw-contain-style: ;
} }
.container { .container {
@ -856,8 +812,8 @@ select {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.mb-8 { .mb-4 {
margin-bottom: 2rem; margin-bottom: 1rem;
} }
.ml-1 { .ml-1 {
@ -900,14 +856,6 @@ select {
display: none; display: none;
} }
.h-24 {
height: 6rem;
}
.h-3 {
height: 0.75rem;
}
.h-4 { .h-4 {
height: 1rem; height: 1rem;
} }
@ -956,10 +904,6 @@ select {
max-width: 20rem; max-width: 20rem;
} }
.flex-1 {
flex: 1 1 0%;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -994,12 +938,6 @@ select {
gap: 0.5rem; gap: 0.5rem;
} }
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -1014,8 +952,8 @@ select {
white-space: nowrap; white-space: nowrap;
} }
.rounded-full { .rounded {
border-radius: 9999px; border-radius: 0.25rem;
} }
.rounded-lg { .rounded-lg {
@ -1051,6 +989,11 @@ select {
background-color: rgb(124 58 237 / var(--tw-bg-opacity)); background-color: rgb(124 58 237 / var(--tw-bg-opacity));
} }
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
@ -1075,10 +1018,6 @@ select {
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
} }
.pb-16 {
padding-bottom: 4rem;
}
.pl-3 { .pl-3 {
padding-left: 0.75rem; padding-left: 0.75rem;
} }
@ -1091,10 +1030,6 @@ select {
padding-top: 0.25rem; padding-top: 0.25rem;
} }
.pt-8 {
padding-top: 2rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -1142,26 +1077,20 @@ select {
font-weight: 600; font-weight: 600;
} }
.italic {
font-style: italic;
}
.text-gray-700 { .text-gray-700 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity)); color: rgb(55 65 81 / var(--tw-text-opacity));
} }
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.text-slate-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
} }
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.text-white { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
@ -1250,7 +1179,7 @@ a:hover {
transition: all 0.2s ease-out; transition: all 0.2s ease-out;
} }
form label:is(.dark *) { :is(.dark form label) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
@ -1260,7 +1189,7 @@ form label:is(.dark *) {
margin-right: auto; margin-right: auto;
} }
.responsive-table:is(.dark *) { :is(.dark .responsive-table) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@ -1291,9 +1220,9 @@ form label:is(.dark *) {
border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
form input:is(.dark *), :is(.dark form input),:is(.dark
select:is(.dark *), select),:is(.dark
textarea:is(.dark *) { textarea) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity)); border-color: rgb(15 23 42 / var(--tw-border-opacity));
@ -1303,9 +1232,9 @@ textarea:is(.dark *) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
form input:disabled:is(.dark *), :is(.dark form input:disabled),:is(.dark
select:disabled:is(.dark *), select:disabled),:is(.dark
textarea:disabled:is(.dark *) { textarea:disabled) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity)); background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1; --tw-text-opacity: 1;
@ -1420,34 +1349,6 @@ th label {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.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;
}
.hover\:bg-gray-400:hover { .hover\:bg-gray-400:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity)); background-color: rgb(156 163 175 / var(--tw-bg-opacity));
@ -1504,32 +1405,32 @@ th label {
display: block; display: block;
} }
.dark\:bg-gray-800:is(.dark *) { :is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
} }
.dark\:bg-gray-900:is(.dark *) { :is(.dark .dark\:bg-gray-900) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
.dark\:text-slate-400:is(.dark *) { :is(.dark .dark\:text-slate-400) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.dark\:text-slate-500:is(.dark *) { :is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
} }
.dark\:text-slate-600:is(.dark *) { :is(.dark .dark\:text-slate-600) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); color: rgb(71 85 105 / var(--tw-text-opacity));
} }
.dark\:text-white:is(.dark *) { :is(.dark .dark\:text-white) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }

View File

@ -22,14 +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

@ -1,4 +1,3 @@
{% load django_htmx %}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
@ -13,7 +12,6 @@
{% endblock title %} {% endblock title %}
</title> </title>
<script src="{% static 'js/htmx.min.js' %}"></script> <script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark" hx-indicator="#indicator"> <body class="dark" hx-indicator="#indicator">
@ -23,8 +21,8 @@
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">
<nav class="dark:bg-gray-900 border-gray-200 h-24 flex items-center"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"> <span class="text-4xl">
@ -39,7 +37,6 @@
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> <ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group"> <li class="relative group">
{% if user.is_authenticated %}
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}">New</a> href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
@ -94,24 +91,17 @@
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'list_sessions' %}">All Sessions</a> href="{% url 'list_sessions' %}">All Sessions</a>
</li> </li>
<li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'logout' %}">Log Out</a>
</li>
{% endif %}
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
<div class="flex flex-1 dark:bg-gray-800 justify-center pt-8 pb-16">
{% block content %} {% block content %}
No content here. No content here.
{% endblock content %} {% endblock content %}
</div> </div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{% block scripts %} {% block scripts %}
{% endblock scripts %} {% endblock scripts %}
</body> </body>

View File

@ -4,22 +4,21 @@
{{ 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 %}
<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="{% url 'start_session_same_as_last' last.id %}"
hx-get="{{ start_session_url }}" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-swap="afterbegin" hx-swap="afterbegin"
hx-target=".responsive-table tbody" hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child"
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>
@ -30,43 +29,40 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for session in dataset %} {% for data in dataset %}
{% partialdef session-row inline=True %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"> <td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' session.purchase.edition.game.id %}"> href="{% url 'view_game' data.purchase.edition.game.id %}">
{{ session.purchase.edition.name }} {{ data.purchase.edition }}
</a> </a>
</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" }} {{ data.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 data.unfinished %}
{% url 'list_sessions_end_session' session.id as end_session_url %} <a href="{% url 'update_session' data.id %}"
<a href="{{ end_session_url }}" hx-get="{% url 'update_session' data.id %}"
hx-get="{{ end_session_url }}"
hx-target="closest tr"
hx-swap="outerHTML" hx-swap="outerHTML"
hx-target=".responsive-table tbody tr:first-child"
hx-select=".responsive-table tbody tr:first-child"
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 data.duration_manual %}
-- --
{% else %} {% else %}
{{ session.timestamp_end | date:"d/m/Y H:i" }} {{ data.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">{{ data.duration_formatted }}</td>
</tr> </tr>
{% 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

@ -1,23 +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' %}
{{ purchase.name }} ({{ purchase.edition.name }} {{ purchase.get_type_display }})
{% else %}
{{ 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">
@ -52,11 +43,11 @@
</tr> </tr>
<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>
<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>
@ -72,52 +63,25 @@
{{ highest_session_average }} ({{ highest_session_average_game }}) {{ highest_session_average }} ({{ highest_session_average_game }})
</td> </td>
</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 font-mono">{{ first_play_name }} ({{ first_play_date }})</td>
</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 font-mono">{{ last_play_name }} ({{ last_play_date }})</td>
</tr>
</tbody> </tbody>
</table> </table>
<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>
<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>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%) {{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</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> </td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">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">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%) {{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td> </td>
</tr> </tr>
<tr> <tr>
@ -173,6 +137,7 @@
<tr> <tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th> <th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th> <th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -181,10 +146,15 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}"> href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %} {% if purchase.type == 'dlc' %}
{{ purchase.name }} ({{ purchase.edition.name }} DLC)
{% else %}
{{ purchase.edition.name }}
{% endif %}
</a> </a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -229,32 +199,6 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<h1 class="text-5xl text-center my-6">Unfinished Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_unfinished %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1> <h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@ -270,7 +214,8 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}"> href="{% url 'edit_purchase' purchase.id %}">
{% partial purchase-name %} {{ purchase.edition.name }}
{% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %}
</a> </a>
</td> </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.price }}</td>

View File

@ -3,22 +3,18 @@
{{ 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">
<h1 class="text-5xl flex items-center"> <h1 class="text-4xl flex items-center">
{{ game.name }} {{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %} {% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2 dark:text-slate-500">First Released: <span class="text-white">{{ game.year_released }}</span></h2>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
<span class="dark:text-slate-500">Playtime: </span> {{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ hours_sum }} <span class="dark:text-slate-500">hours over</span> {{ session_count }} <span class="dark:text-slate-500">sessions (</span>{{ session_average }}<span class="dark:text-slate-500">/session)</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
</h2> ({{ playrange }})
<h2 class="text-lg my-2 ml-2">
<span class="dark:text-slate-500">Played in: </span>
{{ playrange }}
</h2> </h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1"> <h1 class="text-3xl mt-4 mb-1">
@ -40,7 +36,7 @@
</li> </li>
<ul> <ul>
{% for purchase in edition.game_purchases %} {% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}"> <li class="sm:pl-6 flex items-center">
{{ 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 %}
@ -61,56 +57,21 @@
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <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">({{ sessions.count }})</span>
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %} {% url 'start_game_session' game.id as add_session_link %}
<a {% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
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"
href="{{ add_session_link }}"
hx-get="{{ add_session_link }}"
hx-vals="js:{session_count:getSessionCount()}"
hx-target="#session-list"
hx-swap="afterbegin"
>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>
{% for session in sessions %} {% for session in sessions %}
{% partialdef session-info inline=True %} <li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
<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:m" }}
{{ session.timestamp_start | date:"d/m/Y H:i" }}{% 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 %}
{% 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"
href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest li"
hx-swap="outerHTML"
hx-vals="js:{session_count:getSessionCount()}"
hx-indicator="#indicator"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" 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>
{% endif %}
</li> </li>
<li class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li> <li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">
({{ session_count }})
</div>
{% endpartialdef %}
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
{% endblock content %} {% endblock content %}

View File

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

View File

@ -4,105 +4,91 @@ from games import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path("device/add", views.add_device, name="add_device"),
path("edition/add", views.add_edition, name="add_edition"),
path( path(
"edition/add/for-game/<int:game_id>", "list-sessions/recent",
views.add_edition,
name="add_edition_for_game",
),
path("edition/<int:edition_id>/edit", views.edit_edition, name="edit_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("platform/add", views.add_platform, name="add_platform"),
path("platform/<int:platform_id>/edit", views.edit_platform, name="edit_platform"),
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/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,
name="add_session_for_purchase",
),
path(
"session/add/from-game/<int:session_id>",
views.new_session_from_existing_session,
{"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"session/add/from-list/<int:session_id>",
views.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"},
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(
"session/end/from-game/<int:session_id>",
views.end_session,
{"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
views.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
),
path("session/list", views.list_sessions, name="list_sessions"),
path(
"session/list/recent",
views.list_sessions, views.list_sessions,
{"filter": "recent"}, {"filter": "recent"},
name="list_sessions_recent", name="list_sessions_recent",
), ),
path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"),
path( path(
"session/list/by-purchase/<int:purchase_id>", "add-session-for-purchase/<int:purchase_id>",
views.add_session,
name="add_session_for_purchase",
),
path(
"update-session/by-session/<int:session_id>",
views.update_session,
name="update_session",
),
path(
"start-session-same-as-last/<int:last_session_id>",
views.start_session_same_as_last,
name="start_session_same_as_last",
),
path(
"start-session/<int:game_id>",
views.start_game_session,
name="start_game_session",
),
# path(
# "delete_session/by-id/<int:session_id>",
# views.delete_session,
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"),
path(
"add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path(
"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",

View File

@ -1,20 +1,9 @@
from datetime import datetime from datetime import datetime, timedelta
from typing import Any, Callable from typing import Any, Callable
from django.contrib.auth.decorators import login_required from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models import ( from django.db.models.functions import Extract, TruncDate
Avg,
Count,
ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
IntegerField,
)
from django.db.models.functions import TruncDate, ExtractMonth, TruncMonth
from django.http import ( from django.http import (
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
@ -24,7 +13,6 @@ from django.http import (
from django.shortcuts import 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 from common.utils import safe_division
@ -55,7 +43,6 @@ def stats_dropdown_year_range(request):
return result return result
@login_required
def add_session(request, purchase_id=None): def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": timezone.now()} initial = {"timestamp_start": timezone.now()}
@ -86,6 +73,13 @@ def add_session(request, purchase_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
session.save()
return redirect("list_sessions")
def use_custom_redirect( def use_custom_redirect(
func: Callable[..., HttpResponse] func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]: ) -> Callable[..., HttpResponse]:
@ -104,7 +98,6 @@ def use_custom_redirect(
return wrapper return wrapper
@login_required
@use_custom_redirect @use_custom_redirect
def edit_session(request, session_id=None): def edit_session(request, session_id=None):
context = {} context = {}
@ -118,7 +111,6 @@ def edit_session(request, session_id=None):
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, purchase_id=None): def edit_purchase(request, purchase_id=None):
context = {} context = {}
@ -129,12 +121,10 @@ def edit_purchase(request, purchase_id=None):
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"] = 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, game_id=None): def edit_game(request, game_id=None):
context = {} context = {}
@ -148,14 +138,11 @@ def edit_game(request, game_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def view_game(request, game_id=None): def view_game(request, game_id=None):
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = 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( game_purchases_prefetch = Prefetch(
@ -171,14 +158,11 @@ def view_game(request, game_id=None):
.order_by("year_released") .order_by("year_released")
) )
sessions = Session.objects.prefetch_related("device").filter( sessions = Session.objects.filter(purchase__edition__game=game)
purchase__edition__game=game
)
session_count = sessions.count() session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y") playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest() playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = ( playrange = (
playrange_start playrange_start
@ -199,14 +183,12 @@ def view_game(request, game_id=None):
"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": 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, platform_id=None): def edit_platform(request, platform_id=None):
context = {} context = {}
@ -220,7 +202,6 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
@use_custom_redirect @use_custom_redirect
def edit_edition(request, edition_id=None): def edit_edition(request, edition_id=None):
context = {} context = {}
@ -245,53 +226,39 @@ def related_purchase_by_edition(request):
return render(request, "partials/related_purchase_field.html", {"form": form}) return render(request, "partials/related_purchase_field.html", {"form": form})
def clone_session_by_id(session_id: int) -> Session:
session = get_object_or_404(Session, id=session_id)
clone = session
clone.pk = None
clone.timestamp_start = timezone.now()
clone.timestamp_end = None
clone.note = ""
clone.save()
return clone
@login_required
@use_custom_redirect @use_custom_redirect
def new_session_from_existing_session(request, session_id: int, template: str = ""): def start_game_session(request, game_id: int):
session = clone_session_by_id(session_id) last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
if request.htmx: session = SessionForm(
context = { {
"session": session, "purchase": last_session.purchase.id,
"session_count": int(request.GET.get("session_count", 0)) + 1, "timestamp_start": timezone.now(),
"device": last_session.device,
} }
return render(request, template, context) )
return redirect("list_sessions")
@login_required
@use_custom_redirect
def end_session(request, session_id: int, template: str = ""):
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save() session.save()
if request.htmx: return redirect("list_sessions")
context = {
"session": session,
"session_count": request.GET.get("session_count", 0), def start_session_same_as_last(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
} }
return render(request, template, context) )
session.save()
return redirect("list_sessions") return redirect("list_sessions")
@login_required # def delete_session(request, session_id=None):
def delete_session(request, session_id=None): # 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, request,
filter="", filter="",
@ -304,55 +271,56 @@ def list_sessions(
context = {} context = {}
context["title"] = "Sessions" context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
if filter == "purchase": if filter == "purchase":
dataset = all_sessions.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id) context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform": elif filter == "platform":
dataset = all_sessions.filter(purchase__platform=platform_id) dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition": elif filter == "edition":
dataset = all_sessions.filter(purchase__edition=edition_id) dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id) context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = all_sessions.filter(purchase__edition__game=game_id) dataset = Session.objects.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type": elif filter == "ownership_type":
dataset = all_sessions.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.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": elif filter == "recent":
current_year = timezone.now().year current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1)) 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( dataset = Session.objects.filter(
"-timestamp_start" timestamp_start__gte=first_day_of_year
) ).order_by("-timestamp_start")
context["title"] = "This year" context["title"] = "This year"
else: else:
dataset = all_sessions # by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
context = { for session in dataset:
**context, if session.timestamp_end == None and session.duration_manual == timedelta(
"dataset": dataset, seconds=0
"dataset_count": dataset.count(), ):
"last": Session.objects.prefetch_related("purchase__platform").latest(), session.timestamp_end = timezone.now()
} session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@login_required
def stats(request, year: int = 0): def stats(request, year: int = 0):
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:
year = timezone.now().year 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")
this_year_sessions_with_durations = this_year_sessions.annotate( this_year_sessions_with_durations = this_year_sessions.annotate(
duration=ExpressionWrapper( duration=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), F("timestamp_end") - F("timestamp_start"),
@ -360,10 +328,7 @@ def stats(request, year: int = 0):
) )
) )
longest_session = this_year_sessions_with_durations.order_by("-duration").first() longest_session = this_year_sessions_with_durations.order_by("-duration").first()
this_year_games = Game.objects.filter( this_year_games_with_session_counts = Game.objects.annotate(
edition__purchase__session__in=this_year_sessions
).distinct()
this_year_games_with_session_counts = this_year_games.annotate(
session_count=Count( session_count=Count(
"edition__purchase__session", "edition__purchase__session",
filter=Q(edition__purchase__session__timestamp_start__year=year), filter=Q(edition__purchase__session__timestamp_start__year=year),
@ -384,52 +349,48 @@ def stats(request, year: int = 0):
).distinct() ).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year) this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related( this_year_purchases_with_currency = this_year_purchases.filter(
"edition" price_currency__exact=selected_currency
).filter(price_currency__exact=selected_currency) )
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter( this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None date_refunded=None
) )
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(
this_year_purchases_without_refunded.filter(date_finished__isnull=True) date_finished__isnull=True
.filter(infinite=False) ).filter(
.filter(Q(type=Purchase.GAME) | Q(type=Purchase.DLC)) 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_unfinished_count = this_year_purchases_unfinished.count()
this_year_purchases_unfinished_percent = int( this_year_purchases_unfinished_percent = int(
safe_division( safe_division(
this_year_purchases_unfinished_count, this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
this_year_purchases_without_refunded_count,
) )
* 100 * 100
) )
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year) purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_with_playtime = purchases_finished_this_year.annotate(
total_playtime=Sum(
F("session__duration_calculated") + F("session__duration_manual")
)
)
for purchase in purchases_finished_this_year_with_playtime:
formatted_playtime = format_duration(purchase.total_playtime, "%2.0H")
setattr(purchase, "formatted_playtime", formatted_playtime)
purchases_finished_this_year_released_this_year = ( purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by( purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished" "date_finished"
) )
) )
purchased_this_year_finished_this_year = ( purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.filter(date_finished__year=year) this_year_purchases_without_refunded.intersection(
purchases_finished_this_year
).order_by("date_finished") ).order_by("date_finished")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate( this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price")) total_spent=Sum(F("price"))
@ -446,15 +407,6 @@ def stats(request, year: int = 0):
) )
.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(
@ -483,26 +435,6 @@ def stats(request, year: int = 0):
.count() .count()
) )
first_play_name = "N/A"
first_play_date = "N/A"
last_play_name = "N/A"
last_play_date = "N/A"
if this_year_sessions:
first_session = this_year_sessions.earliest()
first_play_name = first_session.purchase.edition.name
first_play_date = first_session.timestamp_start.strftime("%x")
last_session = this_year_sessions.latest()
last_play_name = last_session.purchase.edition.name
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 = 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"
@ -518,89 +450,52 @@ def stats(request, year: int = 0):
"total_spent_currency": selected_currency, "total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded, "all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int( "spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count) safe_division(total_spent, this_year_purchases_without_refunded.count())
), ),
"all_finished_this_year": purchases_finished_this_year.select_related( "all_finished_this_year": purchases_finished_this_year_with_playtime.order_by(
"edition"
).order_by("date_finished"),
"all_finished_this_year_count": purchases_finished_this_year.count(),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.select_related(
"edition"
).order_by(
"date_finished" "date_finished"
), ),
"this_year_finished_this_year_count": purchases_finished_this_year_released_this_year.count(), "this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.select_related( "date_finished"
"edition" ),
).order_by( "purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished" "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,
"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, this_year_purchases_refunded.count(),
all_purchased_this_year_count, this_year_purchases_with_currency.count(),
) )
* 100 * 100
), ),
"all_purchased_refunded_this_year": this_year_purchases_refunded, "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": this_year_purchases_with_currency.order_by( "all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased" "date_purchased"
), ),
"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 if longest_session else timedelta(0),
if longest_session "%2.0Hh %2.0mm",
else 0
), ),
"longest_session_game": ( "longest_session_game": longest_session.purchase.edition.name,
longest_session.purchase.edition.name if longest_session else "N/A" "highest_session_count": game_highest_session_count.session_count,
), "highest_session_count_game": game_highest_session_count.name,
"highest_session_count": ( "highest_session_average": format_duration(
game_highest_session_count.session_count
if game_highest_session_count
else 0
),
"highest_session_count_game": (
game_highest_session_count.name if game_highest_session_count else "N/A"
),
"highest_session_average": (
format_duration(
highest_session_average_game.session_average, "%2.0Hh %2.0mm" 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, "highest_session_average_game": highest_session_average_game,
"first_play_name": first_play_name,
"first_play_date": first_play_date,
"last_play_name": last_play_name,
"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 delete_purchase(request, purchase_id=None):
purchase = get_object_or_404(Purchase, id=purchase_id)
purchase.delete()
return redirect("list_sessions")
@login_required
def add_purchase(request, edition_id=None): def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": timezone.now()}
@ -636,7 +531,6 @@ def add_purchase(request, edition_id=None):
return render(request, "add_purchase.html", context) return render(request, "add_purchase.html", context)
@login_required
def add_game(request): def add_game(request):
context = {} context = {}
form = GameForm(request.POST or None) form = GameForm(request.POST or None)
@ -655,7 +549,6 @@ def add_game(request):
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, game_id=None):
context = {} context = {}
if request.method == "POST": if request.method == "POST":
@ -690,7 +583,6 @@ def add_edition(request, game_id=None):
return render(request, "add_edition.html", context) return render(request, "add_edition.html", context)
@login_required
def add_platform(request): def add_platform(request):
context = {} context = {}
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
@ -703,7 +595,6 @@ def add_platform(request):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def add_device(request): def add_device(request):
context = {} context = {}
form = DeviceForm(request.POST or None) form = DeviceForm(request.POST or None)
@ -716,6 +607,5 @@ def add_device(request):
return render(request, "add.html", context) return render(request, "add.html", context)
@login_required
def index(request): def index(request):
return redirect("list_sessions_recent") return redirect("list_sessions_recent")

View File

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

675
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +1,31 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.2" version = "1.5.1"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies]
python = "^3.12"
django = "^4.2.0"
gunicorn = "^20.1.0"
uvicorn = "^0.20.0"
graphene-django = "^3.1.5"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^24.4.2" black = "^22.12.0"
mypy = "^1.10.1" mypy = "^0.991"
pyyaml = "^6.0.1" pyyaml = "^6.0"
pytest = "^8.2.2" pytest = "^7.2.0"
django-extensions = "^3.2.3" django-extensions = "^3.2.1"
djhtml = "^3.0.6" werkzeug = "^2.2.2"
djlint = "^1.34.1" djhtml = "^1.5.2"
isort = "^5.13.2" djlint = "^1.19.11"
pre-commit = "^3.7.1" isort = "^5.11.4"
django-debug-toolbar = "^4.4.2" pre-commit = "^3.5.0"
django-debug-toolbar = "^4.2.0"
[tool.poetry.dependencies]
python = "^3.11"
django = "^5.0.6"
gunicorn = "^22.0.0"
uvicorn = "^0.30.1"
graphene-django = "^3.2.2"
django-htmx = "^1.18.0"
django-template-partials = "^24.2"
markdown = "^3.6"
[tool.isort] [tool.isort]
profile = "black" profile = "black"

View File

@ -38,9 +38,7 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"template_partials",
"graphene_django", "graphene_django",
"django_htmx",
] ]
GRAPHENE = {"SCHEMA": "games.schema.schema"} GRAPHENE = {"SCHEMA": "games.schema.schema"}
@ -58,7 +56,6 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
] ]
if DEBUG: if DEBUG:
@ -67,9 +64,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 = [
{ {
@ -85,7 +79,6 @@ TEMPLATES = [
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range", "games.views.stats_dropdown_year_range",
], ],
"builtins": ["template_partials.templatetags.partials"],
}, },
}, },
] ]

View File

@ -15,7 +15,6 @@ Including another URLconf
""" """
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
@ -23,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: