14 Commits

Author SHA1 Message Date
241aa9dc13 Add manage page
All checks were successful
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been skipped
2024-07-11 14:05:25 +02:00
4670568acb Add .DS_Store to .gitignore
All checks were successful
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m34s
2024-01-15 22:09:29 +01:00
4b75a1dea9 Increase session count on game overview when starting a new session
All checks were successful
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Successful in 1m32s
2024-01-15 21:41:25 +01:00
e2b7ff2e15 Remove cruft
All checks were successful
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m30s
2024-01-15 19:17:27 +01:00
b94aa49fc3 Fix title not being displayed on the Recent sessions page 2024-01-15 19:17:24 +01:00
73a92e5636 Mark refunded purchases red
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-15 11:19:18 +01:00
42b28665e1 Version 1.5.2
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-14 21:28:38 +01:00
6ba187f8e4 Make it possible to end session from game overview 2024-01-14 21:27:18 +01:00
a765fd8d00 More game overview optimizations
All checks were successful
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 1m37s
2024-01-14 17:04:06 +01:00
854e3cc54a Do not copy notes when cloning session
All checks were successful
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m45s
2024-01-14 13:05:45 +01:00
2d8eb32e90 Remove cruft
All checks were successful
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m29s
2024-01-10 17:13:59 +01:00
1f1ed79ee5 Optimize session listing 2024-01-10 16:57:01 +01:00
01fd7bad69 Remove cruft 2024-01-10 15:55:08 +01:00
44f49e5974 Session list: speed up starting new sessions
All checks were successful
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-10 15:54:09 +01:00
10 changed files with 253 additions and 96 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ package-lock.json
db.sqlite3
/static/
dist/
.DS_Store

View File

@ -1,9 +1,22 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
## Fixed
* Fix title not being displayed on the Recent sessions page
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition
* session list: clicking the "End now?" link is not much faster
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 1.5.1 / 2023-11-14 21:10+01:00

View File

@ -1,6 +1,6 @@
FROM python:3.12.0-slim-bullseye
ENV VERSION_NUMBER=1.5.1 \
ENV VERSION_NUMBER=1.5.2 \
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \

View File

@ -1,5 +1,5 @@
/*
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com
*/
/*
@ -32,9 +32,11 @@
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/
html {
html,
:host {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
@ -44,12 +46,14 @@ html {
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-family: IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
font-variation-settings: normal;
/* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
}
/*
@ -121,8 +125,10 @@ strong {
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
1. Use the user's configured `mono` font-family by default.
2. Use the user's configured `mono` font-feature-settings by default.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/
code,
@ -131,8 +137,12 @@ samp,
pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-size: 1em;
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em;
/* 4 */
}
/*
@ -567,10 +577,26 @@ select {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
}
@media (forced-colors: active) {
[type='checkbox']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='radio']:checked {
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
}
@media (forced-colors: active) {
[type='radio']:checked {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
border-color: transparent;
background-color: currentColor;
@ -585,6 +611,14 @@ select {
background-repeat: no-repeat;
}
@media (forced-colors: active) {
[type='checkbox']:indeterminate {
-webkit-appearance: auto;
-moz-appearance: auto;
appearance: auto;
}
}
[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
border-color: transparent;
background-color: currentColor;
@ -856,6 +890,10 @@ select {
display: none;
}
.h-3 {
height: 0.75rem;
}
.h-4 {
height: 1rem;
}
@ -938,6 +976,12 @@ select {
gap: 0.5rem;
}
.space-x-1 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.25rem * var(--tw-space-x-reverse));
margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse)));
}
.self-center {
align-self: center;
}
@ -956,6 +1000,10 @@ select {
border-radius: 0.25rem;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-lg {
border-radius: 0.5rem;
}
@ -1086,6 +1134,11 @@ select {
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.text-slate-300 {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity));

View File

@ -4,21 +4,21 @@
{{ title }}
{% endblock title %}
{% block content %}
{% if dataset.count >= 1 %}
{% if dataset_count >= 1 %}
{% url 'list_sessions_start_session_from_session' last.id as start_session_url %}
<div class="mx-auto text-center my-4">
<a id="last-session-start"
href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}"
href="{{ start_session_url }}"
hx-get="{{ start_session_url }}"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}">
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}
{% if dataset.count != 0 %}
{% if dataset_count != 0 %}
<table class="responsive-table">
<thead>
<tr>
@ -42,9 +42,10 @@
{{ session.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if session.unfinished %}
<a href="{% url 'update_session' session.id %}"
hx-get="{% url 'update_session' session.id %}"
{% if not session.timestamp_end %}
{% url 'list_sessions_end_session' session.id as end_session_url %}
<a href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest tr"
hx-swap="outerHTML"
hx-indicator="#indicator"

View File

@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block content %}
<table class="table table-sm table-zebra">
<thead>
<tr class="text-left">
<th></th>
<th>Name</th>
<th>Year</th>
<th>Wikidata ID</th>
<th>Created At</th>
<th>Manage</th>
</tr>
</thead>
<tbody>
{% for game in games %}
<tr>
<th>{{ game.pk }}</th>
<td>{{ game.name }}</td>
<td>{{ game.year_released }}</td>
<td>{{ game.wikidata }}</td>
<td>{{ game.created_at }}</td>
<td>
<div class="join">
<button class="btn btn-primary btn-sm join-item">
Edit
</button>
<button class="btn btn-warning btn-sm join-item">
Delete
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}

View File

@ -36,7 +36,7 @@
</li>
<ul>
{% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center">
<li class="sm:pl-6 flex items-center {% if purchase.date_refunded %}text-red-600{% endif %}">
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %}
@ -57,21 +57,56 @@
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500">({{ sessions.count }})</span>
{% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
<span class="dark:text-slate-500" id="session-count">({{ session_count }})</span>
{% url 'view_game_start_session_from_session' latest_session_id as add_session_link %}
<a
class="truncate max-w-xs py-1 px-2 text-xs 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 font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
title="Start new session"
href="{{ add_session_link }}"
hx-get="{{ add_session_link }}"
hx-vals="js:{session_count:getSessionCount()}"
hx-target="#session-list"
hx-swap="afterbegin"
>New</a>
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1>
<ul>
<ul id="session-list">
{% for session in sessions %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% partialdef session-info inline=True %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center space-x-1">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if not session.timestamp_end %}
{% url 'view_game_end_session' session.id as end_session_url %}
<a
class="flex bg-green-600 rounded-full px-2 w-7 h-4 text-white justify-center items-center"
href="{{ end_session_url }}"
hx-get="{{ end_session_url }}"
hx-target="closest li"
hx-swap="outerHTML"
hx-vals="js:{session_count:getSessionCount()}"
hx-indicator="#indicator"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="#ffffff" class="h-3" x="0px" y="0px" viewBox="0 0 24 24">
<path d="M 12 2 C 6.486 2 2 6.486 2 12 C 2 17.514 6.486 22 12 22 C 17.514 22 22 17.514 22 12 C 22 10.874 21.803984 9.7942031 21.458984 8.7832031 L 19.839844 10.402344 C 19.944844 10.918344 20 11.453 20 12 C 20 16.411 16.411 20 12 20 C 7.589 20 4 16.411 4 12 C 4 7.589 7.589 4 12 4 C 13.633 4 15.151922 4.4938906 16.419922 5.3378906 L 17.851562 3.90625 C 16.203562 2.71225 14.185 2 12 2 z M 21.292969 3.2929688 L 11 13.585938 L 7.7070312 10.292969 L 6.2929688 11.707031 L 11 16.414062 L 22.707031 4.7070312 L 21.292969 3.2929688 z"></path>
</svg>
</a>
{% endif %}
</li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
<div class="hidden" hx-swap-oob="innerHTML:#session-count">
({{ session_count }})
</div>
{% endpartialdef %}
{% endfor %}
</ul>
</div>
<script>
function getSessionCount() {
return document.getElementById('session-count').textContent.match("[0-9]+");
}
</script>
{% endblock content %}

View File

@ -19,19 +19,28 @@ urlpatterns = [
name="add_session_for_purchase",
),
path(
"update-session/by-session/<int:session_id>",
views.update_session,
name="update_session",
"session/clone/from-game/<int:session_id>",
views.new_session_from_existing_session,
{"template": "view_game.html#session-info"},
name="view_game_start_session_from_session",
),
path(
"start-session-same-as-last/<int:last_session_id>",
views.start_session_same_as_last,
name="start_session_same_as_last",
"session/clone/from-list/<int:session_id>",
views.new_session_from_existing_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_start_session_from_session",
),
path(
"start-session/<int:game_id>",
views.start_game_session,
name="start_game_session",
"session/end/from-game/<int:session_id>",
views.end_session,
{"template": "view_game.html#session-info"},
name="view_game_end_session",
),
path(
"session/end/from-list/<int:session_id>",
views.end_session,
{"template": "list_sessions.html#session-row"},
name="list_sessions_end_session",
),
# path(
# "delete_session/by-id/<int:session_id>",

View File

@ -1,9 +1,18 @@
from datetime import datetime, timedelta
from datetime import datetime
from typing import Any, Callable
import re
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Avg, Count, ExpressionWrapper, F, Prefetch, Q, Sum, fields
from django.db.models.functions import Extract, TruncDate
from django.db.models import (
Avg,
Count,
ExpressionWrapper,
F,
Prefetch,
Q,
Sum,
fields,
)
from django.db.models.functions import TruncDate
from django.http import (
HttpRequest,
HttpResponse,
@ -74,16 +83,6 @@ def add_session(request, purchase_id=None):
return render(request, "add_session.html", context)
def update_session(request, session_id=None):
session = get_object_or_404(Session, id=session_id)
session.finish_now()
session.save()
if request.htmx:
context = {"session": session}
return render(request, "list_sessions.html#session-row", context)
return redirect("list_sessions")
def use_custom_redirect(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
@ -162,11 +161,14 @@ def view_game(request, game_id=None):
.order_by("year_released")
)
sessions = Session.objects.filter(purchase__edition__game=game)
sessions = Session.objects.prefetch_related("device").filter(
purchase__edition__game=game
)
session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
latest_session = sessions.latest()
playrange_end = latest_session.timestamp_start.strftime("%b %Y")
playrange = (
playrange_start
@ -187,6 +189,7 @@ def view_game(request, game_id=None):
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
"latest_session_id": latest_session.pk,
}
request.session["return_path"] = request.path
@ -230,30 +233,40 @@ def related_purchase_by_edition(request):
return render(request, "partials/related_purchase_field.html", {"form": form})
def clone_session_by_id(session_id: int) -> Session:
session = get_object_or_404(Session, id=session_id)
clone = session
clone.pk = None
clone.timestamp_start = timezone.now()
clone.timestamp_end = None
clone.note = ""
clone.save()
return clone
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest()
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
def new_session_from_existing_session(request, session_id: int, template: str = ""):
session = clone_session_by_id(session_id)
if request.htmx:
context = {
"session": session,
"session_count": int(request.GET.get("session_count", 0)) + 1,
}
)
session.save()
return render(request, template, context)
return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
@use_custom_redirect
def end_session(request, session_id: int, template: str = ""):
session = get_object_or_404(Session, id=session_id)
session.timestamp_end = timezone.now()
session.save()
if request.htmx:
context = {
"session": session,
"session_count": request.GET.get("session_count", 0),
}
return render(request, template, context)
return redirect("list_sessions")
@ -275,45 +288,41 @@ def list_sessions(
context = {}
context["title"] = "Sessions"
all_sessions = Session.objects.prefetch_related(
"purchase", "purchase__edition", "purchase__edition__game"
).order_by("-timestamp_start")
if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id)
dataset = all_sessions.filter(purchase=purchase_id)
context["purchase"] = Purchase.objects.get(id=purchase_id)
elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id)
dataset = all_sessions.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
dataset = all_sessions.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id)
dataset = all_sessions.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
dataset = all_sessions.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
dataset = all_sessions.filter(timestamp_start__gte=first_day_of_year).order_by(
"-timestamp_start"
)
context["title"] = "This year"
else:
# by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
dataset = all_sessions
for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0
):
session.timestamp_end = timezone.now()
session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
context = {
**context,
"dataset": dataset,
"dataset_count": dataset.count(),
"last": Session.objects.prefetch_related("purchase__platform").latest(),
}
return render(request, "list_sessions.html", context)

View File

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