13 Commits

Author SHA1 Message Date
d4ab0596da Re-introduce index view
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 20:50:36 +01:00
8dcbe2f0ad Fix displaying of "filterying by..." text 2023-02-18 20:50:19 +01:00
25bc74eff1 Add support for game editions (#28) 2023-02-18 20:49:46 +01:00
8a7d083fb2 Fix make css 2023-02-18 20:35:59 +01:00
8296ebcf31 Fix redirect to index
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-06 17:23:44 +01:00
04a4f2e0be Order homepage sessions from newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-31 16:37:44 +01:00
4070b4e46e Version 1.0.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-30 22:17:47 +01:00
4892218c83 Show only last 30 days on homepage
Fixes #47
2023-01-30 22:16:28 +01:00
6b00a950ce Show markers on smaller graphs 2023-01-30 22:01:27 +01:00
feee9d6dac Make it possible to edit sessions
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #46
2023-01-30 17:38:44 +01:00
9654fb017d Separate Docker container by branch
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 11:36:34 +01:00
1741397ee7 Prepare changelog
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 00:50:44 +01:00
da0c8d710b Ignore dist directory
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-22 11:02:26 +01:00
17 changed files with 252 additions and 47 deletions

View File

@ -11,15 +11,30 @@ steps:
- poetry install
- poetry env info
- poetry run pytest
- name: build container
- name: build container (prod)
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- latest
when:
branch:
- main
- name: build container (non-prod)
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when:
branch:
exclude:
- main
trigger:
event:
- push
- cron
exclude:
- pull_request
- cron

3
.gitignore vendored
View File

@ -5,4 +5,5 @@ __pycache__
node_modules
package-lock.json
db.sqlite3
static
static
dist/

View File

@ -1,3 +1,13 @@
## Unreleased
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00
* Breaking

View File

@ -7,11 +7,11 @@ HTMLFILES := $(shell find games/templates -type f)
npm:
npm install
css: input.css
npx tailwindcss -i ./input.css -o ./games/static/base.css
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./input.css -o ./games/static/base.css --watch
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations:
poetry run python manage.py makemigrations

View File

@ -64,7 +64,7 @@ def get_chart(data, title="", xlabel="", ylabel=""):
plt.switch_backend("SVG")
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
lines = ax.plot(x, y, "-o")
first = x[0]
last = x[-1]
difference = last - first
@ -76,7 +76,11 @@ def get_chart(data, title="", xlabel="", ylabel=""):
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
for line in lines:
line.set_marker("")
else:
for line in lines:
line.set_marker("")
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())

View File

@ -1,10 +1,12 @@
from django import forms
from games.models import Game, Platform, Purchase, Session
from games.models import Game, Platform, Purchase, Session, Edition
class SessionForm(forms.ModelForm):
purchase = forms.ModelChoiceField(queryset=Purchase.objects.order_by("game__name"))
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name")
)
class Meta:
model = Session
@ -18,12 +20,18 @@ class SessionForm(forms.ModelForm):
class PurchaseForm(forms.ModelForm):
game = forms.ModelChoiceField(queryset=Game.objects.order_by("name"))
edition = forms.ModelChoiceField(queryset=Edition.objects.order_by("name"))
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta:
model = Purchase
fields = ["game", "platform", "date_purchased", "date_refunded"]
fields = ["edition", "platform", "date_purchased", "date_refunded"]
class EditionForm(forms.ModelForm):
class Meta:
model = Edition
fields = ["game", "name", "platform"]
class GameForm(forms.ModelForm):

View File

@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]

View File

@ -16,14 +16,23 @@ class Game(models.Model):
return self.name
class Purchase(models.Model):
class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
def __str__(self):
return self.name
class Purchase(models.Model):
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
def __str__(self):
return f"{self.game} ({self.platform})"
return f"{self.edition} ({self.platform})"
class Platform(models.Model):

View File

@ -17,7 +17,7 @@
<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 'index' %}" class="flex items-center">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
@ -27,6 +27,9 @@
<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_edition' %}">New Edition</a></li>
{% endif %}
{% if edition_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 %}

View File

@ -1,6 +1,6 @@
{% extends 'base.html' %}
{% block title %}Sessions{% endblock title %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
@ -10,15 +10,18 @@
{% if dataset.count >= 1 %}
<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 %}
{% if purchase or platform or edition %}
<span class="block">
<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>Filtering by "{% firstof purchase platform edition %}"
</span>
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
{% endif %}
{% if dataset.count >= 1 %}
<a class="clear-both" href="{% url 'start_session' last.purchase.id %}">
<a href="{% url 'start_session' 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" />
@ -36,7 +39,7 @@
<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>
{% for data in dataset %}
<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_purchase' data.purchase.id %}">{{ data.purchase.edition }}</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">
@ -62,12 +65,14 @@
</button>
</a>
{% endif %}
<button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-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 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" />
</svg>
</button>
<a href="{% url 'edit_session' data.id %}">
<button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-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 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" />
</svg>
</button>
</a>
<a href="{% url 'delete_session' data.id %}">
<button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-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 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">

View File

@ -4,6 +4,12 @@ from games import views
urlpatterns = [
path("", views.index, name="index"),
path(
"list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
),
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"),
@ -23,6 +29,8 @@ urlpatterns = [
name="delete_session",
),
path("add-purchase/", views.add_purchase, name="add_purchase"),
path("add-edition/", views.add_edition, name="add_edition"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("list-sessions/", views.list_sessions, name="list_sessions"),
path(
"list-sessions/by-purchase/<int:purchase_id>",
@ -37,9 +45,9 @@ urlpatterns = [
name="list_sessions_by_platform",
),
path(
"list-sessions/by-game/<int:game_id>",
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "game"},
name="list_sessions_by_game",
{"filter": "edition"},
name="list_sessions_by_edition",
),
]

View File

@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from common.plots import playtime_over_time_chart
@ -6,13 +6,14 @@ from common.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 .forms import GameForm, PlatformForm, PurchaseForm, SessionForm, EditionForm
from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request):
return {
"game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(),
@ -47,6 +48,18 @@ def update_session(request, session_id=None):
return redirect("list_sessions")
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add.html", context)
def start_session(request, purchase_id=None):
session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
session.save()
@ -59,8 +72,9 @@ def delete_session(request, session_id=None):
return redirect("list_sessions")
def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id=""):
context = {}
context["title"] = "Sessions"
if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id)
@ -68,9 +82,14 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_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)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "recent":
dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30)
).order_by("-timestamp_start")
context["title"] = "Last 30 days"
else:
# by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start")
@ -117,6 +136,18 @@ def add_game(request):
return render(request, "add.html", context)
def add_edition(request):
context = {}
form = EditionForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Edition"
return render(request, "add.html", context)
def add_platform(request):
context = {}
form = PlatformForm(request.POST or None)
@ -130,7 +161,4 @@ def add_platform(request):
def index(request):
context = {}
context["total_duration"] = Session().duration_sum
context["title"] = "Index"
return render(request, "index.html", context)
return redirect("list_sessions_recent")

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "timetracker"
version = "1.0.0"
version = "1.0.1"
description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL"

View File

@ -19,7 +19,7 @@ from django.urls import include, path
from django.views.generic import RedirectView
urlpatterns = [
path("", RedirectView.as_view(url="/tracker/list-sessions")),
path("", RedirectView.as_view(url="/tracker")),
path("tracker/", include("games.urls")),
]