19 Commits

Author SHA1 Message Date
4b45127335 Allow deleting sessions
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 20:28:07 +01:00
b8a15e43db Redirect after adding game/platform/purchase/session 2023-01-04 19:35:35 +01:00
a1309c3738 Fix display of duration_manual 2023-01-04 19:32:18 +01:00
12cc9025a0 Fix display of duration_calculated, display durations less than a minute 2023-01-04 19:26:01 +01:00
6fe960bc04 Make the "Finish now?" button on session list work 2023-01-04 19:19:49 +01:00
61d2e65d83 Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 17:46:06 +01:00
84dafe9223 Update generated CSS
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-04 17:29:19 +01:00
59cf620ff3 Set version in the footer to fixed, fix main container height 2023-01-04 17:28:51 +01:00
40810256aa Improve session listing 2023-01-04 17:27:54 +01:00
b3842504af Save calculated duration to database 2023-01-04 17:25:19 +01:00
bf61326c18 Make it possible to add a new platform 2023-01-04 17:24:54 +01:00
4c642d97cb Add homepage, link to it from the logo 2023-01-04 17:22:36 +01:00
d225856174 Set default version to "git-main" 2023-01-04 17:20:46 +01:00
5c50e059e6 Hide navigation bar items
If there are no games/purchases/sessions,
hide the related navbar items
2023-01-04 17:19:40 +01:00
84c92fe654 Fix version
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 22:33:03 +01:00
166dd716ed Remove non-working tag from CI file
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-03 22:06:43 +01:00
6102459637 Set VERSION_NUMBER in Dockerfile
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-03 22:04:46 +01:00
e4cd75d51f Use add.html for add_purchase 2023-01-03 22:04:36 +01:00
b1c8f58855 Add make shell 2023-01-03 22:04:22 +01:00
16 changed files with 229 additions and 92 deletions

View File

@ -8,11 +8,8 @@ steps:
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
environment:
VERSION_NUMBER: $(git describe --tags --abbrev=0)
tags:
- latest
- $VERSION_NUMBER
trigger:
event:
- push

13
CHANGELOG.md Normal file
View File

@ -0,0 +1,13 @@
## Unreleased
* Allow deleting sessions
* Redirect after adding game/platform/purchase/session
* Fix display of duration_manual
* Fix display of duration_calculated, display durations less than a minute
* Make the "Finish now?" button on session list work
* Hide navigation bar items if there are no games/purchases/sessions
* Set default version to "git-main" to indicate development environment
* Add homepage, link to it from the logo
* Make it possible to add a new platform
* Save calculated duration to database if both timestamps are set
* Improve session listing
* Set version in the footer to fixed, fix main container height

View File

@ -12,4 +12,5 @@ COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
USER timetracker
EXPOSE 8000
ENV VERSION_NUMBER 0.1.0-18-gb8a15e4
ENTRYPOINT [ "/entrypoint.sh" ]

View File

@ -1,4 +1,4 @@
.PHONY: createsuperuser
.PHONY: createsuperuser shell
all: css migrate
@ -34,4 +34,7 @@ loadsample:
python src/web/manage.py loaddata sample.yaml
createsuperuser:
python src/web/manage.py createsuperuser
python src/web/manage.py createsuperuser
shell:
python src/web/manage.py shell

View File

View File

@ -0,0 +1,7 @@
from datetime import datetime
from django.conf import settings
from zoneinfo import ZoneInfo
def now():
return datetime.now(ZoneInfo(settings.TIME_ZONE))

View File

@ -1,5 +1,5 @@
from django import forms
from .models import Session, Purchase, Game
from .models import Session, Purchase, Game, Platform
class SessionForm(forms.ModelForm):
@ -24,3 +24,9 @@ class GameForm(forms.ModelForm):
class Meta:
model = Game
fields = ["name", "wikidata"]
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = ["name", "group"]

View File

@ -1,5 +1,7 @@
from django.db import models
from datetime import timedelta
from datetime import datetime
from django.conf import settings
from zoneinfo import ZoneInfo
class Game(models.Model):
@ -38,17 +40,42 @@ class Session(models.Model):
def __str__(self):
mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.total_duration()}{mark})"
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{mark})"
def calculated_duration(self):
if self.timestamp_end == None or self.timestamp_start == None:
return 0
def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self):
if self.duration_manual == None:
if self.timestamp_end == None or self.timestamp_start == None:
return 0
else:
value = self.timestamp_end - self.timestamp_start
else:
return self.timestamp_end - self.timestamp_start
value = self.duration_manual
return value.total_seconds()
def total_duration(self):
def duration_formatted(self):
seconds = self.duration_seconds()
if seconds == 0:
return seconds
hours, remainder = divmod(seconds, 3600)
minutes = remainder // 60
if hours == 0 and minutes == 0:
return "less than a minute"
else:
hour_string = f"{int(hours)}h" if hours != 0 else ""
minute_string = f"{int(minutes)}m" if minutes != 0 else ""
return f"{hour_string}{minute_string}"
def duration_any(self):
return (
self.calculated_duration()
self.duration_formatted()
if self.duration_manual == None
else self.duration_manual + self.calculated_duration()
else self.duration_manual
)
def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start
super(Session, self).save(*args, **kwargs)

View File

@ -717,24 +717,16 @@ select {
position: static;
}
.absolute {
position: absolute;
.fixed {
position: fixed;
}
.left-0 {
left: 0px;
.left-2 {
left: 0.5rem;
}
.bottom-0 {
bottom: 0px;
}
.left-1 {
left: 0.25rem;
}
.bottom-1 {
bottom: 0.25rem;
.bottom-2 {
bottom: 0.5rem;
}
.mx-auto {
@ -762,8 +754,8 @@ select {
display: grid;
}
.h-screen {
height: 100vh;
.min-h-screen {
min-height: 100vh;
}
.w-full {
@ -774,8 +766,8 @@ select {
max-width: 1024px;
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
.grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.flex-col {
@ -818,16 +810,30 @@ select {
border-radius: 0.75rem;
}
.border {
border-width: 1px;
}
.border-gray-200 {
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
}
.border-red-900 {
--tw-border-opacity: 1;
border-color: rgb(127 29 29 / var(--tw-border-opacity));
}
.bg-white {
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-red-700 {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.p-4 {
padding: 1rem;
}
@ -836,6 +842,10 @@ select {
padding: 0.5rem;
}
.p-1 {
padding: 0.25rem;
}
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
@ -863,6 +873,11 @@ select {
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
@ -873,11 +888,6 @@ select {
line-height: 1.25rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-semibold {
font-weight: 600;
}
@ -887,31 +897,6 @@ select {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-slate-800 {
--tw-text-opacity: 1;
color: rgb(30 41 59 / var(--tw-text-opacity));
}
.text-slate-700 {
--tw-text-opacity: 1;
color: rgb(51 65 85 / var(--tw-text-opacity));
}
.text-slate-600 {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
.text-slate-500 {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
.text-slate-400 {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));
@ -944,6 +929,20 @@ form input[type=submit] {
padding: 0.5rem;
}
.hover\:border-dotted:hover {
border-style: dotted;
}
.hover\:border-white:hover {
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
}
.hover\:bg-orange-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(194 65 12 / var(--tw-bg-opacity));
}
.hover\:underline:hover {
text-decoration-line: underline;
}
@ -968,16 +967,16 @@ form input[type=submit] {
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
}
.dark .dark\:bg-slate-400 {
--tw-bg-opacity: 1;
background-color: rgb(148 163 184 / var(--tw-bg-opacity));
}
.dark .dark\:text-white {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.dark .dark\:text-slate-600 {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
.dark .dark\:text-slate-400 {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
@ -988,11 +987,6 @@ form input[type=submit] {
color: rgb(203 213 225 / var(--tw-text-opacity));
}
.dark .dark\:text-slate-600 {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity));
}
@media (min-width: 768px) {
.md\:block {
display: block;

View File

@ -12,10 +12,10 @@
</head>
<body class="dark">
<div class="dark:bg-gray-800 h-screen">
<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="#" class="flex items-center">
<a href="{% url 'index' %}" class="flex items-center">
<span class="text-4xl"></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
@ -23,9 +23,16 @@
<ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{% if game_available and platform_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
{% endif %}
{% if session_count > 0 %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %}
</ul>
</div>
</div>
@ -33,7 +40,7 @@
{% block content %}No content here.{% endblock %}
</div>
{% load version %}
<span id="version-info" class="absolute left-1 bottom-1 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
</body>
</html>

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %}
You have played a total of {{ session_count }} sessions.
{% else %}
Start by clicking the links at the top. To track playtime, you need to have at least 1 owned game.
{% endif %}
</div>
{% endblock content %}

View File

@ -9,16 +9,28 @@
<a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a>
</div>
{% endif %}
<div class="grid grid-cols-4 gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
<div class="grid grid-cols-5 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">Start</div>
<div class="dark:border-white dark:text-slate-300 text-lg">End</div>
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<div></div>
{% for data in dataset %}
<div class=""><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div>
<div class="dark:text-slate-400">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400">{{ data.timestamp_end | date:"d/m/Y H:i" }}</div>
<div class="dark:text-slate-400">{{ data.time_delta }}</div>
<div class="dark:text-slate-400">
{% if data.unfinished %}
Not finished yet. <a href="{% url 'update_session' data.id %}"><button class="bg-red-700 hover:bg-orange-700 border border-red-900 hover:border-dotted hover:border-white rounded p-1 text-white text-sm">Finish now?</button></a>
{% elif data.duration_manual %}
MANUAL
{% else %}
{{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</div>
<div class="dark:text-slate-400">{{ data.duration_formatted }}{% if data.duration_manual %} (M){% endif %}</div>
<div>
<a href="{% url 'delete_session' data.id %}"><button></button></a>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -15,4 +15,4 @@ def version_date():
@register.simple_tag
def version():
return os.environ.get("VERSION_NUMBER", "UNKNOWN VERSION")
return os.environ.get("VERSION_NUMBER", "git-main")

View File

@ -3,8 +3,20 @@ from django.urls import path
from . import views
urlpatterns = [
path("", views.index, name="index"),
path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"),
path(
"update-session/by-session/<int:session_id>",
views.update_session,
name="update_session",
),
path(
"delete_session/by-id/<int:session_id>",
views.delete_session,
name="delete_session",
),
path("add-purchase/", views.add_purchase, name="add_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"),
path(

View File

@ -1,24 +1,48 @@
from django.shortcuts import render
from django.shortcuts import render, redirect
from .models import Game, Platform, Purchase, Session
from .forms import SessionForm, PurchaseForm, GameForm
from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm
from datetime import datetime
from django.db.models import ExpressionWrapper, F, DurationField
import logging
from zoneinfo import ZoneInfo
from django.conf import settings
from common.util.time import now as now_with_tz
def model_counts(request):
return {
"game_available": Game.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
}
def add_session(request):
context = {}
now = datetime.now()
initial = {"timestamp_start": now, "timestamp_end": now}
now = now_with_tz()
initial = {"timestamp_start": now}
form = SessionForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["form"] = form
return render(request, "add_session.html", context)
def update_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.finish_now()
session.save()
return redirect("list_sessions")
def delete_session(request, session_id=None):
session = Session.objects.get(id=session_id)
session.delete()
return redirect("list_sessions")
def list_sessions(request, purchase_id=None):
context = {}
@ -28,11 +52,11 @@ def list_sessions(request, purchase_id=None):
else:
dataset = Session.objects.all()
dataset = dataset.annotate(
time_delta=ExpressionWrapper(
F("timestamp_end") - F("timestamp_start"), output_field=DurationField()
)
)
for session in dataset:
if session.timestamp_end == None and session.duration_manual == None:
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True
context["dataset"] = dataset
return render(request, "list_sessions.html", context)
@ -45,9 +69,11 @@ def add_purchase(request):
form = PurchaseForm(request.POST or None, initial=initial)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
return render(request, "add_purchase.html", context)
context["title"] = "Add New Purchase"
return render(request, "add.html", context)
def add_game(request):
@ -55,7 +81,25 @@ def add_game(request):
form = GameForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Game"
return render(request, "add.html", context)
def add_platform(request):
context = {}
form = PlatformForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
def index(request):
context = {}
return render(request, "index.html", context)

View File

@ -65,6 +65,7 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"tracker.views.model_counts",
],
},
},