Compare commits

..

No commits in common. "2553d6f9e606a1b292a20937e9205800dbe4725e" and "6b7ed0dbb5876e205b603905d54e761f0389cab4" have entirely different histories.

22 changed files with 61 additions and 119 deletions

0
.githooks/pre-commit.disabled → .githooks/pre-commit Normal file → Executable file
View File

View File

@ -1,11 +1,3 @@
## 0.2.4 / 2023-01-16 19:39+01:00
* Fixed
* When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform
* Playtime graph would display timeline backwards
* Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34)
* Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35)
## 0.2.3 / 2023-01-15 23:13+01:00 ## 0.2.3 / 2023-01-15 23:13+01:00
* Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32) * Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32)

View File

@ -6,7 +6,7 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 0.2.4 ENV VERSION_NUMBER 0.2.3
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1

View File

@ -19,13 +19,13 @@ makemigrations:
migrate: makemigrations migrate: makemigrations
poetry run python src/web/manage.py migrate poetry run python src/web/manage.py migrate
dev: migrate dev: migrate sethookdir
poetry run python src/web/manage.py runserver poetry run python src/web/manage.py runserver
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic sethookdir
cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
dumptracker: dumptracker:
@ -52,6 +52,9 @@ poetry.lock: pyproject.toml
test: poetry.lock test: poetry.lock
poetry run pytest poetry run pytest
sethookdir:
git config core.hooksPath .githooks
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'

20
poetry.lock generated
View File

@ -417,24 +417,6 @@ files = [
{file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"}, {file = "iniconfig-1.1.1.tar.gz", hash = "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"},
] ]
[[package]]
name = "isort"
version = "5.11.4"
description = "A Python utility / library to sort Python imports."
category = "dev"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "isort-5.11.4-py3-none-any.whl", hash = "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b"},
{file = "isort-5.11.4.tar.gz", hash = "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6"},
]
[package.extras]
colors = ["colorama (>=0.4.3,<0.5.0)"]
pipfile-deprecated-finder = ["pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]] [[package]]
name = "jsbeautifier" name = "jsbeautifier"
version = "1.14.7" version = "1.14.7"
@ -1297,4 +1279,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683" content-hash = "01d5c9b89b638c993f8540298dedfa79321b3aac1b2af70da58ef77706d0a113"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "0.2.4" version = "0.2.3"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
@ -23,7 +23,6 @@ django-extensions = "^3.2.1"
werkzeug = "^2.2.2" werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@ -1,13 +1,14 @@
import base64
from datetime import datetime
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum import matplotlib.pyplot as plt
from django.db.models.functions import TruncDay from matplotlib.dates import date2num
import base64
from io import BytesIO
from tracker.models import Session from tracker.models import Session
from django.db.models import Sum, IntegerField, F
from django.db.models.functions import TruncDay
import logging
from datetime import datetime
from django.db.models import QuerySet
def key_value_to_value_value(data): def key_value_to_value_value(data):
@ -17,12 +18,11 @@ def key_value_to_value_value(data):
def playtime_over_time_chart(queryset: QuerySet = Session.objects): def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000 microsecond_in_second = 1000000
result = ( result = (
queryset.exclude(timestamp_end__exact=None) queryset.annotate(date=TruncDay("timestamp_start"))
.annotate(date=TruncDay("timestamp_start"))
.values("date") .values("date")
.annotate( .annotate(
hours=Sum( hours=Sum(
F("duration_calculated"), F("duration_calculated") + F("duration_manual"),
output_field=IntegerField(), output_field=IntegerField(),
) )
) )
@ -32,18 +32,12 @@ def playtime_over_time_chart(queryset: QuerySet = Session.objects):
values = [] values = []
running_total = int(0) running_total = int(0)
for item in result: for item in result:
# date_value = datetime.strftime(item["date"], "%d-%m-%Y") date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value) keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second)) running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total) values.append(running_total)
data = [keys, values] data = [keys, values]
return get_chart( return get_chart(data, title="Playtime over time", xlabel="Date", ylabel="Hours")
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph(): def get_graph():
@ -58,34 +52,13 @@ def get_graph():
def get_chart(data, title="", xlabel="", ylabel=""): def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background") plt.style.use("dark_background")
plt.switch_backend("SVG") plt.switch_backend("SVG")
fig, ax = plt.subplots() fig = plt.figure(figsize=(10, 4))
fig.set_size_inches(10, 4) plt.plot(data[0], data[1])
ax.plot(x, y) plt.title(title)
first = x[0] plt.xlabel(xlabel)
last = x[-1] plt.ylabel(ylabel)
difference = last - first plt.tight_layout()
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
else:
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph() chart = get_graph()
return chart return chart

View File

@ -1,8 +1,7 @@
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings from django.conf import settings
from zoneinfo import ZoneInfo
import re
def now() -> datetime: def now() -> datetime:

View File

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

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from .models import Session, Purchase, Game, Platform
from .models import Game, Platform, Purchase, Session
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):

View File

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

View File

@ -1,7 +1,6 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55 # Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime import datetime
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,7 +1,6 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49 # Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime import datetime
from django.db import migrations, models from django.db import migrations, models

View File

@ -1,8 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43 # Generated by Django 4.1.5 on 2023-01-09 17:43
from datetime import timedelta
from django.db import migrations from django.db import migrations
from datetime import timedelta
def set_duration_calculated_none_to_zero(apps, schema_editor): def set_duration_calculated_none_to_zero(apps, schema_editor):

View File

@ -1,8 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04 # Generated by Django 4.1.5 on 2023-01-09 18:04
from datetime import timedelta
from django.db import migrations from django.db import migrations
from datetime import timedelta
def set_duration_manual_none_to_zero(apps, schema_editor): def set_duration_manual_none_to_zero(apps, schema_editor):

View File

@ -1,11 +1,11 @@
from datetime import datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from common.util.time import format_duration
from django.conf import settings
from django.db import models from django.db import models
from django.db.models import F, Manager, Sum from datetime import datetime, timedelta
from django.conf import settings
from zoneinfo import ZoneInfo
from common.util.time import format_duration
from django.db.models import Sum, F
from django.db.models import Manager
from typing import Any
class Game(models.Model): class Game(models.Model):

View File

@ -10,11 +10,11 @@
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %} {% endif %}
{% if purchase or platform or game %} {% if purchase or platform %}
<a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline"> <a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</a><span>Filtering by "{% firstof purchase platform game %}"</span> </a><span>Filtering by "{% firstof purchase platform %}"</span>
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %} {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
{% endif %} {% endif %}
{% if dataset.count >= 1 %} {% if dataset.count >= 1 %}

View File

@ -1,8 +1,7 @@
import os
import time
from django import template from django import template
from django.conf import settings from django.conf import settings
import time
import os
register = template.Library() register = template.Library()

View File

@ -1,13 +1,14 @@
from datetime import datetime from django.shortcuts import render, redirect
from zoneinfo import ZoneInfo
from common.util.plots import playtime_over_time_chart
from common.util.time import now as now_with_tz
from django.conf import settings
from django.shortcuts import redirect, render
from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm
from .models import Game, Platform, Purchase, Session from .models import Game, Platform, Purchase, Session
from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
from common.util.time import now as now_with_tz, format_duration
from django.db.models import Sum
import logging
from common.util.plots import playtime_over_time_chart
def model_counts(request): def model_counts(request):
@ -63,7 +64,7 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "game": elif filter == "game":
dataset = Session.objects.filter(purchase__game=game_id) dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Platform.objects.get(id=game_id)
else: else:
dataset = Session.objects.all().order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
@ -74,8 +75,7 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
context["total_duration"] = dataset.total_duration() context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset context["dataset"] = dataset
# charts are always oldest->newest context["chart"] = playtime_over_time_chart(dataset)
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)

View File

@ -10,8 +10,9 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.1/ref/settings/ https://docs.djangoproject.com/en/4.1/ref/settings/
""" """
import os
from pathlib import Path from pathlib import Path
import logging
import os
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent

View File

@ -13,10 +13,11 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from django.conf import settings
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker/list-sessions")), path("", RedirectView.as_view(url="/tracker/list-sessions")),

View File

@ -1,7 +1,6 @@
import unittest import unittest
from datetime import timedelta
from web.common.util.time import format_duration from web.common.util.time import format_duration
from datetime import timedelta
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):