Compare commits

...

8 Commits

22 changed files with 119 additions and 61 deletions

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

View File

@ -1,3 +1,11 @@
## 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
* 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
ENV VERSION_NUMBER 0.2.3
ENV VERSION_NUMBER 0.2.4
ENV PROD 1
ENV PYTHONUNBUFFERED=1

View File

@ -19,13 +19,13 @@ makemigrations:
migrate: makemigrations
poetry run python src/web/manage.py migrate
dev: migrate sethookdir
dev: migrate
poetry run python src/web/manage.py runserver
caddy:
caddy run --watch
dev-prod: migrate collectstatic sethookdir
dev-prod: migrate collectstatic
cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
dumptracker:
@ -52,9 +52,6 @@ poetry.lock: pyproject.toml
test: poetry.lock
poetry run pytest
sethookdir:
git config core.hooksPath .githooks
date:
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,6 +417,24 @@ files = [
{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]]
name = "jsbeautifier"
version = "1.14.7"
@ -1279,4 +1297,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "01d5c9b89b638c993f8540298dedfa79321b3aac1b2af70da58ef77706d0a113"
content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683"

View File

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

View File

@ -1,14 +1,13 @@
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import date2num
import base64
from io import BytesIO
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
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from tracker.models import Session
def key_value_to_value_value(data):
@ -18,11 +17,12 @@ def key_value_to_value_value(data):
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.annotate(date=TruncDay("timestamp_start"))
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated") + F("duration_manual"),
F("duration_calculated"),
output_field=IntegerField(),
)
)
@ -32,12 +32,18 @@ def playtime_over_time_chart(queryset: QuerySet = Session.objects):
values = []
running_total = int(0)
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)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(data, title="Playtime over time", xlabel="Date", ylabel="Hours")
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
@ -52,13 +58,34 @@ def get_graph():
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig = plt.figure(figsize=(10, 4))
plt.plot(data[0], data[1])
plt.title(title)
plt.xlabel(xlabel)
plt.ylabel(ylabel)
plt.tight_layout()
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
first = x[0]
last = x[-1]
difference = last - first
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()
return chart

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.4 on 2023-01-02 18:27
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.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models

View File

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

View File

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

View File

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

View File

@ -1,11 +1,11 @@
from django.db import models
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
from zoneinfo import ZoneInfo
from common.util.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model):

View File

@ -10,11 +10,11 @@
{% if dataset.count >= 1 %}
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %}
{% if purchase or platform %}
{% if purchase or platform or game %}
<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" />
</svg>
</a><span>Filtering by "{% firstof purchase platform %}"</span>
</a><span>Filtering by "{% firstof purchase platform game %}"</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 %}
{% endif %}
{% if dataset.count >= 1 %}

View File

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

View File

@ -1,14 +1,13 @@
from django.shortcuts import render, redirect
from .models import Game, Platform, Purchase, Session
from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
from datetime import datetime, timedelta
from datetime import datetime
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
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
def model_counts(request):
@ -64,7 +63,7 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Platform.objects.get(id=game_id)
context["game"] = Game.objects.get(id=game_id)
else:
dataset = Session.objects.all().order_by("-timestamp_start")
@ -75,7 +74,8 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset
context["chart"] = playtime_over_time_chart(dataset)
# charts are always oldest->newest
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context)

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import unittest
from web.common.util.time import format_duration
from datetime import timedelta
from web.common.util.time import format_duration
class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: