4 Commits

Author SHA1 Message Date
555608d8c6 Fix syntax
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m14s
2023-11-18 09:52:17 +01:00
a7293c659d CI: Ignore README.md 2023-11-18 09:33:31 +01:00
f36e692361 Do not run for pull requests 2023-11-18 09:33:10 +01:00
fe97f540a0 Fix CI being blocked 2023-11-18 09:32:41 +01:00
55 changed files with 1095 additions and 3635 deletions

View File

@ -0,0 +1,25 @@
name: Django CI/CD
on:
push:
branches: [ main ]
paths-ignore: [ 'README.md' ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

View File

@ -1,36 +0,0 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v4
with:
python-version: 3.12
- run: |
python -m pip install poetry
poetry install
poetry env info
poetry run python manage.py migrate
# PROD=1 poetry run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

4
.gitignore vendored
View File

@ -1,11 +1,9 @@
__pycache__
.mypy_cache
.pytest_cache
.venv/
.venv
node_modules
package-lock.json
db.sqlite3
/static/
dist/
.DS_Store
.python-version

View File

@ -1,6 +1,6 @@
repos:
- repo: https://github.com/psf/black
rev: 24.3.0
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
@ -8,8 +8,3 @@ repos:
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.34.0
hooks:
- id: djlint-reformat-django
- id: djlint-django

View File

@ -1,37 +1,8 @@
## Unreleased
## New
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
## Improved
* mark refunded purchases red on game overview
* 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
* 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
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* game overview: improve how editions and purchases are displayed
* 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

View File

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

View File

@ -3,7 +3,6 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm:
npm install
@ -11,26 +10,17 @@ npm:
css: common/input.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:
poetry run python manage.py makemigrations
migrate: makemigrations
poetry run python manage.py migrate
init:
pyenv install -s $(PYTHON_VERSION)
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"
dev: migrate
poetry run python manage.py runserver
caddy:
caddy run --watch

View File

@ -1,15 +1,3 @@
# Timetracker
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-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-Regular.woff2") format("woff2");
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@ -23,33 +23,12 @@
font-style: normal;
}
@font-face {
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;
color: rgb(254, 185, 160);
transition: all 0.2s ease-out;
} */
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
@apply dark:text-white mx-auto;
}
.responsive-table tr:nth-child(even) {
@ -73,11 +52,8 @@ form label {
.max-w-20char {
max-width: 20ch;
}
.max-w-30char {
max-width: 30ch;
}
.max-w-35char {
max-width: 35ch;
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
@ -135,39 +111,3 @@ th label {
.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;
}
.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

@ -7,24 +7,3 @@ def safe_division(numerator: int | float, denominator: int | float) -> int | flo
return numerator / denominator
except ZeroDivisionError:
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.urls import reverse
from common.utils import safe_getattr
from games.models import Device, Edition, Game, Platform, Purchase, Session
@ -46,8 +45,8 @@ class EditionChoiceField(forms.ModelChoiceField):
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
option["attrs"]["data-platform"] = platform_id
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
@ -84,7 +83,6 @@ class PurchaseForm(forms.ModelForm):
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
"date_dropped": custom_date_widget,
}
model = Purchase
fields = [
@ -93,8 +91,6 @@ class PurchaseForm(forms.ModelForm):
"date_purchased",
"date_refunded",
"date_finished",
"date_dropped",
"infinite",
"price",
"price_currency",
"ownership_type",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +0,0 @@
# Generated by Django 4.2.7 on 2023-11-28 13:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0032_alter_session_options_session_modified_at_and_more"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform", "year_released")},
),
]

View File

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

@ -18,10 +18,23 @@ class Game(models.Model):
def __str__(self):
return self.name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
@ -36,6 +49,19 @@ class Edition(models.Model):
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
@ -90,8 +116,6 @@ class Purchase(models.Model):
date_purchased = models.DateField()
date_refunded = 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_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
@ -112,11 +136,9 @@ class Purchase(models.Model):
def __str__(self):
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
(
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform
),
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
@ -154,19 +176,6 @@ class SessionQuerySet(models.QuerySet):
)
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 Meta:
@ -203,7 +212,7 @@ class Session(models.Model):
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if self.is_manual() and isinstance(self.duration_manual, timedelta):
if self.is_manual():
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
@ -226,9 +235,6 @@ class Session(models.Model):
else:
self.duration_calculated = timedelta(0)
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}

View File

@ -1,30 +0,0 @@
import graphene
from games.graphql.mutations import GameMutation
from games.graphql.queries import (
DeviceQuery,
EditionQuery,
GameQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
)
class Query(
GameQuery,
EditionQuery,
DeviceQuery,
PlatformQuery,
PurchaseQuery,
SessionQuery,
graphene.ObjectType,
):
pass
class Mutation(GameMutation, graphene.ObjectType):
pass
schema = graphene.Schema(query=Query, mutation=Mutation)

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,14 +22,6 @@
value="Submit & Create Session" />
</td>
</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>
</form>
{% endblock content %}

View File

@ -16,7 +16,7 @@
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container" hx-boost="false">
<div class="basic-button-container">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button"
data-target="{{ field.name }}"

View File

@ -1,4 +1,3 @@
{% load django_htmx %}
<!DOCTYPE html>
<html lang="en">
{% load static %}
@ -13,34 +12,24 @@
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
{% django_htmx_script %}
<link rel="stylesheet" href="{% static 'base.css' %}" />
<script src="https://cdn.jsdelivr.net/npm/flowbite@2.4.1/dist/flowbite.min.js"></script>
</head>
<body class="dark" hx-indicator="#indicator">
<body class="dark" hx-indicator="#indicator" hx-boost="true">
<img id="indicator"
src="{% static 'icons/loading.png' %}"
class="absolute right-3 top-3 animate-spin htmx-indicator"
height="24"
width="24"
alt="loading indicator" />
<div class="flex flex-col min-h-screen">
<nav class="dark:bg-gray-900 border-gray-200 h-24 flex items-center">
class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen">
<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">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl">
<img src="{% static 'icons/schedule.png' %}"
height="48"
width="48"
alt="Timetracker Logo"
class="mr-4" />
<img src="{% static 'icons/schedule.png' %}" width="48" 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">
{% if user.is_authenticated %}
<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">
@ -81,12 +70,8 @@
{% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'stats_by_year' 0 %}">Stats</a>
href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'stats_by_year' 0 %}">Overall</a>
</li>
{% 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"
@ -99,25 +84,18 @@
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'list_sessions' %}">All Sessions</a>
</li>
<li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'logout' %}">Log Out</a>
</li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="flex flex-1 dark:bg-gray-800 justify-center pt-8 pb-16">
{% block content %}
No content here.
{% endblock content %}
</div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</div>
{% block scripts %}
{% endblock scripts %}
</body>
</html>
</html>

View File

@ -1,3 +0,0 @@
components:
gamelink: "components/game_link.html"
popover: "components/popover.html"

View File

@ -1,9 +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,9 +0,0 @@
<!-- needs data-popover-target on triggering block -->
<!-- id -->
<!-- children -->
<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

@ -4,22 +4,21 @@
{{ title }}
{% endblock title %}
{% block content %}
<div class="flex-col">
{% if dataset_count >= 1 %}
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
{% if dataset.count >= 1 %}
<div class="mx-auto text-center my-4">
<a id="last-session-start"
href="{{ start_session_url }}"
hx-get="{{ start_session_url }}"
href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
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 %}
</a>
</div>
{% endif %}
{% if dataset_count != 0 %}
{% if dataset.count != 0 %}
<table class="responsive-table">
<thead>
<tr>
@ -30,44 +29,40 @@
</tr>
</thead>
<tbody>
{% for session in dataset %}
{% partialdef session-row inline=True %}
{% for data in dataset %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name relative align-top w-24 h-12 group">
<span class="inline-block relative">
<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 %}">
{{ session.purchase.edition.name }}
<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"
href="{% url 'view_game' data.purchase.edition.game.id %}">
{{ data.purchase.edition }}
</a>
</span>
</td>
<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 class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if not session.timestamp_end %}
{% url 'list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest tr"
{% if data.unfinished %}
<a href="{% url 'update_session' data.id %}"
hx-get="{% url 'update_session' data.id %}"
hx-swap="outerHTML"
hx-target=".responsive-table tbody tr:first-child"
hx-select=".responsive-table tbody tr:first-child"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif session.duration_manual %}
{% elif data.duration_manual %}
--
{% else %}
{{ session.timestamp_end | date:"d/m/Y H:i" }}
{{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</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>
{% endpartialdef %}
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
</div>
{% 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,17 +3,6 @@
{{ title }}
{% endblock title %}
{% 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 %}
<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">
@ -29,6 +18,8 @@
</select>
</form>
</div>
<div class="flex flex-column flex-wrap justify-center">
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
@ -44,88 +35,43 @@
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
{% if total_games %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
{% if all_finished_this_year_count %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year_count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
</tr>
{% endif %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year_count }}</td>
</tr>
<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 font-mono">{{ longest_session_time }} ({% gamelink game_id=longest_session_game.id name=longest_session_game.name %})</td>
</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 font-mono">{{ highest_session_count }} ({% gamelink game_id=highest_session_count_game.id name=highest_session_count_game.name %})</td>
</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 font-mono">
{{ highest_session_average }} ({% gamelink game_id=highest_session_average_game.id name=highest_session_average_game.name %})
</td>
</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">{% gamelink game_id=first_play_game.id name=first_play_game.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">{% gamelink game_id=last_play_game.id name=last_play_game.name %} ({{ last_play_date }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
</tr>
</tbody>
</table>
{% 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 %}
</div>
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year_count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year_count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Dropped</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ dropped_count }} ({{ dropped_percentage }}%)
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished_count }} ({{ unfinished_purchases_percent }}%)
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
@ -138,6 +84,8 @@
</tr>
</tbody>
</table>
</div>
</div>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
@ -150,7 +98,8 @@
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% gamelink game_id=game.id name=game.name %}
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
@ -174,8 +123,6 @@
{% endfor %}
</tbody>
</table>
{% if all_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
@ -188,16 +135,14 @@
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% partial purchase-name %}
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
<table class="responsive-table">
<thead>
@ -210,16 +155,14 @@
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% partial purchase-name %}
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_this_year_finished_this_year %}
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
@ -232,40 +175,14 @@
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% partial purchase-name %}
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if purchased_unfinished %}
<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">
{% 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 %}
{% if all_purchased_this_year %}
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
@ -279,7 +196,11 @@
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{% partial purchase-name %}
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
@ -287,6 +208,5 @@
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{% endblock content %}

View File

@ -3,71 +3,21 @@
{{ title }}
{% endblock title %}
{% load static %}
{% load markdown_extras %}
{% block content %}
<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">
<div class="flex gap-5 mb-3">
<span class="text-wrap max-w-80 text-4xl">
<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>
{% #popover id="popover-year" %}
Original release year
{% /popover %}
</span>
</div>
<div class="flex gap-4 dark:text-slate-400 mb-3">
<span data-popover-target="popover-hours" 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="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">
<h1 class="text-4xl flex items-center">
{{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</h1>
<h2 class="text-lg my-2 ml-2">
{{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }})
</h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul>
@ -86,7 +36,7 @@
</li>
<ul>
{% 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" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %}
@ -105,60 +55,24 @@
</ul>
{% endfor %}
</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
<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 %}
<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"
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>
{% endif %}
<span class="dark:text-slate-500">({{ sessions.count }})</span>
{% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1>
<ul id="session-list">
<ul>
{% 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 space-x-1">
{{ session.timestamp_start | date:"d/m/Y H:i" }}{% if session.timestamp_end %}-{{ session.timestamp_end | date:"H:i" }}{% endif %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as 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 class="sm:pl-4 markdown-content">{{ session.note|markdown }}</li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">
({{ session_count }})
</div>
{% endpartialdef %}
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endfor %}
</ul>
</div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
{% 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,111 +4,96 @@ from games import views
urlpatterns = [
path("", views.index, name="index"),
path("device/add", views.add_device, name="add_device"),
path("edition/add", views.add_edition, name="add_edition"),
path(
"edition/add/for-game/<int:game_id>",
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("game/<int:game_id>/delete", views.delete_game, name="delete_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",
"list-sessions/recent",
views.list_sessions,
{"filter": "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(
"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,
{"filter": "purchase"},
name="list_sessions_by_purchase",
),
path(
"session/list/by-platform/<int:platform_id>",
"list-sessions/by-platform/<int:platform_id>",
views.list_sessions,
{"filter": "platform"},
name="list_sessions_by_platform",
),
path(
"session/list/by-game/<int:game_id>",
"list-sessions/by-game/<int:game_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
),
path(
"session/list/by-edition/<int:edition_id>",
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"session/list/by-ownership/<str:ownership_type>",
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "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(
"stats/<int:year>",
views.stats,

View File

@ -1,20 +1,9 @@
from datetime import datetime
from datetime import datetime, timedelta
from typing import Any, Callable
from django.contrib.auth.decorators import login_required
from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
IntegerField,
)
from django.db.models.functions import TruncDate, ExtractMonth, TruncMonth
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate
from django.http import (
HttpRequest,
HttpResponse,
@ -24,10 +13,9 @@ from django.http import (
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from django.shortcuts import get_object_or_404
from common.time import format_duration
from common.utils import safe_division, safe_getattr
from common.utils import safe_division
from .forms import (
DeviceForm,
@ -42,11 +30,11 @@ from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request):
return {
"game_available": Game.objects.exists(),
"edition_available": Edition.objects.exists(),
"platform_available": Platform.objects.exists(),
"purchase_available": Purchase.objects.exists(),
"session_count": Session.objects.exists(),
"game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
}
@ -55,7 +43,6 @@ def stats_dropdown_year_range(request):
return result
@login_required
def add_session(request, purchase_id=None):
context = {}
initial = {"timestamp_start": timezone.now()}
@ -86,6 +73,13 @@ def add_session(request, purchase_id=None):
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(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
@ -104,7 +98,6 @@ def use_custom_redirect(
return wrapper
@login_required
@use_custom_redirect
def edit_session(request, session_id=None):
context = {}
@ -118,7 +111,6 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context)
@login_required
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
@ -129,12 +121,10 @@ def edit_purchase(request, purchase_id=None):
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
context["purchase_id"] = purchase_id
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@login_required
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
@ -148,21 +138,11 @@ def edit_game(request, game_id=None):
return render(request, "add.html", context)
@login_required
def delete_game(request, game_id=None):
game = get_object_or_404(Game, id=game_id)
game.delete()
return redirect("list_sessions")
@login_required
def view_game(request, game_id=None):
game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = Prefetch(
"related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME).order_by(
"date_purchased"
),
queryset=Purchase.objects.exclude(type=Purchase.GAME),
to_attr="nongame_related_purchases",
)
game_purchases_prefetch = Prefetch(
@ -178,57 +158,37 @@ def view_game(request, game_id=None):
.order_by("year_released")
)
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
sessions = Session.objects.filter(purchase__edition__game=game)
session_count = sessions.count()
session_count_without_manual = (
Session.objects.without_manual().filter(purchase__edition__game=game).count()
)
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_end = sessions.latest().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_without_manual = float(
format_duration(sessions.calculated_duration_unformatted(), "%2.1H")
)
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average_without_manual": round(
safe_division(
total_hours_without_manual, int(session_count_without_manual)
),
1,
),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"latest_session_id": safe_getattr(latest_session, "pk"),
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@login_required
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
@ -242,7 +202,6 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context)
@login_required
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
@ -267,53 +226,39 @@ def related_purchase_by_edition(request):
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
def new_session_from_existing_session(request, session_id: int, template: str = ""):
session = clone_session_by_id(session_id)
if request.htmx:
context = {
"session": session,
"session_count": int(request.GET.get("session_count", 0)) + 1,
def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
session = SessionForm(
{
"purchase": last_session.purchase.id,
"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()
if request.htmx:
context = {
"session": session,
"session_count": request.GET.get("session_count", 0),
return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
return render(request, template, context)
)
session.save()
return redirect("list_sessions")
@login_required
def delete_session(request, session_id=None):
session = get_object_or_404(Session, id=session_id)
session.delete()
return redirect("list_sessions")
# def delete_session(request, session_id=None):
# session = Session.objects.get(id=session_id)
# session.delete()
# return redirect("list_sessions")
@login_required
def list_sessions(
request,
filter="",
@ -326,288 +271,56 @@ def list_sessions(
context = {}
context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
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)
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)
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)
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)
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]
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"
)
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else:
dataset = all_sessions
# by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
context = {
**context,
"dataset": dataset,
"dataset_count": dataset.count(),
"last": Session.objects.prefetch_related("purchase__platform").latest(),
}
for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0
):
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)
@login_required
def stats_alltime(request):
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 = 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_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_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 = 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, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
return HttpResponseRedirect(reverse("stats_alltime"))
this_year_sessions = Session.objects.filter(
timestamp_start__year=year
).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",
filter=Q(edition__purchase__session__timestamp_start__year=year),
)
)
game_highest_session_count = this_year_games_with_session_counts.order_by(
"-session_count"
).first()
year = timezone.now().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
@ -620,39 +333,23 @@ def stats(request, year: int = 0):
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.select_related(
"edition"
).filter(price_currency__exact=selected_currency)
this_year_purchases_with_currency = this_year_purchases.filter(
price_currency__exact=selected_currency
)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished_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_without_refunded.filter(
date_finished__isnull=True
).filter(
type=Purchase.GAME
) # do not count DLC 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,
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
)
* 100
)
@ -664,8 +361,10 @@ def stats(request, year: int = 0):
)
)
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")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
@ -682,23 +381,6 @@ def stats(request, year: int = 0):
)
.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")
@ -719,26 +401,6 @@ def stats(request, year: int = 0):
.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_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 = 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"
@ -754,89 +416,40 @@ def stats(request, year: int = 0):
"total_spent_currency": selected_currency,
"all_purchased_this_year": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded_count)
safe_division(total_spent, this_year_purchases_without_refunded.count())
),
"all_finished_this_year": purchases_finished_this_year.select_related(
"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(
"all_finished_this_year": purchases_finished_this_year.order_by(
"date_finished"
),
"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(
"edition"
).order_by(
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"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,
this_year_purchases_refunded.count(),
this_year_purchases_with_currency.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": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"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",
"month_playtimes": month_playtimes,
}
request.session["return_path"] = request.path
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):
context = {}
initial = {"date_purchased": timezone.now()}
@ -872,7 +485,6 @@ def add_purchase(request, edition_id=None):
return render(request, "add_purchase.html", context)
@login_required
def add_game(request):
context = {}
form = GameForm(request.POST or None)
@ -891,7 +503,6 @@ def add_game(request):
return render(request, "add_game.html", context)
@login_required
def add_edition(request, game_id=None):
context = {}
if request.method == "POST":
@ -926,7 +537,6 @@ def add_edition(request, game_id=None):
return render(request, "add_edition.html", context)
@login_required
def add_platform(request):
context = {}
form = PlatformForm(request.POST or None)
@ -939,7 +549,6 @@ def add_platform(request):
return render(request, "add.html", context)
@login_required
def add_device(request):
context = {}
form = DeviceForm(request.POST or None)
@ -952,6 +561,5 @@ def add_device(request):
return render(request, "add.html", context)
@login_required
def index(request):
return redirect("list_sessions_recent")

View File

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

924
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +1,30 @@
[tool.poetry]
name = "timetracker"
version = "1.5.2"
version = "1.5.1"
description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL"
readme = "README.md"
packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies]
python = "^3.12"
django = "^4.2.0"
gunicorn = "^20.1.0"
uvicorn = "^0.20.0"
[tool.poetry.group.dev.dependencies]
black = "^24.4.2"
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"
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"
[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"
slippers = "^0.6.2"
[tool.isort]
profile = "black"

View File

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

View File

@ -1,35 +0,0 @@
import json
import os
import django
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.test import TestCase
from graphene_django.utils.testing import GraphQLTestCase
from games import schema
from games.models import Game
class GameAPITestCase(GraphQLTestCase):
GRAPHENE_SCHEMA = schema.schema
def test_query_all_games(self):
response = self.query(
"""
query {
games {
id
name
}
}
"""
)
self.assertResponseNoErrors(response)
self.assertEqual(
len(json.loads(response.content)["data"]["games"]),
Game.objects.count(),
)

View File

@ -38,18 +38,11 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"template_partials",
"graphene_django",
"django_htmx",
"slippers",
]
GRAPHENE = {"SCHEMA": "games.schema.schema"}
if DEBUG:
INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin")
INSTALLED_APPS.append("debug_toolbar")
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@ -59,18 +52,9 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"django_htmx.middleware.HtmxMiddleware",
]
if DEBUG:
MIDDLEWARE.append("debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
DEBUG_TOOLBAR_CONFIG = {"ROOT_TAG_EXTRA_ATTRS": "hx-preserve"}
ROOT_URLCONF = "timetracker.urls"
LOGIN_URL = "/login/"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/login/"
TEMPLATES = [
{
@ -86,10 +70,6 @@ TEMPLATES = [
"games.views.model_counts",
"games.views.stats_dropdown_year_range",
],
"builtins": [
"template_partials.templatetags.partials",
"slippers.templatetags.slippers",
],
},
},
]

View File

@ -15,20 +15,13 @@ Including another URLconf
"""
from django.conf import settings
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import RedirectView
from graphene_django.views import GraphQLView
urlpatterns = [
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")),
]
if settings.DEBUG:
urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("__debug__/", include("debug_toolbar.urls")))