Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
cd6ea6e903 | |||
22018fd2ba | |||
a5b2854bf6 | |||
518c0ecd56 | |||
a6cd7a3430 | |||
dba8414fd9 | |||
0e2113eefd |
16
CHANGELOG.md
16
CHANGELOG.md
@ -1,3 +1,19 @@
|
|||||||
|
## 1.2.0 / 2023-11-01 20:18+01:00
|
||||||
|
|
||||||
|
### New
|
||||||
|
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Add a button to start session from game overview
|
||||||
|
|
||||||
|
## 1.1.2 / 2023-10-13 16:30+02:00
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
* Durations are formatted in a consisent manner across all pages
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
* Game Overview: display duration when >1 hour instead of displaying 0
|
||||||
|
|
||||||
## 1.1.1 / 2023-10-09 20:52+02:00
|
## 1.1.1 / 2023-10-09 20:52+02:00
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
@ -6,7 +6,7 @@ RUN npm install && \
|
|||||||
|
|
||||||
FROM python:3.10.9-slim-bullseye
|
FROM python:3.10.9-slim-bullseye
|
||||||
|
|
||||||
ENV VERSION_NUMBER 1.1.1
|
ENV VERSION_NUMBER 1.2.0
|
||||||
ENV PROD 1
|
ENV PROD 1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
@ -32,6 +32,8 @@ def format_duration(
|
|||||||
from the formatting string. For example:
|
from the formatting string. For example:
|
||||||
- 61 seconds as "%s" = 61 seconds
|
- 61 seconds as "%s" = 61 seconds
|
||||||
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
|
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
|
||||||
|
Format specifiers can include width and precision options:
|
||||||
|
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
|
||||||
"""
|
"""
|
||||||
minute_seconds = 60
|
minute_seconds = 60
|
||||||
hour_seconds = 60 * minute_seconds
|
hour_seconds = 60 * minute_seconds
|
||||||
@ -46,18 +48,32 @@ def format_duration(
|
|||||||
remainder = seconds = seconds_total
|
remainder = seconds = seconds_total
|
||||||
if "%d" in format_string:
|
if "%d" in format_string:
|
||||||
days, remainder = divmod(seconds_total, day_seconds)
|
days, remainder = divmod(seconds_total, day_seconds)
|
||||||
if "%H" in format_string:
|
if re.search(r"%\d*\.?\d*H", format_string):
|
||||||
hours, remainder = divmod(remainder, hour_seconds)
|
hours_float, remainder = divmod(remainder, hour_seconds)
|
||||||
if "%m" in format_string:
|
hours = float(hours_float) + remainder / hour_seconds
|
||||||
|
if re.search(r"%\d*\.?\d*m", format_string):
|
||||||
minutes, seconds = divmod(remainder, minute_seconds)
|
minutes, seconds = divmod(remainder, minute_seconds)
|
||||||
literals = {
|
literals = {
|
||||||
"%d": str(days),
|
"d": str(days),
|
||||||
"%H": str(hours),
|
"H": str(hours),
|
||||||
"%m": str(minutes),
|
"m": str(minutes),
|
||||||
"%s": str(seconds),
|
"s": str(seconds),
|
||||||
"%r": str(seconds_total),
|
"r": str(seconds_total),
|
||||||
}
|
}
|
||||||
formatted_string = format_string
|
formatted_string = format_string
|
||||||
for pattern, replacement in literals.items():
|
for pattern, replacement in literals.items():
|
||||||
formatted_string = re.sub(pattern, replacement, formatted_string)
|
# Match format specifiers with optional width and precision
|
||||||
|
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
|
||||||
|
if match:
|
||||||
|
format_spec = match.group(1)
|
||||||
|
if "." in format_spec:
|
||||||
|
# Format the number as float if precision is specified
|
||||||
|
replacement = f"{float(replacement):{format_spec}f}"
|
||||||
|
else:
|
||||||
|
# Format the number as integer if no precision is specified
|
||||||
|
replacement = f"{int(float(replacement)):>{format_spec}}"
|
||||||
|
# Replace the format specifier with the formatted number
|
||||||
|
formatted_string = re.sub(
|
||||||
|
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
|
||||||
|
)
|
||||||
return formatted_string
|
return formatted_string
|
||||||
|
@ -114,7 +114,7 @@ class Session(models.Model):
|
|||||||
return timedelta(seconds=(manual + calculated).total_seconds())
|
return timedelta(seconds=(manual + calculated).total_seconds())
|
||||||
|
|
||||||
def duration_formatted(self) -> str:
|
def duration_formatted(self) -> str:
|
||||||
result = format_duration(self.duration_seconds(), "%H:%m")
|
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -791,6 +791,16 @@ select {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.my-5 {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.my-6 {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mb-1 {
|
.mb-1 {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
@ -799,6 +809,10 @@ select {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ml-1 {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ml-2 {
|
.ml-2 {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
@ -811,14 +825,6 @@ select {
|
|||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ml-8 {
|
|
||||||
margin-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ml-1 {
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@ -839,10 +845,6 @@ select {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.h-6 {
|
|
||||||
height: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.h-4 {
|
.h-4 {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
}
|
}
|
||||||
@ -851,26 +853,30 @@ select {
|
|||||||
height: 1.25rem;
|
height: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-6 {
|
||||||
|
height: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.min-h-screen {
|
.min-h-screen {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-6 {
|
|
||||||
width: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-full {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-5 {
|
.w-5 {
|
||||||
width: 1.25rem;
|
width: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-6 {
|
||||||
|
width: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-7 {
|
.w-7 {
|
||||||
width: 1.75rem;
|
width: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.max-w-screen-lg {
|
.max-w-screen-lg {
|
||||||
max-width: 1024px;
|
max-width: 1024px;
|
||||||
}
|
}
|
||||||
@ -913,6 +919,10 @@ select {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gap-2 {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.self-center {
|
.self-center {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@ -935,6 +945,10 @@ select {
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-sm {
|
||||||
|
border-radius: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
.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));
|
||||||
@ -950,21 +964,16 @@ select {
|
|||||||
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-white {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-blue-600 {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-violet-600 {
|
.bg-violet-600 {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
|
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
--tw-bg-opacity: 1;
|
||||||
|
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.p-4 {
|
.p-4 {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
@ -992,10 +1001,6 @@ select {
|
|||||||
padding-right: 1rem;
|
padding-right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pl-8 {
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-center {
|
.text-center {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@ -1014,6 +1019,11 @@ select {
|
|||||||
line-height: 2.5rem;
|
line-height: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-5xl {
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.text-base {
|
.text-base {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
@ -1282,11 +1292,6 @@ th label {
|
|||||||
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
background-color: rgb(21 128 61 / var(--tw-bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.hover\:bg-blue-700:hover {
|
|
||||||
--tw-bg-opacity: 1;
|
|
||||||
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover\:bg-violet-700:hover {
|
.hover\:bg-violet-700:hover {
|
||||||
--tw-bg-opacity: 1;
|
--tw-bg-opacity: 1;
|
||||||
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
|
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
|
||||||
@ -1312,11 +1317,6 @@ th label {
|
|||||||
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
|
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
.focus\:ring-blue-500:focus {
|
|
||||||
--tw-ring-opacity: 1;
|
|
||||||
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
|
|
||||||
}
|
|
||||||
|
|
||||||
.focus\:ring-violet-500:focus {
|
.focus\:ring-violet-500:focus {
|
||||||
--tw-ring-opacity: 1;
|
--tw-ring-opacity: 1;
|
||||||
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
|
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
<button
|
{% comment %}
|
||||||
type="button"
|
title
|
||||||
|
text
|
||||||
|
{% endcomment %}
|
||||||
|
<a
|
||||||
|
href="{{ link }}"
|
||||||
title="{{ title }}"
|
title="{{ title }}"
|
||||||
autofocus
|
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"
|
||||||
class="truncate max-w-xs sm:max-w-md lg:max-w-lg 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
|
{% comment %} <svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@ -16,7 +19,8 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="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"
|
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>
|
</svg>
|
||||||
|
{% endcomment %}
|
||||||
{{ text }}
|
{{ text }}
|
||||||
</button>
|
</a>
|
||||||
|
26
games/templates/components/button_start.html
Normal file
26
games/templates/components/button_start.html
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{% comment %}
|
||||||
|
title
|
||||||
|
text
|
||||||
|
{% endcomment %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="{{ title }}"
|
||||||
|
autofocus
|
||||||
|
class="truncate max-w-xs sm:max-w-md lg:max-w-lg 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>
|
||||||
|
{{ text }}
|
||||||
|
</button>
|
@ -10,8 +10,8 @@
|
|||||||
<div class="mx-auto text-center my-4">
|
<div class="mx-auto text-center my-4">
|
||||||
<a
|
<a
|
||||||
id="last-session-start"
|
id="last-session-start"
|
||||||
href="{% url 'start_session' last.id %}"
|
href="{% url 'start_session_same_as_last' last.id %}"
|
||||||
hx-get="{% url 'start_session' last.id %}"
|
hx-get="{% url 'start_session_same_as_last' last.id %}"
|
||||||
hx-indicator="#indicator"
|
hx-indicator="#indicator"
|
||||||
hx-swap="afterbegin"
|
hx-swap="afterbegin"
|
||||||
hx-target=".responsive-table tbody"
|
hx-target=".responsive-table tbody"
|
||||||
@ -19,7 +19,7 @@
|
|||||||
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
|
||||||
class="{% if last.timestamp_end == null %}invisible{% endif %}"
|
class="{% if last.timestamp_end == null %}invisible{% endif %}"
|
||||||
>
|
>
|
||||||
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
|
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
64
games/templates/stats.html
Normal file
64
games/templates/stats.html
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
|
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
|
||||||
|
<h1 class="text-5xl text-center my-6">Stats for {{ year }}</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total hours</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total games</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Total 2023 games</th>
|
||||||
|
</tr>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for purchase in top_10_by_playtime %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
|
||||||
|
<a href="{% url 'view_game' purchase.edition.game.id %}">{{ purchase.edition.name }}
|
||||||
|
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.formatted_playtime }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
|
||||||
|
<table class="responsive-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
|
||||||
|
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in total_playtime_per_platform %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }} </td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
@ -47,7 +47,14 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<h1 class="text-3xl mt-4 mb-1">Sessions <span class="dark:text-slate-500">({{ sessions.count }})</span></h1>
|
<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 %}
|
||||||
|
</h1>
|
||||||
<ul>
|
<ul>
|
||||||
{% for session in sessions %}
|
{% for session in sessions %}
|
||||||
<li class="sm:pl-2 flex items-center">
|
<li class="sm:pl-2 flex items-center">
|
||||||
|
@ -19,9 +19,14 @@ urlpatterns = [
|
|||||||
name="update_session",
|
name="update_session",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"start-session/<int:last_session_id>",
|
"start-session-same-as-last/<int:last_session_id>",
|
||||||
views.start_session,
|
views.start_session_same_as_last,
|
||||||
name="start_session",
|
name="start_session_same_as_last",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"start-session/<int:game_id>",
|
||||||
|
views.start_game_session,
|
||||||
|
name="start_game_session",
|
||||||
),
|
),
|
||||||
# path(
|
# path(
|
||||||
# "delete_session/by-id/<int:session_id>",
|
# "delete_session/by-id/<int:session_id>",
|
||||||
@ -68,4 +73,9 @@ urlpatterns = [
|
|||||||
{"filter": "ownership_type"},
|
{"filter": "ownership_type"},
|
||||||
name="list_sessions_by_ownership_type",
|
name="list_sessions_by_ownership_type",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"stats/<int:year>",
|
||||||
|
views.stats,
|
||||||
|
name="stats_by_year",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
@ -101,8 +101,8 @@ def view_game(request, game_id=None):
|
|||||||
context["sessions"] = Session.objects.filter(
|
context["sessions"] = Session.objects.filter(
|
||||||
purchase__edition__game_id=game_id
|
purchase__edition__game_id=game_id
|
||||||
).order_by("-timestamp_start")
|
).order_by("-timestamp_start")
|
||||||
context["total_hours"] = int(
|
context["total_hours"] = float(
|
||||||
format_duration(context["sessions"].total_duration_unformatted(), "%H")
|
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
|
||||||
)
|
)
|
||||||
context["session_average"] = round(
|
context["session_average"] = round(
|
||||||
(context["total_hours"]) / int(context["sessions"].count()), 1
|
(context["total_hours"]) / int(context["sessions"].count()), 1
|
||||||
@ -140,7 +140,24 @@ def edit_edition(request, edition_id=None):
|
|||||||
return render(request, "add.html", context)
|
return render(request, "add.html", context)
|
||||||
|
|
||||||
|
|
||||||
def start_session(request, last_session_id: int):
|
def start_game_session(request, game_id: int):
|
||||||
|
last_session = (
|
||||||
|
Session.objects.filter(purchase__edition__game_id=game_id)
|
||||||
|
.order_by("-timestamp_start")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
session = SessionForm(
|
||||||
|
{
|
||||||
|
"purchase": last_session.purchase.id,
|
||||||
|
"timestamp_start": now_with_tz(),
|
||||||
|
"device": last_session.device,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
session.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
|
||||||
|
|
||||||
|
def start_session_same_as_last(request, last_session_id: int):
|
||||||
last_session = Session.objects.get(id=last_session_id)
|
last_session = Session.objects.get(id=last_session_id)
|
||||||
session = SessionForm(
|
session = SessionForm(
|
||||||
{
|
{
|
||||||
@ -212,6 +229,48 @@ def list_sessions(
|
|||||||
return render(request, "list_sessions.html", context)
|
return render(request, "list_sessions.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
def stats(request, year: int):
|
||||||
|
first_day_of_year = datetime(year, 1, 1)
|
||||||
|
year_sessions = Session.objects.filter(timestamp_start__gte=first_day_of_year)
|
||||||
|
year_purchases = Purchase.objects.filter(session__in=year_sessions).distinct()
|
||||||
|
year_purchases_with_playtime = year_purchases.annotate(
|
||||||
|
total_playtime=Sum(
|
||||||
|
F("session__duration_calculated") + F("session__duration_manual")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
top_10_by_playtime = year_purchases_with_playtime.order_by("-total_playtime")[:10]
|
||||||
|
for purchase in top_10_by_playtime:
|
||||||
|
purchase.formatted_playtime = format_duration(purchase.total_playtime, "%2.0H")
|
||||||
|
|
||||||
|
total_playtime_per_platform = (
|
||||||
|
year_sessions.values("purchase__platform__name") # Group by platform name
|
||||||
|
.annotate(
|
||||||
|
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
|
||||||
|
) # Sum the duration_calculated for each group
|
||||||
|
.annotate(platform_name=F("purchase__platform__name")) # Rename the field
|
||||||
|
.values(
|
||||||
|
"platform_name", "total_playtime"
|
||||||
|
) # Select the renamed field and total_playtime
|
||||||
|
.order_by("-total_playtime") # Optional: Order by the renamed platform name
|
||||||
|
)
|
||||||
|
for item in total_playtime_per_platform:
|
||||||
|
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"total_hours": format_duration(
|
||||||
|
year_sessions.total_duration_unformatted(), "%2.0H"
|
||||||
|
),
|
||||||
|
"total_games": year_purchases.count(),
|
||||||
|
"total_2023_games": year_purchases.filter(edition__year_released=year).count(),
|
||||||
|
"top_10_by_playtime_formatted": top_10_by_playtime,
|
||||||
|
"top_10_by_playtime": top_10_by_playtime,
|
||||||
|
"year": year,
|
||||||
|
"total_playtime_per_platform": total_playtime_per_platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
return render(request, "stats.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_purchase(request):
|
def add_purchase(request):
|
||||||
context = {}
|
context = {}
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "timetracker"
|
name = "timetracker"
|
||||||
version = "1.1.1"
|
version = "1.2.0"
|
||||||
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"
|
||||||
|
@ -6,7 +6,6 @@ from common.time import format_duration
|
|||||||
|
|
||||||
class FormatDurationTest(unittest.TestCase):
|
class FormatDurationTest(unittest.TestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|
||||||
return super().setUp()
|
return super().setUp()
|
||||||
|
|
||||||
def test_only_days(self):
|
def test_only_days(self):
|
||||||
@ -19,6 +18,21 @@ class FormatDurationTest(unittest.TestCase):
|
|||||||
result = format_duration(delta, "%H hours")
|
result = format_duration(delta, "%H hours")
|
||||||
self.assertEqual(result, "1 hours")
|
self.assertEqual(result, "1 hours")
|
||||||
|
|
||||||
|
def test_only_hours_fractional(self):
|
||||||
|
delta = timedelta(hours=1)
|
||||||
|
result = format_duration(delta, "%.1H hours")
|
||||||
|
self.assertEqual(result, "1.0 hours")
|
||||||
|
|
||||||
|
def test_less_than_hour_with_precision(self):
|
||||||
|
delta = timedelta(hours=0.5)
|
||||||
|
result = format_duration(delta, "%.1H hours")
|
||||||
|
self.assertEqual(result, "0.5 hours")
|
||||||
|
|
||||||
|
def test_less_than_hour_without_precision(self):
|
||||||
|
delta = timedelta(hours=0.5)
|
||||||
|
result = format_duration(delta, "%H hours")
|
||||||
|
self.assertEqual(result, "0 hours")
|
||||||
|
|
||||||
def test_overflow_hours(self):
|
def test_overflow_hours(self):
|
||||||
delta = timedelta(hours=25)
|
delta = timedelta(hours=25)
|
||||||
result = format_duration(delta, "%H hours")
|
result = format_duration(delta, "%H hours")
|
||||||
|
Reference in New Issue
Block a user