Compare commits
	
		
			78 Commits
		
	
	
		
			61d2e65d83
			...
			fix-splitd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7dfd91421e | |||
| 4d91a76513 | |||
| e51d586255 | |||
| 2553d6f9e6 | |||
| 8cf6270d8f | |||
| 0b1089b0f4 | |||
| 9534492f17 | |||
| 8b7ed90b49 | |||
| 2ce4dd3a0e | |||
| a851b5329a | |||
| 6fa049e1b1 | |||
| 6b7ed0dbb5 | |||
| dd50d6dd40 | |||
| 162f4f3dbf | |||
| e8e6d5bcae | |||
| c5b451a258 | |||
| 163211ab0b | |||
| 64f5668dde | |||
| 465d958d9b | |||
| d8ece979a8 | |||
| 2defdd4657 | |||
| 078f87687f | |||
| 49723831e9 | |||
| 025ea0dd4e | |||
| 97467c7a52 | |||
| 7842d6f45d | |||
| b77089f7ad | |||
| 24f4459318 | |||
| 751182df52 | |||
| 33e136a810 | |||
| 362732c22a | |||
| 8e1c670ffd | |||
| e5a9b9aa50 | |||
| c9b2d5bd8d | |||
| 0d20b543b0 | |||
| f7b69f7704 | |||
| 1ccfdc321a | |||
| 25a58c2732 | |||
| 270d9f7296 | |||
| 2939b4a515 | |||
| d029fda896 | |||
| 9dead362c1 | |||
| d81dba727b | |||
| f550978e4a | |||
| db5de81c09 | |||
| 15ed6504b1 | |||
| fd9bf8c026 | |||
| 5172c38c16 | |||
| 9c56ed4ce8 | |||
| d00bb1cd06 | |||
| bedfbb7f31 | |||
| f2b08cd1cd | |||
| 5ad0e52787 | |||
| f7ec07994f | |||
| 03e89a92c7 | |||
| 76bf03b482 | |||
| e6b5804e37 | |||
| 2807c5e00e | |||
| 8efce77062 | |||
| 89be0c031b | |||
| 4e67735de8 | |||
| 869e0e0fe0 | |||
| 85f52fc735 | |||
| 34ce1e9b05 | |||
| 67f5090bf8 | |||
| 51d5306f91 | |||
| 66a49ff911 | |||
| 3e32261d4a | |||
| 9b07758198 | |||
| c57f969a00 | |||
| fd7fc7c710 | |||
| 32f10e183e | |||
| fdb9aa8e84 | |||
| 4b45127335 | |||
| b8a15e43db | |||
| a1309c3738 | |||
| 12cc9025a0 | |||
| 6fe960bc04 | 
							
								
								
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,5 @@ | ||||
| src/web/static/* | ||||
| .venv | ||||
| .githooks | ||||
| .vscode | ||||
| node_modules | ||||
| @ -1,9 +1,16 @@ | ||||
| --- | ||||
| kind: pipeline | ||||
| type: docker | ||||
| name: build image | ||||
| name: default | ||||
|  | ||||
| steps: | ||||
| - name: test | ||||
|   image: python:3.10 | ||||
|   commands: | ||||
|     - python -m pip install poetry | ||||
|     - poetry install | ||||
|     - poetry env info | ||||
|     - poetry run pytest | ||||
| - name: build container | ||||
|   image: plugins/docker | ||||
|   settings: | ||||
|  | ||||
							
								
								
									
										14
									
								
								.githooks/pre-commit.disabled
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.githooks/pre-commit.disabled
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| #!/bin/bash | ||||
| set -euo pipefail | ||||
| echo "----------------" | ||||
| echo "Pre-commit hooks" | ||||
| echo "================" | ||||
| BASE_VERSION_NUMBER=$(git describe --tags --abbrev=0) | ||||
| FULL_VERSION_NUMBER=$(git describe --tags) | ||||
| echo "Updating "VERSION_NUMBER" in Dockerfile to $FULL_VERSION_NUMBER" | ||||
| sed -i "s/^ENV VERSION_NUMBER.*$/ENV VERSION_NUMBER ${FULL_VERSION_NUMBER}/" Dockerfile | ||||
| echo "Updating "version" in pyproject.toml to $BASE_VERSION_NUMBER" | ||||
| sed -i "s/^version = \".*\"$/version = \"${BASE_VERSION_NUMBER}\"/" pyproject.toml | ||||
| git add Dockerfile | ||||
| git add pyproject.toml | ||||
| echo "----------------" | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,8 @@ | ||||
| __pycache__ | ||||
| .mypy_cache | ||||
| .pytest_cache | ||||
| .venv | ||||
| node_modules | ||||
| package-lock.json | ||||
| db.sqlite3 | ||||
| src/web/static | ||||
							
								
								
									
										8
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @ -0,0 +1,8 @@ | ||||
| { | ||||
|     "python.testing.pytestArgs": [ | ||||
|         "tests" | ||||
|     ], | ||||
|     "python.testing.unittestEnabled": false, | ||||
|     "python.testing.pytestEnabled": true, | ||||
|     "python.analysis.typeCheckingMode": "basic" | ||||
| } | ||||
							
								
								
									
										60
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										60
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -1,4 +1,64 @@ | ||||
| ## Unreleased | ||||
|  | ||||
| * New | ||||
|   * When adding session, pre-select game with the last session | ||||
|   * Date and time input fields now have proper pickers | ||||
|  | ||||
| ## 0.2.4 / 2023-01-16 19:39+01:00 | ||||
|  | ||||
| * Fixed | ||||
|   * When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform | ||||
|   * Playtime graph would display timeline backwards | ||||
|   * Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34) | ||||
|   * Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35) | ||||
|  | ||||
| ## 0.2.3 / 2023-01-15 23:13+01:00 | ||||
|  | ||||
| * Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32) | ||||
| * Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33 | ||||
|  | ||||
| ## 0.2.2 / 2023-01-15 17:59+01:00 | ||||
|  | ||||
| * Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29) | ||||
| * Fix error when showing session list with no sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/31) | ||||
|  | ||||
| ## 0.2.1 / 2023-01-13 16:53+01:00 | ||||
|  | ||||
| * List number of sessions when filtering on session list | ||||
| * Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19) | ||||
|  | ||||
| ## 0.2.0 / 2023-01-09 22:42+01:00 | ||||
|  | ||||
| * Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6) | ||||
| * Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26) | ||||
|  | ||||
| ## 0.1.4 / 2023-01-08 15:45+01:00 | ||||
|  | ||||
| * Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23) | ||||
|  | ||||
| ## 0.1.3 / 2023-01-08 15:23+01:00 | ||||
|  | ||||
| * Fix CSRF error (https://git.kucharczyk.xyz/lukas/timetracker/pulls/22) | ||||
|  | ||||
| ## 0.1.2 / 2023-01-07 22:05+01:00 | ||||
|  | ||||
| * Switch to Uvicorn/Gunicorn + Caddy (https://git.kucharczyk.xyz/lukas/timetracker/pulls/4) | ||||
|  | ||||
| ## 0.1.1 / 2023-01-05 23:26+01:00 | ||||
| * Order by timestamp_start by default | ||||
| * Add pre-commit hook to update version | ||||
| * Improve the newcomer experience by guiding through each step | ||||
| * Fix errors with empty database | ||||
| * Fix negative playtimes being considered positive | ||||
| * Add %d for days to common.util.time.format_duration | ||||
| * Set up tests, add tests for common.util.time | ||||
| * Display total hours played on homepage | ||||
| * Add format_duration to common.util.time | ||||
| * Allow deleting sessions | ||||
| * Redirect after adding game/platform/purchase/session | ||||
| * Fix display of duration_manual | ||||
| * Fix display of duration_calculated, display durations less than a minute | ||||
| * Make the "Finish now?" button on session list work | ||||
| * Hide navigation bar items if there are no games/purchases/sessions | ||||
| * Set default version to "git-main" to indicate development environment | ||||
| * Add homepage, link to it from the logo | ||||
|  | ||||
							
								
								
									
										14
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Caddyfile
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| { | ||||
|     auto_https off | ||||
|     admin off | ||||
| } | ||||
|  | ||||
| :8000 { | ||||
|     handle_path /static/* { | ||||
|         root * src/web/static/ | ||||
|         file_server | ||||
|     } | ||||
|     handle { | ||||
|         reverse_proxy :8001 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										39
									
								
								Dockerfile
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								Dockerfile
									
									
									
									
									
								
							| @ -1,16 +1,39 @@ | ||||
| FROM python:3.10-slim-bullseye | ||||
| ENV VIRTUAL_ENV=/opt/venv | ||||
| RUN python3 -m venv pip $VIRTUAL_ENV | ||||
| ENV PATH="$VIRTUAL_ENV/bin:$PATH" | ||||
| RUN pip install --no-cache-dir poetry | ||||
| RUN useradd --create-home --uid 1000 timetracker | ||||
| FROM node as css | ||||
| WORKDIR /app | ||||
| COPY . /app | ||||
| RUN npm install && \ | ||||
|     npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify | ||||
|  | ||||
| FROM python:3.10.9-slim-bullseye | ||||
|  | ||||
| ENV VERSION_NUMBER 0.2.4 | ||||
| ENV PROD 1 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| RUN apt update && \ | ||||
|     apt install -y \ | ||||
|     bash \ | ||||
|     vim \ | ||||
|     curl && \ | ||||
|     apt install -y debian-keyring debian-archive-keyring apt-transport-https && \ | ||||
|     curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg && \ | ||||
|     curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list && \ | ||||
|     apt update && \ | ||||
|     apt install caddy && \ | ||||
|     rm -rf /var/lib/apt/lists/* | ||||
|  | ||||
| RUN useradd -m --uid 1000 timetracker | ||||
| WORKDIR /home/timetracker/app | ||||
| COPY . /home/timetracker/app/ | ||||
| RUN chown -R timetracker:timetracker /home/timetracker/app | ||||
| RUN poetry install --without dev | ||||
| COPY --from=css /app/src/web/tracker/static/base.css /home/timetracker/app/src/web/tracker/static/base.css | ||||
| COPY entrypoint.sh / | ||||
| RUN chmod +x /entrypoint.sh | ||||
|  | ||||
| USER timetracker | ||||
| ENV PATH="$PATH:/home/timetracker/.local/bin" | ||||
| RUN pip install --no-cache-dir poetry | ||||
| RUN poetry install --without dev | ||||
|  | ||||
| EXPOSE 8000 | ||||
| ENV VERSION_NUMBER 0.1.0-4-g166dd71 | ||||
| ENTRYPOINT [ "/entrypoint.sh" ] | ||||
							
								
								
									
										43
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,8 +1,6 @@ | ||||
| .PHONY: createsuperuser shell | ||||
|  | ||||
| all: css migrate | ||||
|  | ||||
| initialize: npm css migrate loadplatforms | ||||
| initialize: npm css migrate sethookdir loadplatforms | ||||
|  | ||||
| HTMLFILES := $(shell find src/web/tracker/templates -type f) | ||||
|  | ||||
| @ -16,25 +14,48 @@ css-dev: css | ||||
| 	npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css --watch | ||||
|  | ||||
| makemigrations: | ||||
| 	python src/web/manage.py makemigrations | ||||
| 	poetry run python src/web/manage.py makemigrations | ||||
|  | ||||
| migrate: makemigrations | ||||
| 	python src/web/manage.py migrate | ||||
| 	poetry run python src/web/manage.py migrate | ||||
|  | ||||
| dev: migrate | ||||
| 	python src/web/manage.py runserver | ||||
| 	poetry run python src/web/manage.py runserver | ||||
|  | ||||
| caddy: | ||||
| 	caddy run --watch | ||||
|  | ||||
| dev-prod: migrate collectstatic | ||||
| 	cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker | ||||
|  | ||||
| dumptracker: | ||||
| 	python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml | ||||
| 	poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml | ||||
|  | ||||
| loadplatforms: | ||||
| 	python src/web/manage.py loaddata platforms.yaml | ||||
| 	poetry run python src/web/manage.py loaddata platforms.yaml | ||||
|  | ||||
| loadsample: | ||||
| 	python src/web/manage.py loaddata sample.yaml | ||||
| 	poetry run python src/web/manage.py loaddata sample.yaml | ||||
|  | ||||
| createsuperuser: | ||||
| 	python src/web/manage.py createsuperuser | ||||
| 	poetry run python src/web/manage.py createsuperuser | ||||
|  | ||||
| shell: | ||||
| 	python src/web/manage.py shell | ||||
| 	poetry run python src/web/manage.py shell | ||||
|  | ||||
| collectstatic: | ||||
| 	poetry run python src/web/manage.py collectstatic --clear --no-input | ||||
|  | ||||
| poetry.lock: pyproject.toml | ||||
| 	poetry install | ||||
|  | ||||
| test: poetry.lock | ||||
| 	poetry run pytest | ||||
|  | ||||
| date: | ||||
| 	poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' | ||||
|  | ||||
| cleanstatic: | ||||
| 	rm -r src/web/static/* | ||||
|  | ||||
| clean: cleanstatic | ||||
|  | ||||
							
								
								
									
										17
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,17 @@ | ||||
| --- | ||||
| services: | ||||
|   timetracker: | ||||
|     image: registry.kucharczyk.xyz/timetracker | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: Dockerfile | ||||
|     container_name: timetracker | ||||
|     environment: | ||||
|       - TZ=Europe/Prague | ||||
|       - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" | ||||
|     user: "1000" | ||||
|     # volumes: | ||||
|     #   - "db:/home/timetracker/app/src/web/db.sqlite3" | ||||
|     ports: | ||||
|       - "8000:8000" | ||||
|     restart: unless-stopped | ||||
| @ -1,8 +1,13 @@ | ||||
| #!/bin/bash | ||||
| # Apply database migrations | ||||
| set -euo pipefail | ||||
| echo "Apply database migrations" | ||||
| python src/web/manage.py migrate | ||||
| poetry run python src/web/manage.py migrate | ||||
|  | ||||
| echo "Collect static files" | ||||
| poetry run python src/web/manage.py collectstatic --clear --no-input | ||||
|  | ||||
| # Start server | ||||
| echo "Starting server" | ||||
| python src/web/manage.py runserver 0.0.0.0:8000 | ||||
| caddy start | ||||
| cd src/web || exit | ||||
| poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - | ||||
|  | ||||
							
								
								
									
										1019
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1019
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -1,6 +1,6 @@ | ||||
| [tool.poetry] | ||||
| name = "timetracker" | ||||
| version = "0.0.0" | ||||
| version = "0.2.4" | ||||
| description = "A simple time tracker." | ||||
| authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] | ||||
| license = "GPL" | ||||
| @ -9,12 +9,27 @@ readme = "README.md" | ||||
| [tool.poetry.dependencies] | ||||
| python = "^3.10" | ||||
| django = "^4.1.4" | ||||
| gunicorn = "^20.1.0" | ||||
| uvicorn = "^0.20.0" | ||||
| pandas = "^1.5.2" | ||||
| matplotlib = "^3.6.3" | ||||
|  | ||||
| [tool.poetry.group.dev.dependencies] | ||||
| black = "^22.12.0" | ||||
| mypy = "^0.991" | ||||
| pyyaml = "^6.0" | ||||
| pytest = "^7.2.0" | ||||
| django-extensions = "^3.2.1" | ||||
| werkzeug = "^2.2.2" | ||||
| djhtml = "^1.5.2" | ||||
| djlint = "^1.19.11" | ||||
| isort = "^5.11.4" | ||||
|  | ||||
| [build-system] | ||||
| requires = ["poetry-core"] | ||||
| build-backend = "poetry.core.masonry.api" | ||||
|  | ||||
| [tool.pytest.ini_options] | ||||
| pythonpath = [ | ||||
|   "src" | ||||
| ] | ||||
|  | ||||
| @ -3,13 +3,20 @@ | ||||
| @tailwind utilities; | ||||
|  | ||||
| form label { | ||||
|     @apply dark:text-slate-400; | ||||
|   @apply dark:text-slate-400; | ||||
| } | ||||
|  | ||||
| form input,select,textarea { | ||||
|     @apply dark:bg-slate-500 dark:border dark:border-slate-900 dark:text-slate-100; | ||||
| form input, | ||||
| select, | ||||
| textarea { | ||||
|   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; | ||||
| } | ||||
|  | ||||
| form input[type=submit] { | ||||
|     @apply p-2 bg-purple-900; | ||||
| #session-table { | ||||
|   display: grid; | ||||
|   grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr; | ||||
| } | ||||
|  | ||||
| #button-container button { | ||||
|   @apply mx-1; | ||||
| } | ||||
							
								
								
									
										0
									
								
								src/web/common/util/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								src/web/common/util/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										91
									
								
								src/web/common/util/plots.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								src/web/common/util/plots.py
									
									
									
									
									
										Normal 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 tracker.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 | ||||
							
								
								
									
										63
									
								
								src/web/common/util/time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/web/common/util/time.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,63 @@ | ||||
| import re | ||||
| 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): | ||||
|     if duration == None: | ||||
|         return timedelta(0) | ||||
|     elif isinstance(duration, int): | ||||
|         return timedelta(seconds=duration) | ||||
|     elif isinstance(duration, timedelta): | ||||
|         return duration | ||||
|  | ||||
|  | ||||
| def format_duration( | ||||
|     duration: timedelta | int | None, format_string: str = "%H hours" | ||||
| ) -> str: | ||||
|     """ | ||||
|     Format timedelta into the specified format_string. | ||||
|     Valid format variables: | ||||
|     - %H hours | ||||
|     - %m minutes | ||||
|     - %s seconds | ||||
|     - %r total seconds | ||||
|     Values don't change into higher units if those units are missing | ||||
|     from the formatting string. For example: | ||||
|     - 61 seconds as "%s" = 61 seconds | ||||
|     - 61 seconds as "%m %s" = 1 minutes 1 seconds" | ||||
|     """ | ||||
|     minute_seconds = 60 | ||||
|     hour_seconds = 60 * minute_seconds | ||||
|     day_seconds = 24 * hour_seconds | ||||
|     duration = _safe_timedelta(duration) | ||||
|     # we don't need float | ||||
|     seconds_total = int(duration.total_seconds()) | ||||
|     # timestamps where end is before start | ||||
|     if seconds_total < 0: | ||||
|         seconds_total = 0 | ||||
|     days = hours = minutes = seconds = 0 | ||||
|     remainder = seconds = seconds_total | ||||
|     if "%d" in format_string: | ||||
|         days, remainder = divmod(seconds_total, day_seconds) | ||||
|     if "%H" in format_string: | ||||
|         hours, remainder = divmod(remainder, hour_seconds) | ||||
|     if "%m" in format_string: | ||||
|         minutes, seconds = divmod(remainder, minute_seconds) | ||||
|     literals = { | ||||
|         "%d": str(days), | ||||
|         "%H": str(hours), | ||||
|         "%m": str(minutes), | ||||
|         "%s": str(seconds), | ||||
|         "%r": str(seconds_total), | ||||
|     } | ||||
|     formatted_string = format_string | ||||
|     for pattern, replacement in literals.items(): | ||||
|         formatted_string = re.sub(pattern, replacement, formatted_string) | ||||
|     return formatted_string | ||||
| @ -1,5 +1,6 @@ | ||||
| from django.contrib import admin | ||||
| from .models import Game, Purchase, Platform, Session | ||||
|  | ||||
| from .models import Game, Platform, Purchase, Session | ||||
|  | ||||
| # Register your models here. | ||||
| admin.site.register(Game) | ||||
|  | ||||
| @ -1,8 +1,26 @@ | ||||
| from django import forms | ||||
| from .models import Session, Purchase, Game, Platform | ||||
|  | ||||
| from .models import Game, Platform, Purchase, Session | ||||
|  | ||||
|  | ||||
| class SessionForm(forms.ModelForm): | ||||
|     custom_datetime_widget = forms.SplitDateTimeWidget( | ||||
|         date_format=("%d-%m-%Y"), | ||||
|         time_format=("%H:%M"), | ||||
|         date_attrs={"type": "date"}, | ||||
|         time_attrs={"type": "time"}, | ||||
|     ) | ||||
|     timestamp_start = forms.SplitDateTimeField( | ||||
|         input_date_formats="['%d-%m-%Y]", | ||||
|         input_time_formats="['%H:%M']", | ||||
|         widget=custom_datetime_widget, | ||||
|     ) | ||||
|     timestamp_end = forms.SplitDateTimeField( | ||||
|         input_date_formats="['%d-%m-%Y]", | ||||
|         input_time_formats="['%H:%M']", | ||||
|         widget=custom_datetime_widget, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Session | ||||
|         fields = [ | ||||
| @ -13,11 +31,27 @@ class SessionForm(forms.ModelForm): | ||||
|             "note", | ||||
|         ] | ||||
|  | ||||
|         # fields_classes = { | ||||
|         #     "timestamp_start": custom_datetime_field, | ||||
|         #     "timestamp_end": custom_datetime_field, | ||||
|         # } | ||||
|         # widgets = { | ||||
|         #     "timestamp_start": custom_datetime_widget, | ||||
|         #     "timestamp_end": custom_datetime_widget, | ||||
|         # } | ||||
|  | ||||
|  | ||||
| class PurchaseForm(forms.ModelForm): | ||||
|     class Meta: | ||||
|         model = Purchase | ||||
|         fields = ["game", "platform", "date_purchased", "date_refunded"] | ||||
|         custom_date_widget = forms.DateInput( | ||||
|             format=("%d-%m-%Y"), attrs={"type": "date"} | ||||
|         ) | ||||
|         widgets = { | ||||
|             "date_purchased": custom_date_widget, | ||||
|             "date_refunded": custom_date_widget, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class GameForm(forms.ModelForm): | ||||
|  | ||||
| @ -1,7 +1,7 @@ | ||||
| # Generated by Django 4.1.4 on 2023-01-02 18:27 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
| @ -1,6 +1,7 @@ | ||||
| # Generated by Django 4.1.4 on 2023-01-02 18:55 | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -0,0 +1,22 @@ | ||||
| # Generated by Django 4.1.5 on 2023-01-09 14:49 | ||||
|  | ||||
| import datetime | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("tracker", "0003_alter_session_duration_manual_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="session", | ||||
|             name="duration_manual", | ||||
|             field=models.DurationField( | ||||
|                 blank=True, default=datetime.timedelta(0), null=True | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										35
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/web/tracker/migrations/0005_auto_20230109_1843.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| # Generated by Django 4.1.5 on 2023-01-09 17:43 | ||||
|  | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def set_duration_calculated_none_to_zero(apps, schema_editor): | ||||
|     Session = apps.get_model("tracker", "Session") | ||||
|     for session in Session.objects.all(): | ||||
|         if session.duration_calculated == None: | ||||
|             session.duration_calculated = timedelta(0) | ||||
|             session.save() | ||||
|  | ||||
|  | ||||
| def revert_set_duration_calculated_none_to_zero(apps, schema_editor): | ||||
|     Session = apps.get_model("tracker", "Session") | ||||
|     for session in Session.objects.all(): | ||||
|         if session.duration_calculated == timedelta(0): | ||||
|             session.duration_calculated = None | ||||
|             session.save() | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("tracker", "0004_alter_session_duration_manual"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             set_duration_calculated_none_to_zero, | ||||
|             revert_set_duration_calculated_none_to_zero, | ||||
|         ) | ||||
|     ] | ||||
							
								
								
									
										35
									
								
								src/web/tracker/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/web/tracker/migrations/0006_auto_20230109_1904.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,35 @@ | ||||
| # Generated by Django 4.1.5 on 2023-01-09 18:04 | ||||
|  | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def set_duration_manual_none_to_zero(apps, schema_editor): | ||||
|     Session = apps.get_model("tracker", "Session") | ||||
|     for session in Session.objects.all(): | ||||
|         if session.duration_manual == None: | ||||
|             session.duration_manual = timedelta(0) | ||||
|             session.save() | ||||
|  | ||||
|  | ||||
| def revert_set_duration_manual_none_to_zero(apps, schema_editor): | ||||
|     Session = apps.get_model("tracker", "Session") | ||||
|     for session in Session.objects.all(): | ||||
|         if session.duration_manual == timedelta(0): | ||||
|             session.duration_manual = None | ||||
|             session.save() | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("tracker", "0005_auto_20230109_1843"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython( | ||||
|             set_duration_manual_none_to_zero, | ||||
|             revert_set_duration_manual_none_to_zero, | ||||
|         ) | ||||
|     ] | ||||
| @ -1,5 +1,11 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| from common.util.time import format_duration | ||||
| from django.conf import settings | ||||
| from django.db import models | ||||
| from datetime import timedelta | ||||
| from django.db.models import F, Manager, Sum | ||||
|  | ||||
|  | ||||
| class Game(models.Model): | ||||
| @ -28,46 +34,58 @@ class Platform(models.Model): | ||||
|         return self.name | ||||
|  | ||||
|  | ||||
| class SessionQuerySet(models.QuerySet): | ||||
|     def total_duration(self): | ||||
|         result = self.aggregate( | ||||
|             duration=Sum(F("duration_calculated") + F("duration_manual")) | ||||
|         ) | ||||
|         return format_duration(result["duration"]) | ||||
|  | ||||
|  | ||||
| class Session(models.Model): | ||||
|     purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) | ||||
|     timestamp_start = models.DateTimeField() | ||||
|     timestamp_end = models.DateTimeField(blank=True, null=True) | ||||
|     duration_manual = models.DurationField(blank=True, null=True) | ||||
|     duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) | ||||
|     duration_calculated = models.DurationField(blank=True, null=True) | ||||
|     note = models.TextField(blank=True, null=True) | ||||
|  | ||||
|     objects = SessionQuerySet.as_manager() | ||||
|  | ||||
|     def __str__(self): | ||||
|         mark = ", manual" if self.duration_manual != None else "" | ||||
|         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_any()}{mark})" | ||||
|         return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" | ||||
|  | ||||
|     def duration_seconds(self): | ||||
|         if self.timestamp_end == None or self.timestamp_start == None: | ||||
|             if self.duration_manual == None: | ||||
|                 return 0 | ||||
|             else: | ||||
|                 value = self.duration_manual | ||||
|         else: | ||||
|             value = self.timestamp_end - self.timestamp_start | ||||
|         return value.total_seconds() | ||||
|     def finish_now(self): | ||||
|         self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||
|  | ||||
|     def duration_formatted(self): | ||||
|         seconds = self.duration_seconds() | ||||
|         if seconds == 0: | ||||
|             return seconds | ||||
|         hours, remainder = divmod(seconds, 3600) | ||||
|         minutes = remainder % 60 | ||||
|         hour_string = f"{int(hours)}h" if hours != 0 else "" | ||||
|         minute_string = f"{int(minutes)}m" if minutes != 0 else "" | ||||
|         return f"{hour_string}{minute_string}" | ||||
|     def start_now(): | ||||
|         self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||
|  | ||||
|     def duration_any(self): | ||||
|         return ( | ||||
|             self.duration_formatted() | ||||
|             if self.duration_manual == None | ||||
|             else self.duration_manual | ||||
|         ) | ||||
|     def duration_seconds(self) -> timedelta: | ||||
|         manual = timedelta(0) | ||||
|         calculated = timedelta(0) | ||||
|         if not self.duration_manual in (None, 0, timedelta(0)): | ||||
|             manual = self.duration_manual | ||||
|         if self.timestamp_end != None and self.timestamp_start != None: | ||||
|             calculated = self.timestamp_end - self.timestamp_start | ||||
|         return timedelta(seconds=(manual + calculated).total_seconds()) | ||||
|  | ||||
|     def duration_formatted(self) -> str: | ||||
|         result = format_duration(self.duration_seconds(), "%H:%m") | ||||
|         return result | ||||
|  | ||||
|     @property | ||||
|     def duration_sum(self) -> str: | ||||
|         return Session.objects.all().total_duration() | ||||
|  | ||||
|     @property | ||||
|     def last(self) -> Manager[Any]: | ||||
|         return Session.objects.all().order_by("timestamp_start")[0] | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         if self.timestamp_start != None and self.timestamp_end != None: | ||||
|             self.duration_calculated = self.timestamp_end - self.timestamp_start | ||||
|         else: | ||||
|             self.duration_calculated = timedelta(0) | ||||
|         super(Session, self).save(*args, **kwargs) | ||||
|  | ||||
| @ -683,34 +683,66 @@ select { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .\!container { | ||||
|   width: 100% !important; | ||||
| } | ||||
|  | ||||
| @media (min-width: 640px) { | ||||
|   .container { | ||||
|     max-width: 640px; | ||||
|   } | ||||
|  | ||||
|   .\!container { | ||||
|     max-width: 640px !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 768px) { | ||||
|   .container { | ||||
|     max-width: 768px; | ||||
|   } | ||||
|  | ||||
|   .\!container { | ||||
|     max-width: 768px !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1024px) { | ||||
|   .container { | ||||
|     max-width: 1024px; | ||||
|   } | ||||
|  | ||||
|   .\!container { | ||||
|     max-width: 1024px !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1280px) { | ||||
|   .container { | ||||
|     max-width: 1280px; | ||||
|   } | ||||
|  | ||||
|   .\!container { | ||||
|     max-width: 1280px !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media (min-width: 1536px) { | ||||
|   .container { | ||||
|     max-width: 1536px; | ||||
|   } | ||||
|  | ||||
|   .\!container { | ||||
|     max-width: 1536px !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .visible { | ||||
|   visibility: visible; | ||||
| } | ||||
|  | ||||
| .collapse { | ||||
|   visibility: collapse; | ||||
| } | ||||
|  | ||||
| .static { | ||||
| @ -721,6 +753,26 @@ select { | ||||
|   position: fixed; | ||||
| } | ||||
|  | ||||
| .\!fixed { | ||||
|   position: fixed !important; | ||||
| } | ||||
|  | ||||
| .absolute { | ||||
|   position: absolute; | ||||
| } | ||||
|  | ||||
| .relative { | ||||
|   position: relative; | ||||
| } | ||||
|  | ||||
| .sticky { | ||||
|   position: sticky; | ||||
| } | ||||
|  | ||||
| .\!sticky { | ||||
|   position: sticky !important; | ||||
| } | ||||
|  | ||||
| .left-2 { | ||||
|   left: 0.5rem; | ||||
| } | ||||
| @ -734,6 +786,11 @@ select { | ||||
|   margin-right: auto; | ||||
| } | ||||
|  | ||||
| .my-5 { | ||||
|   margin-top: 1.25rem; | ||||
|   margin-bottom: 1.25rem; | ||||
| } | ||||
|  | ||||
| .mb-4 { | ||||
|   margin-bottom: 1rem; | ||||
| } | ||||
| @ -742,16 +799,76 @@ select { | ||||
|   margin-top: 1rem; | ||||
| } | ||||
|  | ||||
| .mb-3 { | ||||
|   margin-bottom: 0.75rem; | ||||
| } | ||||
|  | ||||
| .ml-1 { | ||||
|   margin-left: 0.25rem; | ||||
| } | ||||
|  | ||||
| .mt-5 { | ||||
|   margin-top: 1.25rem; | ||||
| } | ||||
|  | ||||
| .mb-2 { | ||||
|   margin-bottom: 0.5rem; | ||||
| } | ||||
|  | ||||
| .mt-10 { | ||||
|   margin-top: 2.5rem; | ||||
| } | ||||
|  | ||||
| .block { | ||||
|   display: block; | ||||
| } | ||||
|  | ||||
| .inline-block { | ||||
|   display: inline-block; | ||||
| } | ||||
|  | ||||
| .inline { | ||||
|   display: inline; | ||||
| } | ||||
|  | ||||
| .flex { | ||||
|   display: flex; | ||||
| } | ||||
|  | ||||
| .grid { | ||||
|   display: grid; | ||||
| .table { | ||||
|   display: table; | ||||
| } | ||||
|  | ||||
| .table-caption { | ||||
|   display: table-caption; | ||||
| } | ||||
|  | ||||
| .table-cell { | ||||
|   display: table-cell; | ||||
| } | ||||
|  | ||||
| .contents { | ||||
|   display: contents; | ||||
| } | ||||
|  | ||||
| .hidden { | ||||
|   display: none; | ||||
| } | ||||
|  | ||||
| .\!hidden { | ||||
|   display: none !important; | ||||
| } | ||||
|  | ||||
| .h-6 { | ||||
|   height: 1.5rem; | ||||
| } | ||||
|  | ||||
| .h-5 { | ||||
|   height: 1.25rem; | ||||
| } | ||||
|  | ||||
| .h-4 { | ||||
|   height: 1rem; | ||||
| } | ||||
|  | ||||
| .min-h-screen { | ||||
| @ -762,12 +879,28 @@ select { | ||||
|   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-width: 1024px; | ||||
| } | ||||
|  | ||||
| .grid-cols-4 { | ||||
|   grid-template-columns: repeat(4, minmax(0, 1fr)); | ||||
| .resize { | ||||
|   resize: both; | ||||
| } | ||||
|  | ||||
| .flex-col { | ||||
| @ -782,6 +915,10 @@ select { | ||||
|   align-items: center; | ||||
| } | ||||
|  | ||||
| .justify-end { | ||||
|   justify-content: flex-end; | ||||
| } | ||||
|  | ||||
| .justify-center { | ||||
|   justify-content: center; | ||||
| } | ||||
| @ -798,6 +935,20 @@ select { | ||||
|   align-self: center; | ||||
| } | ||||
|  | ||||
| .overflow-hidden { | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .truncate { | ||||
|   overflow: hidden; | ||||
|   text-overflow: ellipsis; | ||||
|   white-space: nowrap; | ||||
| } | ||||
|  | ||||
| .text-ellipsis { | ||||
|   text-overflow: ellipsis; | ||||
| } | ||||
|  | ||||
| .whitespace-nowrap { | ||||
|   white-space: nowrap; | ||||
| } | ||||
| @ -806,6 +957,10 @@ select { | ||||
|   border-radius: 0.25rem; | ||||
| } | ||||
|  | ||||
| .rounded-lg { | ||||
|   border-radius: 0.5rem; | ||||
| } | ||||
|  | ||||
| .rounded-xl { | ||||
|   border-radius: 0.75rem; | ||||
| } | ||||
| @ -819,24 +974,24 @@ select { | ||||
|   border-color: rgb(229 231 235 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .border-red-800 { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(153 27 27 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .border-red-900 { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(127 29 29 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-white { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(255 255 255 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .bg-red-700 { | ||||
| .bg-green-600 { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(185 28 28 / var(--tw-bg-opacity)); | ||||
|   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 { | ||||
| @ -847,15 +1002,21 @@ select { | ||||
|   padding: 0.5rem; | ||||
| } | ||||
|  | ||||
| .p-1 { | ||||
|   padding: 0.25rem; | ||||
| } | ||||
|  | ||||
| .py-2 { | ||||
|   padding-top: 0.5rem; | ||||
|   padding-bottom: 0.5rem; | ||||
| } | ||||
|  | ||||
| .py-1 { | ||||
|   padding-top: 0.25rem; | ||||
|   padding-bottom: 0.25rem; | ||||
| } | ||||
|  | ||||
| .px-2 { | ||||
|   padding-left: 0.5rem; | ||||
|   padding-right: 0.5rem; | ||||
| } | ||||
|  | ||||
| .pl-3 { | ||||
|   padding-left: 0.75rem; | ||||
| } | ||||
| @ -868,6 +1029,10 @@ select { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| .text-right { | ||||
|   text-align: right; | ||||
| } | ||||
|  | ||||
| .text-4xl { | ||||
|   font-size: 2.25rem; | ||||
|   line-height: 2.5rem; | ||||
| @ -883,20 +1048,28 @@ select { | ||||
|   line-height: 1rem; | ||||
| } | ||||
|  | ||||
| .text-base { | ||||
|   font-size: 1rem; | ||||
|   line-height: 1.5rem; | ||||
| } | ||||
|  | ||||
| .text-lg { | ||||
|   font-size: 1.125rem; | ||||
|   line-height: 1.75rem; | ||||
| } | ||||
|  | ||||
| .text-sm { | ||||
|   font-size: 0.875rem; | ||||
|   line-height: 1.25rem; | ||||
| } | ||||
|  | ||||
| .font-semibold { | ||||
|   font-weight: 600; | ||||
| } | ||||
|  | ||||
| .uppercase { | ||||
|   text-transform: uppercase; | ||||
| } | ||||
|  | ||||
| .lowercase { | ||||
|   text-transform: lowercase; | ||||
| } | ||||
|  | ||||
| .text-white { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(255 255 255 / var(--tw-text-opacity)); | ||||
| @ -907,18 +1080,75 @@ select { | ||||
|   color: rgb(203 213 225 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-red-400 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(248 113 113 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .text-red-700 { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(185 28 28 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .shadow-md { | ||||
|   --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); | ||||
| } | ||||
|  | ||||
| .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); | ||||
| } | ||||
|  | ||||
| .blur { | ||||
|   --tw-blur: blur(8px); | ||||
|   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); | ||||
| } | ||||
|  | ||||
| .invert { | ||||
|   --tw-invert: invert(100%); | ||||
|   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); | ||||
| } | ||||
|  | ||||
| .\!invert { | ||||
|   --tw-invert: invert(100%) !important; | ||||
|   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) !important; | ||||
| } | ||||
|  | ||||
| .filter { | ||||
|   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); | ||||
| } | ||||
|  | ||||
| .\!filter { | ||||
|   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) !important; | ||||
| } | ||||
|  | ||||
| .transition { | ||||
|   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-timing-function: cubic-bezier(0.4, 0, 0.2, 1); | ||||
|   transition-duration: 150ms; | ||||
| } | ||||
|  | ||||
| .duration-200 { | ||||
|   transition-duration: 200ms; | ||||
| } | ||||
|  | ||||
| .ease-in { | ||||
|   transition-timing-function: cubic-bezier(0.4, 0, 1, 1); | ||||
| } | ||||
|  | ||||
| .dark form label { | ||||
|   --tw-text-opacity: 1; | ||||
|   color: rgb(148 163 184 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| .dark form input,.dark select,.dark textarea { | ||||
| .dark form input,.dark  | ||||
| select,.dark  | ||||
| textarea { | ||||
|   border-width: 1px; | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(15 23 42 / var(--tw-border-opacity)); | ||||
| @ -928,45 +1158,69 @@ select { | ||||
|   color: rgb(241 245 249 / var(--tw-text-opacity)); | ||||
| } | ||||
|  | ||||
| form input[type=submit] { | ||||
| #session-table { | ||||
|   display: grid; | ||||
|   grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr; | ||||
| } | ||||
|  | ||||
| #button-container button { | ||||
|   margin-left: 0.25rem; | ||||
|   margin-right: 0.25rem; | ||||
| } | ||||
|  | ||||
| .hover\:bg-green-700:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(88 28 135 / var(--tw-bg-opacity)); | ||||
|   padding: 0.5rem; | ||||
|   background-color: rgb(21 128 61 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:border-dotted:hover { | ||||
|   border-style: dotted; | ||||
| } | ||||
|  | ||||
| .hover\:border-white:hover { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(255 255 255 / var(--tw-border-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-red-600:hover { | ||||
| .hover\:bg-blue-700:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(220 38 38 / var(--tw-bg-opacity)); | ||||
|   background-color: rgb(29 78 216 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-red-500:hover { | ||||
| .hover\:bg-red-700:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(239 68 68 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-yellow-700:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(161 98 7 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:bg-orange-700:hover { | ||||
|   --tw-bg-opacity: 1; | ||||
|   background-color: rgb(194 65 12 / var(--tw-bg-opacity)); | ||||
|   background-color: rgb(185 28 28 / var(--tw-bg-opacity)); | ||||
| } | ||||
|  | ||||
| .hover\:underline:hover { | ||||
|   text-decoration-line: underline; | ||||
| } | ||||
|  | ||||
| .focus\:outline-none:focus { | ||||
|   outline: 2px solid transparent; | ||||
|   outline-offset: 2px; | ||||
| } | ||||
|  | ||||
| .focus\:ring-2:focus { | ||||
|   --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(2px + 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); | ||||
| } | ||||
|  | ||||
| .focus\:ring-green-500:focus { | ||||
|   --tw-ring-opacity: 1; | ||||
|   --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 { | ||||
|   --tw-ring-offset-width: 2px; | ||||
| } | ||||
|  | ||||
| .focus\:ring-offset-blue-200:focus { | ||||
|   --tw-ring-offset-color: #bfdbfe; | ||||
| } | ||||
|  | ||||
| .dark .dark\:border-white { | ||||
|   --tw-border-opacity: 1; | ||||
|   border-color: rgb(255 255 255 / var(--tw-border-opacity)); | ||||
|  | ||||
| @ -1,13 +1,13 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block title %}{{ title }}{% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
| <form method="POST" enctype="multipart/form-data" class="mx-auto"> | ||||
|     {% csrf_token %} | ||||
|     <form method="post" enctype="multipart/form-data" class="mx-auto"> | ||||
|         {% csrf_token %} | ||||
|  | ||||
|     {{ form.as_p }} | ||||
|         {{ form.as_p }} | ||||
|  | ||||
|     <input type="submit" value="Submit"> | ||||
| </form> | ||||
|         <input type="submit" value="Submit"/> | ||||
|     </form> | ||||
| {% endblock content %} | ||||
| @ -1,13 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block title %}Add New Purchase{% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
| <form method="POST" enctype="multipart/form-data" class="mx-auto"> | ||||
|     {% csrf_token %} | ||||
|  | ||||
|     {{ form.as_p }} | ||||
|  | ||||
|     <input type="submit" value="Submit"> | ||||
| </form> | ||||
| {% endblock content %} | ||||
| @ -1,13 +0,0 @@ | ||||
| {% extends 'base.html' %} | ||||
|  | ||||
| {% block title %}Add New Session{% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
| <form method="POST" enctype="multipart/form-data" class="mx-auto"> | ||||
|     {% csrf_token %} | ||||
|  | ||||
|     {{ form.as_p }} | ||||
|  | ||||
|     <input type="submit" value="Submit"> | ||||
| </form> | ||||
| {% endblock content %} | ||||
| @ -1,46 +1,48 @@ | ||||
| <!doctype html> | ||||
| <html lang="en"> | ||||
|  | ||||
| {% load static %} | ||||
|     {% load static %} | ||||
|  | ||||
| <head> | ||||
|     <meta charset="utf-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|     <title>Timetracker - {% block title %}Untitled{% endblock title %}</title> | ||||
|     <link rel="stylesheet" href="https://rsms.me/inter/inter.css"> | ||||
|     <link rel="stylesheet" href="{% static 'base.css' %}" /> | ||||
| </head> | ||||
|     <head> | ||||
|         <meta charset="utf-8"/> | ||||
|         <meta name="description" content="Self-hosted time-tracker."/> | ||||
|         <meta name="keywords" content="time, tracking, video games, self-hosted"/> | ||||
|         <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||||
|         <title>Timetracker - {% block title %}Untitled{% endblock title %}</title> | ||||
|         <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/> | ||||
|         <link rel="stylesheet" href="{% static 'base.css' %}" /> | ||||
|     </head> | ||||
|  | ||||
| <body class="dark"> | ||||
|     <div class="dark:bg-gray-800 min-h-screen"> | ||||
|         <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> | ||||
|             <div class="container flex flex-wrap items-center justify-between mx-auto"> | ||||
|                 <a href="{% url 'index' %}" class="flex items-center"> | ||||
|                     <span class="text-4xl">⌚</span> | ||||
|                     <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> | ||||
|                 </a> | ||||
|                 <div class="w-full md:block md:w-auto"> | ||||
|                     <ul | ||||
|                         class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> | ||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> | ||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> | ||||
|                         {% if game_available and platform_available %} | ||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> | ||||
|                         {% endif %} | ||||
|                         {% if purchase_available %} | ||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> | ||||
|                         {% endif %} | ||||
|                         {% if session_count > 0 %} | ||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> | ||||
|                         {% endif %} | ||||
|                     </ul> | ||||
|     <body class="dark"> | ||||
|         <div class="dark:bg-gray-800 min-h-screen"> | ||||
|             <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> | ||||
|                 <div class="container flex flex-wrap items-center justify-between mx-auto"> | ||||
|                     <a href="{% url 'index' %}" class="flex items-center"> | ||||
|                         <span class="text-4xl">⌚</span> | ||||
|                         <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> | ||||
|                     </a> | ||||
|                     <div class="w-full md:block md:w-auto"> | ||||
|                         <ul | ||||
|                             class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> | ||||
|                             <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> | ||||
|                             <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> | ||||
|                             {% if game_available and platform_available %} | ||||
|                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> | ||||
|                             {% endif %} | ||||
|                             {% if purchase_available %} | ||||
|                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> | ||||
|                             {% endif %} | ||||
|                             {% if session_count > 0 %} | ||||
|                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> | ||||
|                             {% endif %} | ||||
|                         </ul> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </div> | ||||
|         </nav> | ||||
|         {% block content %}No content here.{% endblock %} | ||||
|     </div> | ||||
|     {% load version %} | ||||
|     <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> | ||||
| </body> | ||||
|             </nav> | ||||
|             {% block content %}No content here.{% endblock content %} | ||||
|         </div> | ||||
|         {% load version %} | ||||
|         <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> | ||||
|     </body> | ||||
|  | ||||
| </html> | ||||
| @ -1,13 +1,17 @@ | ||||
| {% extends 'base.html' %} | ||||
| {% extends "base.html" %} | ||||
|  | ||||
| {% block title %}{{ title }}{% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
| <div class="text-slate-300 mx-auto max-w-screen-lg text-center"> | ||||
| {% if session_count > 0 %} | ||||
| You have played a total of {{ session_count }} sessions. | ||||
| {% else %} | ||||
| Start by clicking the links at the top. To track playtime, you need to have at least 1 owned game. | ||||
| {% endif %} | ||||
| </div> | ||||
|     <div class="text-slate-300 mx-auto max-w-screen-lg text-center"> | ||||
|         {% if session_count > 0 %} | ||||
|             You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}. | ||||
|         {% elif not game_available or not platform_available %} | ||||
|             There are no games in the database. Start by clicking "New Game" and "New Platform". | ||||
|         {% elif not purchase_available %} | ||||
|             There are no owned games. Click "New Purchase" at the top. | ||||
|         {% else %} | ||||
|             You haven't played any games yet. Click "New Session" to add one now. | ||||
|         {% endif %} | ||||
|     </div> | ||||
| {% endblock content %} | ||||
| @ -3,30 +3,79 @@ | ||||
| {% block title %}Sessions{% endblock title %} | ||||
|  | ||||
| {% block content %} | ||||
| {% if purchase %} | ||||
| <div class="text-center text-xl mb-4 dark:text-slate-400"> | ||||
|     <h1>Listing sessions only for purchase "{{ purchase }}"</h1> | ||||
|     <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a> | ||||
| </div> | ||||
| {% endif %} | ||||
| <div class="grid grid-cols-4 gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center"> | ||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Name</div> | ||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Start</div> | ||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">End</div> | ||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> | ||||
|     {% for data in dataset %} | ||||
|     <div class=""><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div> | ||||
|     <div class="dark:text-slate-400">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> | ||||
|     <div class="dark:text-slate-400"> | ||||
|         {% if data.unfinished %} | ||||
|         Not finished yet. <button class="bg-red-700 hover:bg-orange-700 border border-red-900 hover:border-dotted hover:border-white rounded p-1 text-white text-sm">Finish now?</button> | ||||
|         {% elif data.duration_manual %} | ||||
|         MANUAL | ||||
|         {% else %} | ||||
|         {{ data.timestamp_end | date:"d/m/Y H:i" }} | ||||
|         {% endif %} | ||||
|         <div class="text-center text-xl mb-4 dark:text-slate-400"> | ||||
|             {% if dataset.count >= 2 %} | ||||
|             <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" /> | ||||
|             {% endif %} | ||||
|             {% if dataset.count >= 1 %} | ||||
|             <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> | ||||
|             {% endif %} | ||||
|             {% if purchase or platform or game %} | ||||
|             <a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline"> | ||||
|                 <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | ||||
|               </svg> | ||||
|               </a><span>Filtering by "{% firstof purchase platform game %}"</span> | ||||
|             {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %} | ||||
|             {% endif %} | ||||
|             {% if dataset.count >= 1 %} | ||||
|             <a class="block" href="{% url 'start_session' dataset.last.purchase.id %}"> | ||||
|                 <button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg "> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline"> | ||||
|                         <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" /> | ||||
|                     </svg> | ||||
|                     {{ last.purchase }} | ||||
|                 </button> | ||||
|             </a> | ||||
|             {% endif %} | ||||
|         </div> | ||||
|     <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center"> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg">Name</div> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg">Platform</div> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> | ||||
|         <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div> | ||||
|         {% for data in dataset %} | ||||
|             <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.game }}</a></div> | ||||
|             <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div> | ||||
|             <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> | ||||
|             <div class="dark:text-slate-400 text-center"> | ||||
|                 {% if data.unfinished %} | ||||
|                     <span class="text-red-400">Not finished yet.</span> | ||||
|                 {% elif data.duration_manual %} | ||||
|                     -- | ||||
|                 {% else %} | ||||
|                     {{ data.timestamp_end | date:"d/m/Y H:i" }} | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             <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"> | ||||
|                 <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" /> | ||||
|               </svg> | ||||
|               {% endif %}</div> | ||||
|             <div id="button-container" class="flex justify-end"> | ||||
|                 {% if data.unfinished %} | ||||
|                     <a href="{% url 'update_session' data.id %}"> | ||||
|                         <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"> | ||||
|                                 <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" /> | ||||
|                             </svg> | ||||
|                         </button> | ||||
|                     </a> | ||||
|                 {% endif %} | ||||
|                 <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center  bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg "> | ||||
|                     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> | ||||
|                         <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" /> | ||||
|                         <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" /> | ||||
|                     </svg> | ||||
|                 </button> | ||||
|                 <a href="{% url '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> | ||||
|     <div class="dark:text-slate-400">{{ data.duration_formatted }}{% if data.duration_manual %} (M){% endif %}</div> | ||||
|     {% endfor %} | ||||
| </div> | ||||
| {% endblock content %} | ||||
| @ -1,6 +1,8 @@ | ||||
| from django import template | ||||
| import time | ||||
| import os | ||||
| import time | ||||
|  | ||||
| from django import template | ||||
| from django.conf import settings | ||||
|  | ||||
| register = template.Library() | ||||
|  | ||||
| @ -9,7 +11,11 @@ register = template.Library() | ||||
| def version_date(): | ||||
|     return time.strftime( | ||||
|         "%d-%b-%Y %H:%m", | ||||
|         time.gmtime(os.path.getmtime(os.path.abspath(os.path.join(".git")))), | ||||
|         time.gmtime( | ||||
|             os.path.getmtime( | ||||
|                 os.path.abspath(os.path.join(settings.BASE_DIR, "..", "..", ".git")) | ||||
|             ) | ||||
|         ), | ||||
|     ) | ||||
|  | ||||
|  | ||||
|  | ||||
| @ -7,11 +7,39 @@ urlpatterns = [ | ||||
|     path("add-game/", views.add_game, name="add_game"), | ||||
|     path("add-platform/", views.add_platform, name="add_platform"), | ||||
|     path("add-session/", views.add_session, name="add_session"), | ||||
|     path( | ||||
|         "update-session/by-session/<int:session_id>", | ||||
|         views.update_session, | ||||
|         name="update_session", | ||||
|     ), | ||||
|     path( | ||||
|         "start-session/<int:purchase_id>", | ||||
|         views.start_session, | ||||
|         name="start_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("list-sessions/", views.list_sessions, name="list_sessions"), | ||||
|     path( | ||||
|         "list-sessions/by-purchase/<int:purchase_id>", | ||||
|         views.list_sessions, | ||||
|         name="list_sessions", | ||||
|         {"filter": "purchase"}, | ||||
|         name="list_sessions_by_purchase", | ||||
|     ), | ||||
|     path( | ||||
|         "list-sessions/by-platform/<int:platform_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "platform"}, | ||||
|         name="list_sessions_by_platform", | ||||
|     ), | ||||
|     path( | ||||
|         "list-sessions/by-game/<int:game_id>", | ||||
|         views.list_sessions, | ||||
|         {"filter": "game"}, | ||||
|         name="list_sessions_by_game", | ||||
|     ), | ||||
| ] | ||||
|  | ||||
| @ -1,10 +1,13 @@ | ||||
| from django.shortcuts import render | ||||
|  | ||||
| from .models import Game, Platform, Purchase, Session | ||||
| from .forms import SessionForm, PurchaseForm, GameForm, PlatformForm | ||||
| from datetime import datetime | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| from common.util.plots import playtime_over_time_chart | ||||
| from common.util.time import now as now_with_tz | ||||
| from django.conf import settings | ||||
| from django.shortcuts import redirect, render | ||||
|  | ||||
| from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm | ||||
| from .models import Game, Platform, Purchase, Session | ||||
|  | ||||
|  | ||||
| def model_counts(request): | ||||
| @ -18,31 +21,67 @@ def model_counts(request): | ||||
|  | ||||
| def add_session(request): | ||||
|     context = {} | ||||
|     now = datetime.now() | ||||
|     initial = {"timestamp_start": now, "timestamp_end": now} | ||||
|     now = now_with_tz() | ||||
|     last = Session.objects.all().last() | ||||
|     initial = { | ||||
|         "timestamp_start_0": now.date(), | ||||
|         "timestamp_start_1": now.time(), | ||||
|         "purchase": last.purchase, | ||||
|     } | ||||
|     form = SessionForm(request.POST or None, initial=initial) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("list_sessions") | ||||
|  | ||||
|     context["title"] = "Add New Session" | ||||
|     context["form"] = form | ||||
|     return render(request, "add_session.html", context) | ||||
|     return render(request, "add.html", context) | ||||
|  | ||||
|  | ||||
| def list_sessions(request, purchase_id=None): | ||||
| def update_session(request, session_id=None): | ||||
|     session = Session.objects.get(id=session_id) | ||||
|     session.finish_now() | ||||
|     session.save() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| def start_session(request, purchase_id=None): | ||||
|     session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()}) | ||||
|     session.save() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| def delete_session(request, session_id=None): | ||||
|     session = Session.objects.get(id=session_id) | ||||
|     session.delete() | ||||
|     return redirect("list_sessions") | ||||
|  | ||||
|  | ||||
| def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""): | ||||
|     context = {} | ||||
|  | ||||
|     if purchase_id != None: | ||||
|     if filter == "purchase": | ||||
|         dataset = Session.objects.filter(purchase=purchase_id) | ||||
|         context["purchase"] = Purchase.objects.get(id=purchase_id) | ||||
|     elif filter == "platform": | ||||
|         dataset = Session.objects.filter(purchase__platform=platform_id) | ||||
|         context["platform"] = Platform.objects.get(id=platform_id) | ||||
|     elif filter == "game": | ||||
|         dataset = Session.objects.filter(purchase__game=game_id) | ||||
|         context["game"] = Game.objects.get(id=game_id) | ||||
|     else: | ||||
|         dataset = Session.objects.all() | ||||
|         dataset = Session.objects.all().order_by("-timestamp_start") | ||||
|  | ||||
|     for session in dataset: | ||||
|         if session.timestamp_end == None and session.duration_manual == None: | ||||
|         if session.timestamp_end == None and session.duration_manual.seconds == 0: | ||||
|             session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||
|             session.unfinished = True | ||||
|  | ||||
|     context["total_duration"] = dataset.total_duration() | ||||
|     context["dataset"] = dataset | ||||
|     context["last"] = Session.objects.all().last() | ||||
|     # charts are always oldest->newest | ||||
|     context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start")) | ||||
|  | ||||
|     return render(request, "list_sessions.html", context) | ||||
|  | ||||
| @ -54,6 +93,7 @@ def add_purchase(request): | ||||
|     form = PurchaseForm(request.POST or None, initial=initial) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Purchase" | ||||
| @ -65,6 +105,7 @@ def add_game(request): | ||||
|     form = GameForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Game" | ||||
| @ -76,6 +117,7 @@ def add_platform(request): | ||||
|     form = PlatformForm(request.POST or None) | ||||
|     if form.is_valid(): | ||||
|         form.save() | ||||
|         return redirect("index") | ||||
|  | ||||
|     context["form"] = form | ||||
|     context["title"] = "Add New Platform" | ||||
| @ -84,4 +126,6 @@ def add_platform(request): | ||||
|  | ||||
| def index(request): | ||||
|     context = {} | ||||
|     context["total_duration"] = Session().duration_sum | ||||
|     context["title"] = "Index" | ||||
|     return render(request, "index.html", context) | ||||
|  | ||||
| @ -10,9 +10,8 @@ For the full list of settings and their values, see | ||||
| https://docs.djangoproject.com/en/4.1/ref/settings/ | ||||
| """ | ||||
|  | ||||
| from pathlib import Path | ||||
| import logging | ||||
| import os | ||||
| from pathlib import Path | ||||
|  | ||||
| # Build paths inside the project like this: BASE_DIR / 'subdir'. | ||||
| BASE_DIR = Path(__file__).resolve().parent.parent | ||||
| @ -25,7 +24,7 @@ BASE_DIR = Path(__file__).resolve().parent.parent | ||||
| SECRET_KEY = "django-insecure-x0_t$gei=_o_p(%%!-db$jezka@y+d67$a8tvw13nl^8$l*t@=" | ||||
|  | ||||
| # SECURITY WARNING: don't run with debug turned on in production! | ||||
| DEBUG = True | ||||
| DEBUG = False if os.environ.get("PROD") else True | ||||
|  | ||||
| ALLOWED_HOSTS = ["*"] | ||||
|  | ||||
| @ -34,7 +33,6 @@ ALLOWED_HOSTS = ["*"] | ||||
|  | ||||
| INSTALLED_APPS = [ | ||||
|     "tracker.apps.TrackerConfig", | ||||
|     "django.contrib.admin", | ||||
|     "django.contrib.auth", | ||||
|     "django.contrib.contenttypes", | ||||
|     "django.contrib.sessions", | ||||
| @ -42,6 +40,10 @@ INSTALLED_APPS = [ | ||||
|     "django.contrib.staticfiles", | ||||
| ] | ||||
|  | ||||
| if DEBUG: | ||||
|     INSTALLED_APPS.append("django_extensions") | ||||
|     INSTALLED_APPS.append("django.contrib.admin") | ||||
|  | ||||
| MIDDLEWARE = [ | ||||
|     "django.middleware.security.SecurityMiddleware", | ||||
|     "django.contrib.sessions.middleware.SessionMiddleware", | ||||
| @ -109,7 +111,7 @@ AUTH_PASSWORD_VALIDATORS = [ | ||||
|  | ||||
| LANGUAGE_CODE = "en-us" | ||||
|  | ||||
| TIME_ZONE = os.environ.get("TZ", "UTC") | ||||
| TIME_ZONE = "Europe/Prague" if DEBUG else os.environ.get("TZ", "UTC") | ||||
|  | ||||
| USE_I18N = True | ||||
|  | ||||
| @ -120,6 +122,7 @@ USE_TZ = True | ||||
| # https://docs.djangoproject.com/en/4.1/howto/static-files/ | ||||
|  | ||||
| STATIC_URL = "static/" | ||||
| STATIC_ROOT = BASE_DIR / "static" | ||||
|  | ||||
| # Default primary key field type | ||||
| # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field | ||||
| @ -130,8 +133,19 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" | ||||
| LOGGING = { | ||||
|     "version": 1, | ||||
|     "disable_existing_loggers": False, | ||||
|     "handlers": {"console": {"class": "logging.StreamHandler"}}, | ||||
|     "root": {"handlers": ["console"], "level": "WARNING"}, | ||||
|     "handlers": { | ||||
|         "console": {"class": "logging.StreamHandler"}, | ||||
|     }, | ||||
|     "loggers": { | ||||
|         "django": { | ||||
|             "handlers": ["console"], | ||||
|             "level": "INFO", | ||||
|         }, | ||||
|     }, | ||||
| } | ||||
|  | ||||
| CSRF_TRUSTED_ORIGINS = ["https://tracker.kucharczyk.xyz"] | ||||
| _csrf_trusted_origins = os.environ.get("CSRF_TRUSTED_ORIGINS") | ||||
| if _csrf_trusted_origins: | ||||
|     CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") | ||||
| else: | ||||
|     CSRF_TRUSTED_ORIGINS = [] | ||||
|  | ||||
| @ -13,13 +13,15 @@ Including another URLconf | ||||
|     1. Import the include() function: from django.urls import include, path | ||||
|     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) | ||||
| """ | ||||
| from django.conf import settings | ||||
| from django.contrib import admin | ||||
| from django.urls import include, path | ||||
| from django.views.generic import RedirectView | ||||
|  | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("admin/", admin.site.urls), | ||||
|     path("", RedirectView.as_view(url="/tracker/list-sessions")), | ||||
|     path("tracker/", include("tracker.urls")), | ||||
| ] | ||||
|  | ||||
| if settings.DEBUG: | ||||
|     urlpatterns.append(path("admin/", admin.site.urls)) | ||||
|  | ||||
							
								
								
									
										93
									
								
								tests/test_time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								tests/test_time.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,93 @@ | ||||
| import unittest | ||||
| from datetime import timedelta | ||||
|  | ||||
| from web.common.util.time import format_duration | ||||
|  | ||||
|  | ||||
| class FormatDurationTest(unittest.TestCase): | ||||
|     def setUp(self) -> None: | ||||
|  | ||||
|         return super().setUp() | ||||
|  | ||||
|     def test_only_days(self): | ||||
|         delta = timedelta(days=3) | ||||
|         result = format_duration(delta, "%d days") | ||||
|         self.assertEqual(result, "3 days") | ||||
|  | ||||
|     def test_only_hours(self): | ||||
|         delta = timedelta(hours=1) | ||||
|         result = format_duration(delta, "%H hours") | ||||
|         self.assertEqual(result, "1 hours") | ||||
|  | ||||
|     def test_overflow_hours(self): | ||||
|         delta = timedelta(hours=25) | ||||
|         result = format_duration(delta, "%H hours") | ||||
|         self.assertEqual(result, "25 hours") | ||||
|  | ||||
|     def test_overflow_hours_into_days(self): | ||||
|         delta = timedelta(hours=25) | ||||
|         result = format_duration(delta, "%d days, %H hours") | ||||
|         self.assertEqual(result, "1 days, 1 hours") | ||||
|  | ||||
|     def test_only_minutes(self): | ||||
|         delta = timedelta(minutes=34) | ||||
|         result = format_duration(delta, "%m minutes") | ||||
|         self.assertEqual(result, "34 minutes") | ||||
|  | ||||
|     def test_only_overflow_minutes(self): | ||||
|         delta = timedelta(minutes=61) | ||||
|         result = format_duration(delta, "%m minutes") | ||||
|         self.assertEqual(result, "61 minutes") | ||||
|  | ||||
|     def test_overflow_minutes_into_hours(self): | ||||
|         delta = timedelta(minutes=61) | ||||
|         result = format_duration(delta, "%H hours, %m minutes") | ||||
|         self.assertEqual(result, "1 hours, 1 minutes") | ||||
|  | ||||
|     def test_only_overflow_seconds(self): | ||||
|         delta = timedelta(seconds=61) | ||||
|         result = format_duration(delta, "%s seconds") | ||||
|         self.assertEqual(result, "61 seconds") | ||||
|  | ||||
|     def test_overflow_seconds_into_minutes(self): | ||||
|         delta = timedelta(seconds=61) | ||||
|         result = format_duration(delta, "%m minutes, %s seconds") | ||||
|         self.assertEqual(result, "1 minutes, 1 seconds") | ||||
|  | ||||
|     def test_only_rawseconds(self): | ||||
|         delta = timedelta(seconds=5690) | ||||
|         result = format_duration(delta, "%r total seconds") | ||||
|         self.assertEqual(result, "5690 total seconds") | ||||
|  | ||||
|     def test_empty(self): | ||||
|         delta = timedelta() | ||||
|         result = format_duration(delta, "") | ||||
|         self.assertEqual(result, "") | ||||
|  | ||||
|     def test_zero(self): | ||||
|         delta = timedelta() | ||||
|         result = format_duration(delta, "%r seconds") | ||||
|         self.assertEqual(result, "0 seconds") | ||||
|  | ||||
|     def test_all_at_once(self): | ||||
|         delta = timedelta(days=50, hours=10, minutes=34, seconds=24) | ||||
|         result = format_duration( | ||||
|             delta, "%d days, %H hours, %m minutes, %s seconds, %r total seconds" | ||||
|         ) | ||||
|         self.assertEqual( | ||||
|             result, "50 days, 10 hours, 34 minutes, 24 seconds, 4358064 total seconds" | ||||
|         ) | ||||
|  | ||||
|     def test_negative(self): | ||||
|         delta = timedelta(hours=-2) | ||||
|         result = format_duration(delta, "%H hours") | ||||
|         self.assertEqual(result, "0 hours") | ||||
|  | ||||
|     def test_none(self): | ||||
|         try: | ||||
|             format_duration(None) | ||||
|         except TypeError as exc: | ||||
|             assert False, f"format_duration(None) raised an exception {exc}" | ||||
|  | ||||
|     def test_number(self): | ||||
|         self.assertEqual(format_duration(3600, "%H hour"), "1 hour") | ||||
		Reference in New Issue
	
	Block a user