15 Commits

Author SHA1 Message Date
345315db38 Start converting to react-router
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 17:50:15 +01:00
d9076e4367 Add apiService 2023-01-25 17:49:02 +01:00
65e556656f Fix Vite proxy config 2023-01-25 17:48:51 +01:00
22fd1c356a Add tailwind typography and forms 2023-01-25 17:48:33 +01:00
9080425e1e Add django-cors-headers 2023-01-25 17:48:17 +01:00
c64c68791f Add eslint and prettier 2023-01-25 13:31:05 +01:00
f6edc8688a Remove cruft 2023-01-25 13:19:50 +01:00
1c73bba301 Remove top-level package.json 2023-01-25 13:07:45 +01:00
da5783c08f Add Nav component 2023-01-25 12:56:32 +01:00
04e6b01c2a Integrate TailwindCSS 2023-01-25 12:47:39 +01:00
3a14ea98a6 Change API url 2023-01-25 12:17:56 +01:00
ba27511b97 Add django-rest-framework 2023-01-25 12:05:37 +01:00
ae95015f55 CI: autotag
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-25 11:49:22 +01:00
c217fd30ef Add vite proxy config 2023-01-25 11:48:17 +01:00
ec16d40361 Add vite react
Some checks failed
continuous-integration/drone/push Build is failing
2023-01-25 11:44:04 +01:00
92 changed files with 1279 additions and 3897 deletions

View File

@ -5,13 +5,4 @@
.venv .venv
.vscode .vscode
node_modules node_modules
static src/timetracker/static/*
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,28 +5,23 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.12 image: python:3.10
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run python manage.py migrate
- poetry run pytest - poetry run pytest
- name: build container (prod)
- name: build-prod
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
- 1.1.0
depends_on:
- "test"
when: when:
branch: branch:
- main - main
- name: build-non-prod - name: build container (non-prod)
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
@ -35,20 +30,9 @@ steps:
branch: branch:
exclude: exclude:
- main - main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger: trigger:
event: event:
- push - push
- cron - cron

View File

@ -1,17 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2

View File

@ -1,25 +0,0 @@
name: Django CI/CD
on:
push:
branches: [ main ]
paths-ignore: [ 'README.md' ]
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

2
.gitignore vendored
View File

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

View File

@ -1,10 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)

View File

@ -4,8 +4,5 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic", "python.analysis.typeCheckingMode": "basic"
"[python]": { }
"editor.defaultFormatter": "ms-python.black-formatter"
},
}

View File

@ -1,129 +1,6 @@
## Unreleased ## Unrelease
## New * Add React
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 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
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* Add helper buttons next to datime fields
* Change recent session view to current year instead of last 30 days
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
* Add copy button on Add session page to copy times between fields
* Use the same form when editing a session as when adding a session
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Focus important fields on forms
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
* Change fonts to IBM Plex
* Only use local WOFF2 font files
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
* Allow filtering by game, edition, purchase from the session list
* Add icons for the above
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* 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 ## 1.0.0 / 2023-01-20 19:54+01:00

View File

@ -1,45 +1,34 @@
FROM python:3.12.0-slim-bullseye FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
ENV VERSION_NUMBER=1.5.1 \ FROM python:3.10.9-slim-bullseye
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
RUN apt-get update && apt-get upgrade -y \ ENV VERSION_NUMBER 1.0.0
&& apt-get install --no-install-recommends -y \ ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN apt update && \
apt install -y \
bash \ bash \
curl \ vim \
&& curl -sSL 'https://install.python-poetry.org' | python - \ curl && \
&& poetry --version \ rm -rf /var/lib/apt/lists/*
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker \ RUN useradd -m --uid 1000 timetracker
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install --without dev
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

91
common/plots.py Normal file
View File

@ -0,0 +1,91 @@
import base64
from datetime import datetime
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from games.models import Session
def key_value_to_value_value(data):
return {data["date"]: data["hours"]}
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated"),
output_field=IntegerField(),
)
)
.values("date", "hours")
)
keys = []
values = []
running_total = int(0)
for item in result:
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
buffer = BytesIO()
plt.savefig(buffer, format="svg", transparent=True)
buffer.seek(0)
image_png = buffer.getvalue()
graph = base64.b64encode(image_png)
graph = graph.decode("utf-8")
buffer.close()
return graph
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
first = x[0]
last = x[-1]
difference = last - first
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
else:
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph()
return chart

View File

@ -1,5 +1,12 @@
import re import re
from datetime import timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):
@ -25,48 +32,32 @@ 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
safe_duration = _safe_timedelta(duration) duration = _safe_timedelta(duration)
# we don't need float # we don't need float
seconds_total = int(safe_duration.total_seconds()) seconds_total = int(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
days = hours = hours_float = minutes = seconds = 0 days = hours = minutes = seconds = 0
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 re.search(r"%\d*\.?\d*H", format_string): if "%H" in format_string:
hours_float, remainder = divmod(remainder, hour_seconds) hours, remainder = divmod(remainder, hour_seconds)
hours = float(hours_float) + remainder / hour_seconds if "%m" in format_string:
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) if "m" not in format_string else str(hours_float), "%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():
# Match format specifiers with optional width and precision formatted_string = re.sub(pattern, replacement, formatted_string)
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

@ -1,9 +0,0 @@
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0

View File

@ -10,14 +10,13 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: volumes:
- "static-files:/var/www/django/static" - "static-files:/home/timetracker/app/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: caddy image: caddy
volumes: volumes:
- "static-files:/usr/share/caddy:ro" - "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile" - "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
@ -27,4 +26,3 @@ services:
volumes: volumes:
static-files: static-files:

View File

@ -7,13 +7,5 @@ poetry run python manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
}
trap _term SIGTERM
echo "Starting app" echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$! poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
wait "$gunicorn_pid"

View File

@ -10,9 +10,13 @@
}, },
"dependencies": { "dependencies": {
"autoprefixer": "^10.4.13", "autoprefixer": "^10.4.13",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-prettier": "^4.2.1",
"postcss": "^8.4.21", "postcss": "^8.4.21",
"prettier": "^2.8.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.7.0",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.2.4"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,42 +1,20 @@
import { useState } from 'react' import "./App.css";
import './App.css' import Nav from "./components/Nav";
import { Outlet, useLoaderData } from "react-router-dom";
function App() { function App() {
return ( return (
<> <>
<div className="dark:bg-gray-800 min-h-screen"> <div className="dark:bg-gray-800 min-h-screen">
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <Nav />
<div className="container flex flex-wrap items-center justify-between mx-auto"> <Outlet />
<a href="{% url 'index' %}" className="flex items-center"> </div>
<span className="text-4xl"></span> {/* {% load version %} */}
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> {/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
</a> </>
<div className="w-full md:block md:w-auto"> );
<ul
className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a className="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 className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
{/* {% block content %}No content here.{% endblock content %} */}
</div>
{/* {% load version %} */}
{/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
</>
)
} }
export default App; export default App;

View File

@ -19,4 +19,4 @@ textarea {
#button-container button { #button-container button {
@apply mx-1; @apply mx-1;
} }

View File

@ -6,7 +6,10 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { proxy: {
"/api": "http://127.0.0.1:8001", '/api': {
}, target: 'http://127.0.0.1:8000',
}, changeOrigin: true
}
}
}
}); });

View File

@ -1,11 +1,9 @@
from django.contrib import admin from django.contrib import admin
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
# Register your models here. # Register your models here.
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Purchase) admin.site.register(Purchase)
admin.site.register(Platform) admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)

View File

@ -1,175 +1,38 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField( purchase = forms.ModelChoiceField(queryset=Purchase.objects.order_by("game__name"))
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta: class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session model = Session
fields = [ fields = [
"purchase", "purchase",
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"device",
"note", "note",
] ]
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class IncludePlatformSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-platform"] = value.instance.platform.id
return option
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs): game = forms.ModelChoiceField(queryset=Game.objects.order_by("name"))
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name"
),
required=False,
)
class Meta: class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
}
model = Purchase model = Purchase
fields = [ fields = ["game", "platform", "date_purchased", "date_refunded"]
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class EditionForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "sort_name", "year_released", "wikidata"] fields = ["name", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm): class PlatformForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ["name", "group"] fields = ["name", "group"]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30 # Generated by Django 4.1.5 on 2023-01-19 18:30
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
import django.db.models.deletion
from django.db import migrations, models
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

@ -1,34 +0,0 @@
# 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

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
import django.db.models.deletion
from django.db import migrations, models
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

@ -1,18 +0,0 @@
# 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

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]

View File

@ -1,31 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]

View File

@ -1,51 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]

View File

@ -1,141 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]

View File

@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]

View File

@ -1,17 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]

View File

@ -1,24 +0,0 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]

View File

@ -1,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Edition = apps.get_model(
"games", "Edition"
) # Replace 'your_app_name' with the actual name of your app
for edition in Edition.objects.all():
name = edition.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
edition.sort_name = sort_name
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0023_purchase_date_finished"),
]
operations = [
migrations.AddField(
model_name="edition",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,39 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-09 09:32
from django.db import migrations, models
def create_sort_name(apps, schema_editor):
Game = apps.get_model(
"games", "Game"
) # Replace 'your_app_name' with the actual name of your app
for game in Game.objects.all():
name = game.name
# Check for articles at the beginning of the name and move them to the end
if name.lower().startswith("the "):
sort_name = f"{name[4:]}, The"
elif name.lower().startswith("a "):
sort_name = f"{name[2:]}, A"
elif name.lower().startswith("an "):
sort_name = f"{name[3:]}, An"
else:
sort_name = name
# Save the sort_name back to the database
game.sort_name = sort_name
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0024_edition_sort_name"),
]
operations = [
migrations.AddField(
model_name="game",
name="sort_name",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.RunPython(create_sort_name),
]

View File

@ -1,27 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0025_game_sort_name"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="type",
field=models.CharField(
choices=[
("game", "Game"),
("dlc", "DLC"),
("season_pass", "Season Pass"),
("battle_pass", "Battle Pass"),
],
default="game",
max_length=255,
),
),
]

View File

@ -1,24 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="games.purchase",
),
),
]

View File

@ -1,25 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase
def null_game_name(apps, schema_editor):
Purchase.objects.filter(type=Purchase.GAME).update(name=None)
class Migration(migrations.Migration):
dependencies = [
("games", "0027_purchase_related_purchase"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="name",
field=models.CharField(
blank=True, default="Unknown Name", max_length=255, null=True
),
),
migrations.RunPython(null_game_name),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,252 +1,87 @@
from datetime import timedelta from datetime import datetime, timedelta
from typing import Any
from django.core.exceptions import ValidationError from zoneinfo import ZoneInfo
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
year_released = models.IntegerField(default=datetime.today().year)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self):
return self.sort_name
def save(self, *args, **kwargs):
def get_sort_name(name):
articles = ["a", "an", "the"]
name_parts = name.split()
first_word = name_parts[0].lower()
if first_word in articles:
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
else:
return name
self.sort_name = get_sort_name(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
def games_only(self):
return self.filter(type=Purchase.GAME)
class Purchase(models.Model): class Purchase(models.Model):
PHYSICAL = "ph" game = models.ForeignKey("Game", on_delete=models.CASCADE)
DIGITAL = "di" platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField() date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True) date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
)
def __str__(self): def __str__(self):
platform_info = self.platform return f"{self.game} ({self.platform})"
if self.platform != self.edition.platform:
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
def is_game(self):
return self.type == self.GAME
def save(self, *args, **kwargs):
if self.type == Purchase.GAME:
self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self): def total_duration(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 result["duration"] return format_duration(result["duration"])
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.is_manual() else "" mark = ", manual" if self.duration_manual != None else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = timezone.now() self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
calculated = timedelta(0) calculated = timedelta(0)
if self.is_manual(): if not self.duration_manual in (None, 0, timedelta(0)):
manual = self.duration_manual manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None: if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start calculated = self.timestamp_end - self.timestamp_start
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(), "%02.0H:%02.0m") result = format_duration(self.duration_seconds(), "%H:%m")
return result return result
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
@property @property
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration_formatted() return Session.objects.all().total_duration()
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:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"

View File

@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
*/ */
/* /*
@ -31,7 +31,6 @@
3. Use a more readable tab size. 3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings 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.
*/ */
html { html {
@ -44,12 +43,10 @@ html {
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 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: Inter, sans-serif;
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal;
/* 6 */
} }
/* /*
@ -129,7 +126,7 @@ code,
kbd, kbd,
samp, samp,
pre { pre {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-size: 1em; font-size: 1em;
/* 2 */ /* 2 */
@ -191,10 +188,6 @@ select,
textarea { textarea {
font-family: inherit; font-family: inherit;
/* 1 */ /* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%; font-size: 100%;
/* 1 */ /* 1 */
font-weight: inherit; font-weight: inherit;
@ -345,14 +338,6 @@ menu {
padding: 0; padding: 0;
} }
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/* /*
Prevent resizing textareas horizontally by default. Prevent resizing textareas horizontally by default.
*/ */
@ -434,7 +419,7 @@ video {
display: none; display: none;
} }
[type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
@ -451,7 +436,7 @@ video {
--tw-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000;
} }
[type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
--tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
@ -480,11 +465,6 @@ input::placeholder,textarea::placeholder {
::-webkit-date-and-time-value { ::-webkit-date-and-time-value {
min-height: 1.5em; min-height: 1.5em;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
} }
::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
@ -502,7 +482,7 @@ select {
print-color-adjust: exact; print-color-adjust: exact;
} }
[multiple],[size]:where(select:not([size="1"])) { [multiple] {
background-image: initial; background-image: initial;
background-position: initial; background-position: initial;
background-repeat: unset; background-repeat: unset;
@ -619,9 +599,6 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@ -669,9 +646,6 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@ -739,10 +713,6 @@ select {
} }
} }
.invisible {
visibility: hidden;
}
.static { .static {
position: static; position: static;
} }
@ -751,24 +721,16 @@ select {
position: fixed; position: fixed;
} }
.absolute { .left-2 {
position: absolute; left: 0.5rem;
} }
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
.left-2 { .clear-both {
left: 0.5rem; clear: both;
}
.right-3 {
right: 0.75rem;
}
.top-3 {
top: 0.75rem;
} }
.mx-auto { .mx-auto {
@ -776,23 +738,26 @@ select {
margin-right: auto; margin-right: auto;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.mr-4 {
margin-right: 1rem;
}
.mt-4 { .mt-4 {
margin-top: 1rem; margin-top: 1rem;
} }
.mb-3 {
margin-bottom: 0.75rem;
}
.mt-10 {
margin-top: 2.5rem;
}
.ml-1 {
margin-left: 0.25rem;
}
.block { .block {
display: block; display: block;
} }
@ -805,48 +770,46 @@ select {
display: flex; display: flex;
} }
.table {
display: table;
}
.hidden {
display: none;
}
.h-6 { .h-6 {
height: 1.5rem; height: 1.5rem;
} }
.h-5 {
height: 1.25rem;
}
.h-4 {
height: 1rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-6 {
width: 1.5rem;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
.w-6 {
width: 1.5rem;
}
.w-5 {
width: 1.25rem;
}
.w-7 {
width: 1.75rem;
}
.w-4 {
width: 1rem;
}
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
.max-w-xs {
max-width: 20rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@ -859,18 +822,32 @@ select {
align-items: center; align-items: center;
} }
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
.gap-4 {
gap: 1rem;
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
.truncate { .overflow-hidden {
overflow: hidden; overflow: hidden;
}
.text-ellipsis {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.whitespace-nowrap { .whitespace-nowrap {
@ -885,28 +862,46 @@ select {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-xl {
border-radius: 0.75rem;
}
.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));
} }
.bg-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / 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-green-600 {
--tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity));
}
.bg-blue-600 {
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
}
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
.px-2 { .p-2 {
padding-left: 0.5rem; padding: 0.5rem;
padding-right: 0.5rem; }
.py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
} }
.py-1 { .py-1 {
@ -914,9 +909,9 @@ select {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.py-2 { .px-2 {
padding-top: 0.5rem; padding-left: 0.5rem;
padding-bottom: 0.5rem; padding-right: 0.5rem;
} }
.pl-3 { .pl-3 {
@ -931,16 +926,8 @@ select {
text-align: center; text-align: center;
} }
.font-mono { .text-right {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right;
}
.font-serif {
font-family: IBM Plex Serif, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
}
.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 {
@ -948,11 +935,6 @@ select {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -963,34 +945,9 @@ select {
line-height: 1rem; line-height: 1rem;
} }
.text-2xl { .text-base {
font-size: 1.5rem; font-size: 1rem;
line-height: 2rem; line-height: 1.5rem;
}
.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 { .text-lg {
@ -1002,16 +959,9 @@ select {
font-weight: 600; font-weight: 600;
} }
.font-bold { .text-white {
font-weight: 700; --tw-text-opacity: 1;
} color: rgb(255 255 255 / var(--tw-text-opacity));
.capitalize {
text-transform: capitalize;
}
.italic {
font-style: italic;
} }
.text-slate-300 { .text-slate-300 {
@ -1019,14 +969,9 @@ select {
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
} }
.text-white { .text-red-400 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(248 113 113 / var(--tw-text-opacity));
}
.text-yellow-300 {
--tw-text-opacity: 1;
color: rgb(253 224 71 / var(--tw-text-opacity));
} }
.shadow-md { .shadow-md {
@ -1035,8 +980,10 @@ select {
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);
} }
.filter { .shadow {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow); --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.transition { .transition {
@ -1055,90 +1002,14 @@ select {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1); transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
} }
.max-w-20char { .dark form label {
max-width: 20ch;
}
.\[a-zA-Z\:\\-\] {
a-z-a--z: \-;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
:is(.dark form label) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.responsive-table { .dark form input,.dark
margin-left: auto; select,.dark
margin-right: auto; textarea {
}
:is(.dark .responsive-table) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.responsive-table tr:nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.responsive-table tbody tr:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(15 23 42 / var(--tw-bg-opacity));
}
.responsive-table thead th {
border-bottom-width: 2px;
--tw-border-opacity: 1;
border-bottom-color: rgb(100 116 139 / var(--tw-border-opacity));
text-align: left;
font-size: 1.25rem;
line-height: 1.75rem;
}
.responsive-table thead th:not(:first-child),
td:not(:first-child) {
border-left-width: 1px;
--tw-border-opacity: 1;
border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
padding-left: 1rem;
padding-right: 1rem;
}
:is(.dark form input),:is(.dark
select),:is(.dark
textarea) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity)); border-color: rgb(15 23 42 / var(--tw-border-opacity));
@ -1148,42 +1019,9 @@ textarea) {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
:is(.dark form input:disabled),:is(.dark #session-table {
select:disabled),:is(.dark display: grid;
textarea:disabled) { grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} }
#button-container button { #button-container button {
@ -1191,85 +1029,21 @@ textarea:disabled) {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
th {
text-align: right;
}
th label {
margin-right: 1rem;
}
.basic-button-container {
display: flex;
justify-content: center;
}
.basic-button-container > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.basic-button {
display: inline-block;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
text-transform: uppercase;
line-height: 1.25;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.basic-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.basic-button:focus {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.basic-button:active {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.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-blue-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
}
.hover\:underline:hover { .hover\:underline:hover {
text-decoration-line: underline; text-decoration-line: underline;
} }
@ -1290,6 +1064,16 @@ 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-red-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus { .focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px; --tw-ring-offset-width: 2px;
} }
@ -1298,55 +1082,44 @@ th label {
--tw-ring-offset-color: #bfdbfe; --tw-ring-offset-color: #bfdbfe;
} }
:is(.dark .dark\:bg-gray-800) { .dark .dark\:border-white {
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
}
.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));
} }
:is(.dark .dark\:bg-gray-900) { .dark .dark\:bg-gray-900 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
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-600) { .dark .dark\:bg-slate-700 {
--tw-text-opacity: 1; --tw-bg-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); background-color: rgb(51 65 85 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-white) { .dark .dark\:text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
@media (min-width: 640px) { .dark .dark\:text-slate-600 {
.sm\:table-cell { --tw-text-opacity: 1;
display: table-cell; color: rgb(71 85 105 / var(--tw-text-opacity));
} }
.sm\:max-w-md { .dark .dark\:text-slate-400 {
max-width: 28rem; --tw-text-opacity: 1;
} color: rgb(148 163 184 / var(--tw-text-opacity));
}
.sm\:px-4 { .dark .dark\:text-slate-300 {
padding-left: 1rem; --tw-text-opacity: 1;
padding-right: 1rem; color: rgb(203 213 225 / var(--tw-text-opacity));
}
.sm\:pl-2 {
padding-left: 0.5rem;
}
.sm\:pl-4 {
padding-left: 1rem;
}
.sm\:pl-6 {
padding-left: 1.5rem;
}
.sm\:decoration-2 {
text-decoration-thickness: 2px;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@ -1354,10 +1127,6 @@ th label {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }
@ -1365,28 +1134,4 @@ th label {
.md\:flex-row { .md\:flex-row {
flex-direction: row; flex-direction: row;
} }
.md\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.md\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.md\:max-w-40char {
max-width: 40ch;
}
}
@media (min-width: 1024px) {
.lg\:table-cell {
display: table-cell;
}
.lg\:max-w-lg {
max-width: 32rem;
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

View File

@ -1,29 +0,0 @@
/**
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
sourceElement.addEventListener("change", sourceElementHandler);
targetElement.addEventListener("focus", targetElementHandler);
}
window.addEventListener("load", () => {
syncSelectInputUntilChanged("#id_game", "#id_name");
});

View File

@ -1,12 +0,0 @@
import { syncSelectInputUntilChanged } from './utils.js'
let syncData = [
{
"source": "#id_name",
"source_value": "value",
"target": "#id_sort_name",
"target_value": "value"
}
]
syncSelectInputUntilChanged(syncData, "form")

View File

@ -1,31 +0,0 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
let syncData = [
{
source: "#id_edition",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
let myConfig = [
() => {
return getEl("#id_type").value == "game";
},
["#id_name", "#id_related_purchase"],
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
}
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
}

View File

@ -1,16 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date);
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}

File diff suppressed because one or more lines are too long

View File

@ -1,128 +0,0 @@
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
export function toISOUTCString(date) {
let month = (date.getMonth() + 1).toString().padStart(2, 0);
return `${date.getFullYear()}-${month}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source = (sourceElement instanceof HTMLSelectElement) ? sourceElement.selectedOptions[0] : sourceElement
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1))
}
else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByName(selector)
}
}
/**
* @description Does something to elements when something happens.
* @param {() => boolean} condition The condition that is being tested.
* @param {string[]} targetElements
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
*/
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error("Element ${elementName} doesn't exist.");
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error("Element ${elementName} doesn't exist.");
} else {
callbackfn2(el);
}
});
}
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };

View File

@ -1,43 +0,0 @@
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
/**
* @param {Node} targetNode
*/
function addToggleButton(targetNode) {
let manualToggleButton = elt(
"td",
{},
elt(
"div",
{ className: "basic-button" },
elt(
"button",
{
onclick: (event) => {
let textInputField = elt("input", { type: "text", id: targetNode.id });
targetNode.replaceWith(textInputField);
event.target.addEventListener("click", (event) => {
textInputField.replaceWith(targetNode);
});
},
},
"Toggle manual"
)
)
);
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
});

View File

@ -1,2 +0,0 @@
User-agent: *
Disallow: /

View File

@ -1,18 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %}
{% block title %} {% block title %}{{ title }}{% endblock title %}
{{ title }}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" class="mx-auto">
<table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }} {{ form.as_p }}
<tr>
<td></td> <input type="submit" value="Submit"/>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form> </form>
{% endblock content %} {% endblock content %}

View File

@ -1,22 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% endblock scripts %}

View File

@ -1,32 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,32 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,36 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% load static %}
<script type="module" src="{% static 'js/add_session.js' %}"></script>
{% endblock content %}

View File

@ -1,95 +1,48 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="description" content="Self-hosted time-tracker."/> <meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/> <meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title> <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<script src="{% static 'js/htmx.min.js' %}"></script> <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark"> <body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <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"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'index' %}" class="flex items-center">
<span class="text-4xl"></span> <span class="text-4xl"></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> <ul
<li class="relative group"> class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<a class="block py-2 pl-3 pr-4 hover:underline" <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
href="{% url 'add_game' %}">New</a> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> {% if game_available and platform_available %}
{% if purchase_available %} <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
<li> {% endif %}
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" {% if purchase_available %}
href="{% url 'add_device' %}">Device</a> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
</li> {% endif %}
{% endif %}
<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>
{% if game_available and platform_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>
{% endif %}
<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>
{% 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"> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
<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>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
{% block content %} {% block content %}No content here.{% endblock content %}
No content here.
{% endblock content %}
</div> </div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %}
{% endblock scripts %}
</body> </body>
</html> </html>

View File

@ -1,22 +0,0 @@
<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

@ -1,18 +0,0 @@
{% 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

@ -1,13 +0,0 @@
<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

@ -1,11 +1,11 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% 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_formatted }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}.
{% 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 %}
@ -14,4 +14,4 @@
You haven't played any games yet. Click "New Session" to add one now. You haven't played any games yet. Click "New Session" to add one now.
{% endif %} {% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -1,73 +1,81 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% block title %}Sessions{% endblock title %}
{% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
{% if dataset.count >= 1 %} {% if dataset.count >= 2 %}
<div class="mx-auto text-center my-4"> <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
<a {% endif %}
id="last-session-start" {% if dataset.count >= 1 %}
href="{% url 'start_session' last.id %}" <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
hx-get="{% url 'start_session' last.id %}" {% endif %}
hx-indicator="#indicator" {% if purchase or platform or game %}
hx-swap="afterbegin" <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">
hx-target=".responsive-table tbody" <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" />
hx-select=".responsive-table tbody tr:first-child" </svg>
onClick="document.querySelector('#last-session-start').classList.add('invisible')" </a><span>Filtering by "{% firstof purchase platform game %}"</span>
class="{% if last.timestamp_end == null %}invisible{% endif %}" {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
> {% endif %}
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %} {% if dataset.count >= 1 %}
</a> <a class="clear-both" href="{% url 'start_session' last.purchase.id %}">
</div> <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 ">
{% endif %} <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" />
<table class="responsive-table"> </svg>
<thead> {{ last.purchase }}
<tr> </button>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th> </a>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th> {% endif %}
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th> </div>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th> <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
</tr> <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
</thead> <div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
<tbody> <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
{% for data in dataset %} <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
<tr> <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
<td <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char" {% 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>
{{ data.purchase.edition }} <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>
</td> <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> <div class="dark:text-slate-400 text-center">
{{ data.timestamp_start | date:"d/m/Y H:i" }} {% if data.unfinished %}
</td> <span class="text-red-400">Not finished yet.</span>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> {% elif data.duration_manual %}
{% if data.unfinished %} --
<a {% else %}
href="{% url 'update_session' data.id %}" {{ data.timestamp_end | date:"d/m/Y H:i" }}
hx-get="{% url 'update_session' data.id %}" {% endif %}
hx-swap="outerHTML" </div>
hx-target=".responsive-table tbody tr:first-child" <div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
hx-select=".responsive-table tbody tr:first-child" <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
hx-indicator="#indicator" </svg>
onClick="document.querySelector('#last-session-start').classList.remove('invisible')" {% endif %}</div>
> <div id="button-container" class="flex justify-end">
<span class="text-yellow-300">Finish now?</span> {% if data.unfinished %}
</a> <a href="{% url 'update_session' data.id %}">
{% elif data.duration_manual %} <button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center 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 w-7 h-4 rounded-lg ">
-- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
{% else %} <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
{{ data.timestamp_end | date:"d/m/Y H:i" }} </svg>
{% endif %} </button>
</td> </a>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> {% endif %}
{{ data.duration_formatted }} <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 ">
</td> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
</tr> <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" />
{% endfor %} <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" />
</tbody> </svg>
</table> </button>
{% endblock content %} <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">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
</svg>
</button>
</a>
</div>
{% endfor %}
</div>
{% endblock content %}

View File

@ -1 +0,0 @@
{{ form.related_purchase }}

View File

@ -1,213 +0,0 @@
{% 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>
<div class="flex flex-column flex-wrap justify-center">
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ this_year_finished_this_year.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
</div>
</div>
<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">Finished</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">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_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.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</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">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_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.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</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">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_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.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All 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 }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</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

@ -1,83 +0,0 @@
{% 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-4xl flex items-center">
{{ 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">
{{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }})
</h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.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">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<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 %}
{% if purchase.related_purchases %}
<li>
<ul>
{% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-6 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</li>
{% endfor %}
</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 %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1>
<ul>
{% 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>
{% endfor %}
</ul>
</div>
{% endblock content %}

View File

@ -4,64 +4,25 @@ from games import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), 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-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"), path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"), path("add-session/", views.add_session, name="add_session"),
path(
"add-session-for-purchase/<int:purchase_id>",
views.add_session,
name="add_session_for_purchase",
),
path( path(
"update-session/by-session/<int:session_id>", "update-session/by-session/<int:session_id>",
views.update_session, views.update_session,
name="update_session", name="update_session",
), ),
path( path(
"start-session-same-as-last/<int:last_session_id>", "start-session/<int:purchase_id>",
views.start_session_same_as_last, views.start_session,
name="start_session_same_as_last", name="start_session",
), ),
path( path(
"start-session/<int:game_id>", "delete_session/by-id/<int:session_id>",
views.start_game_session, views.delete_session,
name="start_game_session", name="delete_session",
), ),
# path(
# "delete_session/by-id/<int:session_id>",
# views.delete_session,
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"), path("add-purchase/", views.add_purchase, name="add_purchase"),
path(
"add-purchase-for-edition/<int:edition_id>",
views.add_purchase,
name="add_purchase_for_edition",
),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"),
path(
"add-edition-for-game/<int:game_id>",
views.add_edition,
name="add_edition_for_game",
),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"), path("list-sessions/", views.list_sessions, name="list_sessions"),
path( path(
"list-sessions/by-purchase/<int:purchase_id>", "list-sessions/by-purchase/<int:purchase_id>",
@ -81,16 +42,4 @@ urlpatterns = [
{"filter": "game"}, {"filter": "game"},
name="list_sessions_by_game", name="list_sessions_by_game",
), ),
path(
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
] ]

View File

@ -1,69 +1,43 @@
from datetime import datetime, timedelta from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.plots import playtime_over_time_chart
from common.time import now as now_with_tz from common.time import now as now_with_tz
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
from common.time import format_duration from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm
from common.utils import safe_division from .models import Game, Platform, Purchase, Session
from .forms import (
DeviceForm,
EditionForm,
GameForm,
PlatformForm,
PurchaseForm,
SessionForm,
)
from .models import Edition, Game, Platform, Purchase, Session
def model_counts(request): def model_counts(request):
return { return {
"game_available": Game.objects.count() != 0, "game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0, "platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0, "purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(), "session_count": Session.objects.count(),
} }
def stats_dropdown_year_range(request): def add_session(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)}
return result
def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": timezone.now()} initial = {}
last = Session.objects.last() now = now_with_tz()
initial["timestamp_start"] = now
last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
if request.method == "POST": form = SessionForm(request.POST or None, initial=initial)
form = SessionForm(request.POST or None, initial=initial) if form.is_valid():
if form.is_valid(): form.save()
form.save() return redirect("list_sessions")
return redirect("list_sessions")
else:
if purchase_id:
purchase = Purchase.objects.get(id=purchase_id)
form = SessionForm(
initial={
**initial,
"purchase": purchase,
}
)
else:
form = SessionForm(initial=initial)
context["title"] = "Add New Session" context["title"] = "Add New Session"
context["form"] = form context["form"] = form
return render(request, "add_session.html", context) return render(request, "add.html", context)
def update_session(request, session_id=None): def update_session(request, session_id=None):
@ -73,203 +47,20 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def use_custom_redirect( def start_session(request, purchase_id=None):
func: Callable[..., HttpResponse] session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
) -> Callable[..., HttpResponse]: session.save()
""" return redirect("list_sessions")
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 delete_session(request, session_id=None):
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id) session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session) session.delete()
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
context["script_name"] = "add_purchase.js"
return render(request, "add_purchase.html", context)
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
def view_game(request, game_id=None):
game = Game.objects.get(id=game_id)
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
game_purchases = Purchase.objects.filter(edition__game_id=game_id).filter(
type=Purchase.GAME
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
game_purchases_prefetch = Prefetch(
"purchase_set",
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related(
nongame_related_purchases_prefetch
),
to_attr="game_purchases",
)
editions = (
Edition.objects.filter(game=game)
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
)
sessions = Session.objects.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")
playrange = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@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,
}
)
session.save()
return redirect("list_sessions") return redirect("list_sessions")
def start_session_same_as_last(request, last_session_id: int): def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""):
last_session = Session.objects.get(id=last_session_id)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": timezone.now(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions")
def list_sessions(
request,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
ownership_type: str = "",
):
context = {} context = {}
context["title"] = "Sessions"
if filter == "purchase": if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
@ -277,260 +68,53 @@ def list_sessions(
elif filter == "platform": elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id) dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = Session.objects.filter(purchase__edition__game=game_id) dataset = Session.objects.filter(purchase__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30)
)
context["title"] = "Last 30 days"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta( if session.timestamp_end == None and session.duration_manual.seconds == 0:
seconds=0 session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
):
session.timestamp_end = timezone.now()
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration()
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()
# charts are always oldest->newest
if Session.objects.count() >= 2:
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int = 0): def add_purchase(request):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = timezone.now().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK"
unique_days = (
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
this_year_played_purchases = Purchase.objects.filter(
session__in=this_year_sessions
).distinct()
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
this_year_purchases_with_currency = this_year_purchases.filter(
price_currency__exact=selected_currency
)
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
date_refunded=None
)
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
date_finished__isnull=True
).filter(
type=Purchase.GAME
) # do not count DLC etc.
this_year_purchases_unfinished_percent = int(
safe_division(
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
)
* 100
)
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
purchases_finished_this_year_released_this_year = (
purchases_finished_this_year.filter(edition__year_released=year).order_by(
"date_finished"
)
)
purchased_this_year_finished_this_year = (
this_year_purchases_without_refunded.intersection(
purchases_finished_this_year
).order_by("date_finished")
)
this_year_spendings = this_year_purchases_without_refunded.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"] or 0
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=this_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 = (
this_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")
backlog_decrease_count = (
Purchase.objects.filter(date_purchased__year__lt=year)
.intersection(purchases_finished_this_year)
.count()
)
context = {
"total_hours": format_duration(
this_year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": this_year_played_purchases.count(),
"total_2023_games": this_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": this_year_purchases_without_refunded,
"spent_per_game": int(
safe_division(total_spent, this_year_purchases_without_refunded.count())
),
"all_finished_this_year": purchases_finished_this_year.order_by(
"date_finished"
),
"this_year_finished_this_year": purchases_finished_this_year_released_this_year.order_by(
"date_finished"
),
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year.order_by(
"date_finished"
),
"total_sessions": this_year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": this_year_purchases_unfinished,
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
"refunded_percent": int(
safe_division(
this_year_purchases_refunded.count(),
this_year_purchases_with_currency.count(),
)
* 100
),
"all_purchased_refunded_this_year": this_year_purchases_refunded,
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
"date_purchased"
),
"backlog_decrease_count": backlog_decrease_count,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": timezone.now()} now = datetime.now()
initial = {"date_purchased": now}
if request.method == "POST": form = PurchaseForm(request.POST or None, initial=initial)
form = PurchaseForm(request.POST or None, initial=initial) if form.is_valid():
if form.is_valid(): form.save()
purchase = form.save() return redirect("index")
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
)
)
else:
return redirect("index")
else:
if edition_id:
edition = Edition.objects.get(id=edition_id)
form = PurchaseForm(
initial={
**initial,
"edition": edition,
"platform": edition.platform,
}
)
else:
form = PurchaseForm(initial=initial)
context["form"] = form context["form"] = form
context["title"] = "Add New Purchase" context["title"] = "Add New Purchase"
context["script_name"] = "add_purchase.js" return render(request, "add.html", context)
return render(request, "add_purchase.html", context)
def add_game(request): def add_game(request):
context = {} context = {}
form = GameForm(request.POST or None) form = GameForm(request.POST or None)
if form.is_valid(): if form.is_valid():
game = form.save() form.save()
if "submit_and_redirect" in request.POST: return redirect("index")
return HttpResponseRedirect(
reverse("add_edition_for_game", kwargs={"game_id": game.id})
)
else:
return redirect("index")
context["form"] = form context["form"] = form
context["title"] = "Add New Game" context["title"] = "Add New Game"
context["script_name"] = "add_game.js" return render(request, "add.html", context)
return render(request, "add_game.html", context)
def add_edition(request, game_id=None):
context = {}
if request.method == "POST":
form = EditionForm(request.POST or None)
if form.is_valid():
edition = form.save()
if "submit_and_redirect" in request.POST:
return HttpResponseRedirect(
reverse(
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
)
)
else:
return redirect("index")
else:
if game_id:
game = Game.objects.get(id=game_id)
form = EditionForm(
initial={
"game": game,
"name": game.name,
"sort_name": game.sort_name,
"year_released": game.year_released,
}
)
else:
form = EditionForm()
context["form"] = form
context["title"] = "Add New Edition"
context["script_name"] = "add_edition.js"
return render(request, "add_edition.html", context)
def add_platform(request): def add_platform(request):
@ -543,3 +127,10 @@ def add_platform(request):
context["form"] = form context["form"] = form
context["title"] = "Add New Platform" context["title"] = "Add New Platform"
return render(request, "add.html", context) return render(request, "add.html", context)
def index(request):
context = {}
context["total_duration"] = Session().duration_sum
context["title"] = "Index"
return render(request, "index.html", context)

6
package.json Normal file
View File

@ -0,0 +1,6 @@
{
"dependencies": {
"@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9"
}
}

1114
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.1" version = "1.0.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"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.10"
django = "^4.2.0" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2" pandas = "^1.5.2"
@ -27,10 +27,6 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

View File

@ -1,90 +0,0 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.test import TestCase
from django.urls import reverse
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class PathWorksTest(TestCase):
def setUp(self) -> None:
pl = Platform(name="Test Platform")
pl.save()
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition", platform=pl)
e.save()
p = Purchase(
edition=e,
platform=pl,
date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.testSession = s
return super().setUp()
def test_add_device_returns_200(self):
url = reverse("add_device")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_platform_returns_200(self):
url = reverse("add_platform")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_game_returns_200(self):
url = reverse("add_game")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_edition_returns_200(self):
url = reverse("add_edition")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_purchase_returns_200(self):
url = reverse("add_purchase")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_add_session_returns_200(self):
url = reverse("add_session")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_session_returns_200(self):
id = self.testSession.id
url = reverse("edit_session", args=[id])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_view_game_returns_200(self):
url = reverse("view_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_edit_game_returns_200(self):
url = reverse("edit_game", args=[1])
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_list_sessions_returns_200(self):
url = reverse("list_sessions")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)

View File

@ -1,40 +0,0 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup()
from django.conf import settings
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE)
class FormatDurationTest(TestCase):
def setUp(self) -> None:
return super().setUp()
def test_duration_format(self):
g = Game(name="The Test Game")
g.save()
e = Edition(game=g, name="The Test Game Edition")
e.save()
p = Purchase(
edition=e, date_purchased=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO)
)
p.save()
s = Session(
purchase=p,
timestamp_start=datetime(2022, 9, 26, 14, 58, tzinfo=ZONEINFO),
timestamp_end=datetime(2022, 9, 26, 17, 38, tzinfo=ZONEINFO),
)
s.save()
self.assertEqual(
s.duration_formatted(),
"02:40",
)

View File

@ -6,6 +6,7 @@ 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):
@ -18,21 +19,6 @@ 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")
@ -83,16 +69,6 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%r seconds") result = format_duration(delta, "%r seconds")
self.assertEqual(result, "0 seconds") self.assertEqual(result, "0 seconds")
def test_specific(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%H:%m")
self.assertEqual(result, "2:40")
def test_specific_precise_if_unncessary(self):
delta = timedelta(hours=2, minutes=40)
result = format_duration(delta, "%02.0H:%02.0m")
self.assertEqual(result, "02:40")
def test_all_at_once(self): def test_all_at_once(self):
delta = timedelta(days=50, hours=10, minutes=34, seconds=24) delta = timedelta(days=50, hours=10, minutes=34, seconds=24)
result = format_duration( result = format_duration(

View File

@ -72,7 +72,6 @@ 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",
], ],
}, },
}, },
@ -127,7 +126,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static" STATIC_ROOT = BASE_DIR / "static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field