19 Commits
0.2.2 ... 0.2.5

Author SHA1 Message Date
55c2693f32 Bump version to 0.2.5
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-18 17:01:37 +01:00
972ff67050 Update CSS 2023-01-18 16:58:55 +01:00
8ae99faa8e Fix button taking up 100% width
Fixes #37
2023-01-18 16:58:25 +01:00
8e4086ce83 Remove reduntant property last
Fixes #38
2023-01-18 16:57:32 +01:00
2760068cde Add more to .gitignore
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-18 16:33:38 +01:00
cef797c333 Revert "Add date and time pickers to forms"
All checks were successful
continuous-integration/drone/push Build is passing
This reverts commit 4d91a76513.
2023-01-16 23:23:26 +01:00
4d91a76513 Add date and time pickers to forms
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-16 22:07:43 +01:00
e51d586255 Automatically select purchase when adding session 2023-01-16 21:19:20 +01:00
2553d6f9e6 Definitively disable pre-commit hook
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-16 19:49:41 +01:00
8cf6270d8f Bump version 2023-01-16 19:47:32 +01:00
0b1089b0f4 Disable pre-commit hook 2023-01-16 19:46:15 +01:00
9534492f17 Exclude manual times from graphs
Fixes #35
2023-01-16 19:39:24 +01:00
8b7ed90b49 Improve playtime graph date formatting 2023-01-16 19:27:52 +01:00
2ce4dd3a0e Fix graph timeline being backwards 2023-01-16 17:26:10 +01:00
a851b5329a Correctly display game that is used as filter 2023-01-16 17:24:34 +01:00
6fa049e1b1 Sort and clean up imports 2023-01-15 23:39:52 +01:00
6b7ed0dbb5 Order by timestamp_start from the newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-15 23:20:43 +01:00
dd50d6dd40 Allow filtering by platform and game
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #32
2023-01-15 23:14:28 +01:00
162f4f3dbf Fix Dockerfile
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-15 19:16:34 +01:00
26 changed files with 221 additions and 210 deletions

View File

@ -1,5 +1,8 @@
src/web/static/* .git
.venv
.githooks .githooks
.mypy_cache
.pytest_cache
.venv
.vscode .vscode
node_modules node_modules
src/web/static/*

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

View File

@ -1,3 +1,25 @@
## 0.2.5 / 2023-01-18 17:01+01:00
* New
* When adding session, pre-select game with the last session
* Fixed
* Start session now button would take up 100% width, leading to accidental clicks (https://git.kucharczyk.xyz/lukas/timetracker/issues/37)
* Removed
* Session model property `last` is already implemented by Django method `last()`, thus it was removed (https://git.kucharczyk.xyz/lukas/timetracker/issues/38)
## 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)
* Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33
## 0.2.2 / 2023-01-15 17:59+01:00 ## 0.2.2 / 2023-01-15 17:59+01:00
* Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29) * Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29)

View File

@ -4,17 +4,25 @@ COPY . /app
RUN npm install && \ RUN npm install && \
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
FROM python:3.10.9-alpine FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 0.2.2 ENV VERSION_NUMBER 0.2.5
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apk add \ RUN apt update && \
apt install -y \
bash \ bash \
vim \ vim \
curl \ curl && \
caddy apt install -y debian-keyring debian-archive-keyring apt-transport-https && \
RUN adduser -D -u 1000 timetracker curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && \
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list && \
apt update && \
apt install caddy && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app

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 sethookdir dev: migrate
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 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 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,9 +52,6 @@ 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,6 +417,24 @@ 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"
@ -1279,4 +1297,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 = "01d5c9b89b638c993f8540298dedfa79321b3aac1b2af70da58ef77706d0a113" content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "0.2.2" version = "0.2.5"
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,6 +23,7 @@ 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

@ -14,7 +14,7 @@ textarea {
#session-table { #session-table {
display: grid; display: grid;
grid-template-columns: repeat(3, 2fr) 0.5fr 1fr; grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
} }
#button-container button { #button-container button {

View File

@ -1,14 +1,13 @@
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.dates import date2num
import base64 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 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): 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): def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000 microsecond_in_second = 1000000
result = ( result = (
queryset.annotate(date=TruncDay("timestamp_start")) queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date") .values("date")
.annotate( .annotate(
hours=Sum( hours=Sum(
F("duration_calculated") + F("duration_manual"), F("duration_calculated"),
output_field=IntegerField(), output_field=IntegerField(),
) )
) )
@ -32,12 +32,18 @@ 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(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(): def get_graph():
@ -52,13 +58,34 @@ 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 = plt.figure(figsize=(10, 4)) fig, ax = plt.subplots()
plt.plot(data[0], data[1]) fig.set_size_inches(10, 4)
plt.title(title) ax.plot(x, y)
plt.xlabel(xlabel) first = x[0]
plt.ylabel(ylabel) last = x[-1]
plt.tight_layout() 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() chart = get_graph()
return chart return chart

View File

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

View File

@ -1,5 +1,6 @@
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,5 +1,6 @@
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
from django.db import migrations, models
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,6 +1,7 @@
# 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,6 +1,7 @@
# 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,9 @@
# 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 django.db import migrations
from datetime import timedelta from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor): def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session") Session = apps.get_model("tracker", "Session")

View File

@ -1,8 +1,9 @@
# 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 django.db import migrations
from datetime import timedelta from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor): def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("tracker", "Session") Session = apps.get_model("tracker", "Session")

View File

@ -1,11 +1,11 @@
from django.db import models
from datetime import datetime, timedelta 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 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): class Game(models.Model):
@ -79,10 +79,6 @@ class Session(models.Model):
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration() return Session.objects.all().total_duration()
@property
def last(self) -> Manager[Any]:
return Session.objects.all().order_by("timestamp_start")[:-1]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start

View File

@ -683,66 +683,34 @@ select {
width: 100%; width: 100%;
} }
.\!container {
width: 100% !important;
}
@media (min-width: 640px) { @media (min-width: 640px) {
.container { .container {
max-width: 640px; max-width: 640px;
} }
.\!container {
max-width: 640px !important;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.container { .container {
max-width: 768px; max-width: 768px;
} }
.\!container {
max-width: 768px !important;
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.container { .container {
max-width: 1024px; max-width: 1024px;
} }
.\!container {
max-width: 1024px !important;
}
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {
.container { .container {
max-width: 1280px; max-width: 1280px;
} }
.\!container {
max-width: 1280px !important;
}
} }
@media (min-width: 1536px) { @media (min-width: 1536px) {
.container { .container {
max-width: 1536px; max-width: 1536px;
} }
.\!container {
max-width: 1536px !important;
}
}
.visible {
visibility: visible;
}
.collapse {
visibility: collapse;
} }
.static { .static {
@ -753,26 +721,6 @@ select {
position: fixed; position: fixed;
} }
.\!fixed {
position: fixed !important;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.sticky {
position: sticky;
}
.\!sticky {
position: sticky !important;
}
.left-2 { .left-2 {
left: 0.5rem; left: 0.5rem;
} }
@ -781,6 +729,10 @@ select {
bottom: 0.5rem; bottom: 0.5rem;
} }
.clear-both {
clear: both;
}
.mx-auto { .mx-auto {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
@ -794,8 +746,12 @@ select {
margin-top: 1rem; margin-top: 1rem;
} }
.mb-5 { .mb-3 {
margin-bottom: 1.25rem; margin-bottom: 0.75rem;
}
.mt-10 {
margin-top: 2.5rem;
} }
.ml-1 { .ml-1 {
@ -806,10 +762,6 @@ select {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -818,28 +770,8 @@ select {
display: flex; display: flex;
} }
.table { .h-6 {
display: table; height: 1.5rem;
}
.table-caption {
display: table-caption;
}
.table-cell {
display: table-cell;
}
.contents {
display: contents;
}
.hidden {
display: none;
}
.\!hidden {
display: none !important;
} }
.h-5 { .h-5 {
@ -858,6 +790,10 @@ select {
width: 100%; width: 100%;
} }
.w-6 {
width: 1.5rem;
}
.w-5 { .w-5 {
width: 1.25rem; width: 1.25rem;
} }
@ -874,10 +810,6 @@ select {
max-width: 1024px; max-width: 1024px;
} }
.resize {
resize: both;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@ -910,10 +842,12 @@ select {
align-self: center; align-self: center;
} }
.truncate { .overflow-hidden {
overflow: hidden; overflow: hidden;
}
.text-ellipsis {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.whitespace-nowrap { .whitespace-nowrap {
@ -932,10 +866,6 @@ select {
border-radius: 0.75rem; border-radius: 0.75rem;
} }
.border {
border-width: 1px;
}
.border-gray-200 { .border-gray-200 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
@ -1029,14 +959,6 @@ select {
font-weight: 600; font-weight: 600;
} }
.uppercase {
text-transform: uppercase;
}
.lowercase {
text-transform: lowercase;
}
.text-white { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
@ -1064,29 +986,6 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.blur {
--tw-blur: blur(8px);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.invert {
--tw-invert: invert(100%);
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.\!invert {
--tw-invert: invert(100%) !important;
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) !important;
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.\!filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow) !important;
}
.transition { .transition {
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
@ -1122,7 +1021,7 @@ textarea {
#session-table { #session-table {
display: grid; display: grid;
grid-template-columns: repeat(3, 2fr) 0.5fr 1fr; grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
} }
#button-container button { #button-container button {

View File

@ -5,29 +5,39 @@
{% block content %} {% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400"> <div class="text-center text-xl mb-4 dark:text-slate-400">
{% if dataset.count >= 2 %} {% if dataset.count >= 2 %}
<img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-5" /> <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
<a href="{% url 'start_session' dataset.last.purchase.id %}"> {% endif %}
<button type="button" title="Track last tracked" class="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 "> {% if dataset.count >= 1 %}
New session of {{ dataset.last.purchase }} <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
{% endif %}
{% 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 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 %}
<a class="clear-both" href="{% url 'start_session' dataset.last.purchase.id %}">
<button type="button" title="Track last tracked" class="mt-10 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" />
</svg>
{{ last.purchase }}
</button> </button>
</a> </a>
{% else %}
Playtime chart will be displayed when there are 2 or more sessions.
{% endif %}
{% if purchase %}
<h1>Listing sessions only for purchase "{{ purchase }}"</h1>
<h2>Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</h2>
<a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a>
{% endif %} {% endif %}
</div> </div>
<div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center"> <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
<div class="dark:border-white dark:text-slate-300 text-lg">Name</div> <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
<div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div> <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div> <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div> <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
{% for data in dataset %} {% for data in dataset %}
<div><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div> <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.game }}</a></div>
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div>
<div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400 text-center"> <div class="dark:text-slate-400 text-center">
{% if data.unfinished %} {% if data.unfinished %}

View File

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

@ -27,6 +27,19 @@ urlpatterns = [
path( path(
"list-sessions/by-purchase/<int:purchase_id>", "list-sessions/by-purchase/<int:purchase_id>",
views.list_sessions, views.list_sessions,
name="list_sessions", {"filter": "purchase"},
name="list_sessions_by_purchase",
),
path(
"list-sessions/by-platform/<int:platform_id>",
views.list_sessions,
{"filter": "platform"},
name="list_sessions_by_platform",
),
path(
"list-sessions/by-game/<int:game_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
), ),
] ]

View File

@ -1,14 +1,13 @@
from django.shortcuts import render, redirect from datetime import datetime
from .models import Game, Platform, Purchase, Session
from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo 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.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): def model_counts(request):
@ -23,7 +22,8 @@ def model_counts(request):
def add_session(request): def add_session(request):
context = {} context = {}
now = now_with_tz() now = now_with_tz()
initial = {"timestamp_start": now} last = Session.objects.all().last()
initial = {"timestamp_start": now, "purchase": last.purchase}
form = SessionForm(request.POST or None, initial=initial) form = SessionForm(request.POST or None, initial=initial)
if form.is_valid(): if form.is_valid():
form.save() form.save()
@ -53,14 +53,21 @@ def delete_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def list_sessions(request, purchase_id=None): def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
context = {} context = {}
if purchase_id != None: if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id) context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Game.objects.get(id=game_id)
else: else:
dataset = Session.objects.all().order_by("timestamp_start") # by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0: if session.timestamp_end == None and session.duration_manual.seconds == 0:
@ -69,7 +76,10 @@ def list_sessions(request, purchase_id=None):
context["total_duration"] = dataset.total_duration() context["total_duration"] = dataset.total_duration()
context["dataset"] = dataset context["dataset"] = dataset
context["chart"] = playtime_over_time_chart(dataset) # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
# charts are always oldest->newest
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,9 +10,8 @@ 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/
""" """
from pathlib import Path
import logging
import os import os
from pathlib import Path
# 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,11 +13,10 @@ 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,8 @@
import unittest import unittest
from web.common.util.time import format_duration
from datetime import timedelta from datetime import timedelta
from web.common.util.time import format_duration
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None: