23 Commits

Author SHA1 Message Date
4552cf7616 Version 1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
e67aa3fda1 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
8423fd02b4 Extend stats range to 2018
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
2bd07e5f2d Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
f13ed8a078 Reorder imports in views.py
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
391fcc79a8 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
a5b2854bf6 Add a button to start session from game overview
All checks were successful
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
518c0ecd56 Add more time tests for fractional numbers
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
a6cd7a3430 Do not format as float if no precision specified
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
dba8414fd9 Version 1.1.2
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-13 16:33:55 +02:00
0e2113eefd Display durations in a consistent manner
Fixes #61
2023-10-13 16:32:12 +02:00
c4b0347f3b Version 1.1.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-09 20:56:23 +02:00
c6ed21167c Remove debugging cruft from container 2023-10-09 20:56:13 +02:00
4ce15c44fc Add edit buttons to game overview, notes 2023-10-09 20:55:31 +02:00
c814b4c2cb Version 1.1.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-09 00:04:46 +02:00
11b9c602de Improve game overview
- add counts for each section
- add average hours per session
2023-10-09 00:00:45 +02:00
19 changed files with 626 additions and 218 deletions

View File

@ -18,6 +18,7 @@ steps:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
- 1.1.0
depends_on: depends_on:
- "test" - "test"
when: when:

View File

@ -1,4 +1,43 @@
## Unreleased ## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 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
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New ### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8) * Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)

View File

@ -6,17 +6,10 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.0.3 ENV VERSION_NUMBER 1.3.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apt update && \
apt install -y \
bash \
vim \
curl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/

View File

@ -32,13 +32,15 @@ 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
day_seconds = 24 * hour_seconds day_seconds = 24 * hour_seconds
duration = _safe_timedelta(duration) safe_duration = _safe_timedelta(duration)
# we don't need float # we don't need float
seconds_total = int(duration.total_seconds()) seconds_total = int(safe_duration.total_seconds())
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
@ -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

View File

@ -73,11 +73,14 @@ class Platform(models.Model):
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
def total_duration(self): def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate( result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual")) duration=Sum(F("duration_calculated") + F("duration_manual"))
) )
return format_duration(result["duration"]) return result["duration"]
class Session(models.Model): class Session(models.Model):
@ -111,12 +114,12 @@ 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
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration() return Session.objects.all().total_duration_formatted()
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:

View File

@ -755,6 +755,10 @@ select {
position: absolute; position: absolute;
} }
.relative {
position: relative;
}
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
@ -771,24 +775,14 @@ select {
top: 0.75rem; top: 0.75rem;
} }
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.mx-2 { .mx-2 {
margin-left: 0.5rem; margin-left: 0.5rem;
margin-right: 0.5rem; margin-right: 0.5rem;
} }
.my-1 { .mx-auto {
margin-top: 0.25rem; margin-left: auto;
margin-bottom: 0.25rem; margin-right: auto;
} }
.my-2 { .my-2 {
@ -796,34 +790,28 @@ select {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.mb-4 { .my-4 {
margin-top: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.mr-4 { .my-6 {
margin-right: 1rem; margin-top: 1.5rem;
} margin-bottom: 1.5rem;
.mt-4 {
margin-top: 1rem;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mb-2 {
margin-bottom: 0.5rem;
} }
.mb-1 { .mb-1 {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
@ -832,10 +820,22 @@ select {
margin-left: 0.5rem; margin-left: 0.5rem;
} }
.mr-4 {
margin-right: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
.block { .block {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -848,14 +848,18 @@ select {
display: table; display: table;
} }
.list-item {
display: list-item;
}
.hidden { .hidden {
display: none; display: none;
} }
.h-4 {
height: 1rem;
}
.h-5 {
height: 1.25rem;
}
.h-6 { .h-6 {
height: 1.5rem; height: 1.5rem;
} }
@ -864,38 +868,38 @@ select {
min-height: 100vh; min-height: 100vh;
} }
.w-5 {
width: 1.25rem;
}
.w-6 { .w-6 {
width: 1.5rem; width: 1.5rem;
} }
.w-7 {
width: 1.75rem;
}
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-5 {
width: 1.25rem;
}
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
.max-w-xs {
max-width: 20rem;
}
.max-w-lg {
max-width: 32rem;
}
.max-w-3xl {
max-width: 48rem;
}
.max-w-sm { .max-w-sm {
max-width: 24rem; max-width: 24rem;
} }
.max-w-xs {
max-width: 20rem;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@ -906,10 +910,6 @@ select {
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
} }
.list-disc {
list-style-type: disc;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@ -922,10 +922,18 @@ select {
align-items: center; align-items: center;
} }
.justify-center {
justify-content: center;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 {
gap: 0.5rem;
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -948,41 +956,40 @@ 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));
} }
.border-red-300 {
--tw-border-opacity: 1;
border-color: rgb(252 165 165 / var(--tw-border-opacity));
}
.border-red-500 {
--tw-border-opacity: 1;
border-color: rgb(239 68 68 / var(--tw-border-opacity));
}
.border-slate-500 { .border-slate-500 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(100 116 139 / var(--tw-border-opacity)); border-color: rgb(100 116 139 / var(--tw-border-opacity));
} }
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-green-600 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
} }
.bg-violet-600 {
--tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
}
.bg-white { .bg-white {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
} }
.bg-slate-400 {
--tw-bg-opacity: 1;
background-color: rgb(148 163 184 / var(--tw-bg-opacity));
}
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
@ -992,6 +999,11 @@ select {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@ -1010,6 +1022,10 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -1018,12 +1034,9 @@ select {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
.font-serif { .text-3xl {
font-family: IBM Plex Serif, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; font-size: 1.875rem;
} line-height: 2.25rem;
.font-sans {
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";
} }
.text-4xl { .text-4xl {
@ -1031,11 +1044,21 @@ 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;
} }
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -1046,62 +1069,19 @@ select {
line-height: 1rem; line-height: 1rem;
} }
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-9xl {
font-size: 8rem;
line-height: 1;
}
.text-8xl {
font-size: 6rem;
line-height: 1;
}
.text-7xl {
font-size: 4.5rem;
line-height: 1;
}
.text-6xl {
font-size: 3.75rem;
line-height: 1;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
}
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
.font-bold {
font-weight: 700;
}
.capitalize {
text-transform: capitalize;
}
.italic { .italic {
font-style: italic; font-style: italic;
} }
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
@ -1117,23 +1097,10 @@ select {
color: rgb(253 224 71 / var(--tw-text-opacity)); color: rgb(253 224 71 / var(--tw-text-opacity));
} }
.text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
.underline { .underline {
text-decoration-line: underline; text-decoration-line: underline;
} }
.decoration-red-500 {
text-decoration-color: #ef4444;
}
.decoration-slate-400 {
text-decoration-color: #94a3b8;
}
.decoration-slate-500 { .decoration-slate-500 {
text-decoration-color: #64748b; text-decoration-color: #64748b;
} }
@ -1350,11 +1317,21 @@ th label {
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);
} }
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-violet-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
}
.hover\:underline:hover { .hover\:underline:hover {
text-decoration-line: underline; text-decoration-line: underline;
} }
@ -1375,6 +1352,11 @@ 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-violet-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus { .focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px; --tw-ring-offset-width: 2px;
} }
@ -1383,6 +1365,14 @@ th label {
--tw-ring-offset-color: #bfdbfe; --tw-ring-offset-color: #bfdbfe;
} }
.focus\:ring-offset-violet-200:focus {
--tw-ring-offset-color: #ddd6fe;
}
.group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) { :is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
@ -1393,6 +1383,11 @@ th label {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-600) { :is(.dark .dark\:text-slate-600) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); color: rgb(71 85 105 / var(--tw-text-opacity));
@ -1404,10 +1399,6 @@ th label {
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:ml-2 {
margin-left: 0.5rem;
}
.sm\:inline { .sm\:inline {
display: inline; display: inline;
} }
@ -1420,10 +1411,6 @@ th label {
max-width: 28rem; max-width: 28rem;
} }
.sm\:max-w-lg {
max-width: 32rem;
}
.sm\:max-w-xl { .sm\:max-w-xl {
max-width: 36rem; max-width: 36rem;
} }
@ -1437,6 +1424,10 @@ th label {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
.sm\:pl-4 {
padding-left: 1rem;
}
.sm\:decoration-2 { .sm\:decoration-2 {
text-decoration-thickness: 2px; text-decoration-thickness: 2px;
} }
@ -1475,11 +1466,11 @@ th label {
display: table-cell; display: table-cell;
} }
.lg\:max-w-lg {
max-width: 32rem;
}
.lg\:max-w-3xl { .lg\:max-w-3xl {
max-width: 48rem; max-width: 48rem;
} }
.lg\:max-w-lg {
max-width: 32rem;
}
} }

View File

@ -25,19 +25,37 @@
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> 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 class="relative group">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
{% if game_available and platform_available %} <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li> {% if purchase_available %}
{% endif %} <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
{% if edition_available %} {% endif %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
{% endif %} {% if game_available and platform_available %}
{% if purchase_available %} <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> {% endif %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
{% endif %} {% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %} {% endif %}
</ul> </ul>

View File

@ -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>

View 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>

View File

@ -0,0 +1,21 @@
<a href="{{ edit_url }}">
<button
type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-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>

View File

@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %} {% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
{% elif not game_available or not platform_available %} {% elif not game_available or not platform_available %}
There are no games in the database. Start by clicking "New Game" and "New Platform". There are no games in the database. Start by clicking "New Game" and "New Platform".
{% elif not purchase_available %} {% elif not purchase_available %}

View File

@ -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 %}

View File

@ -0,0 +1,98 @@
{% 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">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Hours</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Games</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Purchases</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">{{ total_spent_currency }}/game</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>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ spent_per_game }}</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 game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.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>
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}

View File

@ -6,13 +6,23 @@
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl">{{ game.name }} (#{{ game.pk }})</h1> <h1 class="text-4xl flex items-center">
<h2 class="text-lg my-2 ml-2">{{ total_playtime }} ({{ first_session.timestamp_start | date:"M Y"}} — {{ last_session.timestamp_start | date:"M Y"}}) </h2> {{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</h1>
<h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ first_session.timestamp_start | date:"M Y"}}
{{ last_session.timestamp_start | date:"M Y"}}) </h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions</h1> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2"> <li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
@ -21,19 +31,57 @@
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1">Purchases</h1> <h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul> <ul>
{% for purchase in purchases %} {% for purchase in purchases %}
<li class="sm:pl-2">{{ purchase.platform }} ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})</li> <li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1">Sessions</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">{{ session.timestamp_start | date:"d/m/Y" }} ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})</li> <li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }}
({{ 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>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -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,10 @@ urlpatterns = [
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_ownership_type", name="list_sessions_by_ownership_type",
), ),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
] ]

View File

@ -1,9 +1,12 @@
from common.time import format_duration, now as now_with_tz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from common.time import now as now_with_tz
from django.conf import settings from django.conf import settings
from django.db.models import Sum, F
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo
from .forms import ( from .forms import (
GameForm, GameForm,
@ -26,6 +29,10 @@ def model_counts(request):
} }
def stats_dropdown_year_range(request):
return {"stats_dropdown_year_range": range(2018, 2024)}
def add_session(request): def add_session(request):
context = {} context = {}
initial = {} initial = {}
@ -54,6 +61,25 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def use_custom_redirect(
func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
"""
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect
def edit_session(request, session_id=None): def edit_session(request, session_id=None):
context = {} context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
@ -66,6 +92,7 @@ def edit_session(request, session_id=None):
return render(request, "add_session.html", context) return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None): def edit_purchase(request, purchase_id=None):
context = {} context = {}
purchase = Purchase.objects.get(id=purchase_id) purchase = Purchase.objects.get(id=purchase_id)
@ -78,6 +105,7 @@ def edit_purchase(request, purchase_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_game(request, game_id=None): def edit_game(request, game_id=None):
context = {} context = {}
purchase = Game.objects.get(id=game_id) purchase = Game.objects.get(id=game_id)
@ -100,15 +128,23 @@ 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_playtime"] = context["sessions"].total_duration() context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
context["session_average"] = round(
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped # here first and last is flipped
# because sessions are ordered from newest to oldest # because sessions are ordered from newest to oldest
# so the most recent are on top # so the most recent are on top
context["last_session"] = context["sessions"].first() context["last_session"] = context["sessions"].first()
context["first_session"] = context["sessions"].last() context["first_session"] = context["sessions"].last()
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None): def edit_platform(request, platform_id=None):
context = {} context = {}
purchase = Platform.objects.get(id=platform_id) purchase = Platform.objects.get(id=platform_id)
@ -121,6 +157,7 @@ def edit_platform(request, platform_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None): def edit_edition(request, edition_id=None):
context = {} context = {}
edition = Edition.objects.get(id=edition_id) edition = Edition.objects.get(id=edition_id)
@ -133,7 +170,25 @@ 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): @use_custom_redirect
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(
{ {
@ -197,7 +252,7 @@ def list_sessions(
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration() context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last() context["last"] = Session.objects.all().order_by("timestamp_start").last()
@ -205,6 +260,75 @@ def list_sessions(
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
first_day_of_year = datetime(year, 1, 1)
last_day_of_year = datetime(year + 1, 1, 1)
year_sessions = Session.objects.filter(timestamp_start__year=year)
year_played_purchases = Purchase.objects.filter(
session__in=year_sessions
).distinct()
selected_currency = "CZK"
all_purchased_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.filter(date_refunded__exact=None)
.order_by("date_purchased")
)
this_year_spendings = all_purchased_this_year.aggregate(total_spent=Sum(F("price")))
total_spent = this_year_spendings["total_spent"]
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
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_played_purchases.count(),
"total_2023_games": year_played_purchases.filter(
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": all_purchased_this_year,
"spent_per_game": int(total_spent / all_purchased_this_year.count()),
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request): def add_purchase(request):
context = {} context = {}
now = datetime.now() now = datetime.now()

View File

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

View File

@ -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")

View File

@ -68,6 +68,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
}, },
}, },