31 Commits

Author SHA1 Message Date
c35b539c42 Merge sessions and notes
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m9s
2023-11-17 21:20:33 +01:00
bbe5e072b2 Don't display prices if zero 2023-11-17 21:10:56 +01:00
6fc2f623dc Apply djlint 2023-11-17 21:06:57 +01:00
9481bd5fef Add pre-commit
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m33s
2023-11-17 09:34:51 +01:00
4083165123 Use the black profile for isort 2023-11-17 09:15:18 +01:00
45bb2681c7 Use isort on migrations 2023-11-17 09:15:06 +01:00
dbb8ec3f9a Handle empty edition_id 2023-11-17 09:14:25 +01:00
206b5f6d46 Prevent HTMX from messing up the initial state
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-16 20:33:56 +01:00
b7e14ecc83 Account for no sessions
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m21s
2023-11-16 20:29:08 +01:00
912e010729 Enable hx-boost everywhere
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m18s
2023-11-16 19:56:08 +01:00
a485237456 Fix form not syncing due to HTMX
All checks were successful
Django CI/CD / build-and-push (push) Successful in 2m38s
2023-11-16 19:03:16 +01:00
f5faf92ee0 Fix error
All checks were successful
Django CI/CD / build-and-push (push) Successful in 1m57s
2023-11-16 16:53:59 +01:00
07452d8c43 Re-instance gitea actions
Some checks failed
Django CI/CD / test (push) Failing after 34s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-16 16:51:52 +01:00
229a79d266 Update .drone.yml testing
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-16 16:30:17 +01:00
c6ed577fe3 Formatting
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-16 16:27:41 +01:00
171e4779a3 Move static files in prod 2023-11-16 16:27:41 +01:00
79f94e5984 Fix docker-compose.yml 2023-11-16 16:27:41 +01:00
ccebcb89c6 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-16 16:27:41 +01:00
fe0a6b39e3 Fix .dockerignore 2023-11-16 16:27:41 +01:00
6a495f951f Remove Django admin 2023-11-16 16:27:41 +01:00
c8646d0a0c Update dependencies 2023-11-16 16:27:41 +01:00
f2bb15e669 Fix naive date 2023-11-16 16:27:41 +01:00
c49177d63c isort 2023-11-16 16:27:41 +01:00
bd8d30eac1 Improve time-related stuff
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-16 16:27:41 +01:00
c44d8bf427 Improve time-related stuff
All checks were successful
continuous-integration/drone/push Build is passing
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-15 19:14:09 +01:00
3f037b4c7c Only allow choosing purchases of selected edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 14:25:42 +01:00
8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
e158bc0623 Improve how editions and purchases are displayed
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-15 10:37:24 +01:00
8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
49 changed files with 1231 additions and 872 deletions

View File

@ -5,4 +5,13 @@
.venv
.vscode
node_modules
src/timetracker/static/*
static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,7 +5,7 @@ name: default
steps:
- name: test
image: python:3.10
image: python:3.12
commands:
- python -m pip install poetry
- poetry install

View File

@ -0,0 +1,27 @@
name: Django CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-push:
needs: test
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

10
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,10 @@
repos:
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)

View File

@ -1,3 +1,9 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved

View File

@ -1,27 +1,45 @@
FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM python:3.12.0-slim-bullseye
FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER=1.5.1 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
ENV VERSION_NUMBER 1.5.1
ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apt-get update && apt-get upgrade -y \
&& apt-get install --no-install-recommends -y \
bash \
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install
EXPOSE 8000
CMD [ "/entrypoint.sh" ]

View File

@ -72,6 +72,10 @@ textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
@media screen and (min-width: 768px) {
form input,
select,

View File

@ -1,12 +1,5 @@
import re
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
from datetime import timedelta
def _safe_timedelta(duration: timedelta | int | None):

View File

@ -10,13 +10,14 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
volumes:
- "static-files:/home/timetracker/app/static"
- "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy"
- "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports:
- "8000:8000"
@ -26,3 +27,4 @@ services:
volumes:
static-files:

View File

@ -1,6 +1,6 @@
from django.contrib import admin
from games.models import Game, Platform, Purchase, Session, Edition, Device
from games.models import Device, Edition, Game, Platform, Purchase, Session
# Register your models here.
admin.site.register(Game)

View File

@ -1,6 +1,7 @@
from django import forms
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device
from games.models import Device, Edition, Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
@ -50,6 +51,21 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
@ -58,7 +74,8 @@ class PurchaseForm(forms.ModelForm):
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
)
),
required=False,
)
class Meta:
@ -82,6 +99,27 @@ class PurchaseForm(forms.ModelForm):
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):

View File

@ -1,6 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -0,0 +1,44 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,11 +1,11 @@
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from datetime import timedelta
from common.time import format_duration
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration
class Game(models.Model):
@ -13,6 +13,7 @@ class Game(models.Model):
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
@ -43,6 +44,7 @@ class Edition(models.Model):
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.sort_name
@ -120,12 +122,16 @@ class Purchase(models.Model):
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(
max_length=255, default="Unknown Name", null=True, blank=True
)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
"Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
additional_info = [
@ -144,12 +150,17 @@ class Purchase(models.Model):
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
@ -167,6 +178,9 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True)
@ -180,6 +194,8 @@ class Session(models.Model):
default=None,
)
note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
@ -188,10 +204,10 @@ class Session(models.Model):
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
@ -244,6 +260,7 @@ class Device(models.Model):
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

View File

@ -808,6 +808,10 @@ select {
margin-bottom: 2.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
@ -1231,6 +1235,19 @@ textarea:disabled) {
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) {
form input,
select,
@ -1392,6 +1409,11 @@ th label {
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
:is(.dark .dark\:text-slate-400) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
@ -1429,6 +1451,10 @@ th label {
padding-right: 1rem;
}
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 {
padding-left: 0.5rem;
}

View File

@ -25,6 +25,19 @@ function setupElementHandlers() {
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
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;
// Condition to check - replace this with your actual logic
if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request
}
}
});

View File

@ -1,25 +1,24 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Purchase"/></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,29 +1,32 @@
{% extends "base.html" %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" name="submit" value="Submit"/></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,12 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
@ -19,7 +18,9 @@
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
<button class="basic-button"
data-target="{{ field.name }}"
data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
@ -28,7 +29,9 @@
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>

View File

@ -1,72 +1,101 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
{% load static %}
<head>
<meta charset="utf-8" />
<meta name="description" content="Self-hosted time-tracker." />
<meta name="keywords" content="time, tracking, video games, self-hosted" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<title>Timetracker -
{% block title %}
Untitled
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'base.css' %}" />
</head>
<body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-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" />
<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' %}" width="48" class="mr-4" /></span>
<span class="text-4xl">
<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">
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_device' %}">Device</a>
</li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_game' %}">Game</a>
</li>
{% if game_available and platform_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_edition' %}">Edition</a>
</li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_platform' %}">Platform</a>
</li>
{% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_purchase' %}">Purchase</a>
</li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_session' %}">Session</a>
</li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
<li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'list_sessions' %}">All Sessions</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% block content %}No content here.{% endblock content %}
{% 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>
{% block scripts %}{% endblock scripts %}
{% block scripts %}
{% endblock scripts %}
</body>
</html>

View File

@ -2,24 +2,11 @@
title
text
{% endcomment %}
<a
href="{{ link }}"
<a href="{{ link }}"
title="{{ title }}"
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"
>
{% comment %} <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
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">
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{% endcomment %}
{{ text }}

View File

@ -2,25 +2,17 @@
title
text
{% endcomment %}
<button
type="button"
<button type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 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 text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 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 text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{{ text }}
</button>

View File

@ -1,21 +1,13 @@
<a href="{{ edit_url }}">
<button
type="button"
<button type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
class="w-5 h-5">
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
</svg>
</button>
</a>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %}

View File

@ -1,29 +1,24 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
{% if dataset.count >= 1 %}
<div class="mx-auto text-center my-4">
<a
id="last-session-start"
<a id="last-session-start"
href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-indicator="#indicator"
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 %}"
>
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 %}
<table class="responsive-table">
<thead>
<tr>
@ -36,11 +31,8 @@
<tbody>
{% for data in dataset %}
<tr>
<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"
<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>
@ -50,15 +42,13 @@
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if data.unfinished %}
<a
href="{% url 'update_session' data.id %}"
<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')"
>
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif data.duration_manual %}
@ -67,11 +57,12 @@
{{ 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">
{{ data.duration_formatted }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ data.duration_formatted }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1 @@
{{ form.related_purchase }}

View File

@ -1,15 +1,17 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% load static %}
{% 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">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
<select name="year"
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
@ -62,11 +64,15 @@
</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>
<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">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
@ -92,9 +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">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
</a>
<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>
@ -129,7 +134,10 @@
<tbody>
{% for purchase in all_finished_this_year %}
<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 %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url '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 %}
@ -146,7 +154,10 @@
<tbody>
{% for purchase in this_year_finished_this_year %}
<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 %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url '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 %}
@ -163,7 +174,10 @@
<tbody>
{% 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"><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">
<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 %}
@ -182,11 +196,10 @@
{% for purchase in all_purchased_this_year %}
<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 %}">
<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 %}
{% 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>

View File

@ -1,9 +1,8 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% load static %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center">
@ -13,11 +12,14 @@
{% include 'components/edit_button.html' with edit_url=edit_url %}
</h1>
<h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2>
({{ playrange }})
</h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<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>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
@ -32,69 +34,45 @@
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% for purchase in edition.game_purchases %}
<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 %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %}
<li>
</li>
<ul>
{% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-6 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500">
({{ sessions.count }})
</span>
<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>
{% for session in sessions %}
<li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }}
<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 %}
</li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %}
</ul>
</div>
{% endblock content %}

View File

@ -44,6 +44,11 @@ urlpatterns = [
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>",

View File

@ -1,24 +1,31 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Sum, F, Count
from typing import Any, Callable
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, HttpResponseRedirect
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo
from django.utils import timezone
from common.time import format_duration
from common.utils import safe_division
from .forms import (
DeviceForm,
EditionForm,
GameForm,
PlatformForm,
PurchaseForm,
SessionForm,
EditionForm,
DeviceForm,
)
from .models import Game, Platform, Purchase, Session, Edition
from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request):
@ -32,19 +39,15 @@ def model_counts(request):
def stats_dropdown_year_range(request):
result = {
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result
def add_session(request, purchase_id=None):
context = {}
initial = {"timestamp_start": now_with_tz()}
initial = {"timestamp_start": timezone.now()}
last = Session.objects.all().last()
last = Session.objects.last()
if last != None:
initial["purchase"] = last.purchase
@ -136,44 +139,52 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id)
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
game_purchases = (
Purchase.objects.filter(edition__game_id=game_id)
.filter(type=Purchase.GAME)
.order_by("date_purchased")
nongame_related_purchases_prefetch = Prefetch(
"related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME),
to_attr="nongame_related_purchases",
)
game_purchases_prefetch = Prefetch(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
context["session_average"] = round(
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
sessions = Session.objects.filter(purchase__edition__game=game)
session_count = sessions.count()
context["playrange"] = (
playrange_start = sessions.earliest().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}"
)
total_hours = float(format_duration(sessions.total_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": 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,
}
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@ -204,17 +215,24 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
@ -227,7 +245,7 @@ def start_session_same_as_last(request, last_session_id: int):
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
@ -269,27 +287,29 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = datetime.now().year
first_day_of_year = datetime(current_year, 1, 1)
current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else:
# by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start")
dataset = Session.objects.order_by("-timestamp_start")
for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0
):
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.timestamp_end = timezone.now()
session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context)
@ -299,7 +319,7 @@ def stats(request, year: int = 0):
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
year = timezone.now().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK"
unique_days = (
@ -432,7 +452,7 @@ def stats(request, year: int = 0):
def add_purchase(request, edition_id=None):
context = {}
initial = {"date_purchased": now_with_tz()}
initial = {"date_purchased": timezone.now()}
if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial)

780
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,9 +7,9 @@ license = "GPL"
readme = "README.md"
packages = [{include = "timetracker"}]
[tool.poetry.dependencies]
python = "^3.10"
django = "^4.1.4"
[tool.poetry.group.main.dependencies]
python = "^3.12"
django = "^4.2.0"
gunicorn = "^20.1.0"
uvicorn = "^0.20.0"
@ -23,6 +23,10 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2"
djlint = "^1.19.11"
isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system]
requires = ["poetry-core"]

View File

@ -1,15 +1,16 @@
import django
import os
from django.test import TestCase
from django.urls import reverse
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.test import TestCase
from django.urls import reverse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session, Platform
from games.models import Edition, Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -1,14 +1,16 @@
import django
import os
from django.test import TestCase
from django.db import models
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Game, Edition, Purchase, Session
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -40,7 +40,7 @@ INSTALLED_APPS = [
"django.contrib.staticfiles",
]
# if DEBUG:
if DEBUG:
INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin")
@ -123,7 +123,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static"
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static"
# Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field

View File

@ -23,6 +23,5 @@ urlpatterns = [
path("tracker/", include("games.urls")),
]
# if settings.DEBUG:
# urlpatterns.append(path("admin/", admin.site.urls))
if settings.DEBUG:
urlpatterns.append(path("admin/", admin.site.urls))