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 | kind: pipeline | ||||||
| type: docker | type: docker | ||||||
| name: build image | name: default | ||||||
|  |  | ||||||
| steps: | steps: | ||||||
|  | - name: test | ||||||
|  |   image: python:3.10 | ||||||
|  |   commands: | ||||||
|  |     - python -m pip install poetry | ||||||
|  |     - poetry install | ||||||
|  |     - poetry env info | ||||||
|  |     - poetry run pytest | ||||||
| - name: build container | - name: build container | ||||||
|   image: plugins/docker |   image: plugins/docker | ||||||
|   settings: |   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 "----------------" | ||||||
							
								
								
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -1,5 +1,8 @@ | |||||||
| __pycache__ | __pycache__ | ||||||
|  | .mypy_cache | ||||||
|  | .pytest_cache | ||||||
| .venv | .venv | ||||||
| node_modules | node_modules | ||||||
| package-lock.json | package-lock.json | ||||||
| db.sqlite3 | 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" | ||||||
|  | } | ||||||
							
								
								
									
										62
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										62
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| @ -1,8 +1,68 @@ | |||||||
| ## Unreleased | ## 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 | * Hide navigation bar items if there are no games/purchases/sessions | ||||||
| * Set default version to "git-main" to indicate development environment | * Set default version to "git-main" to indicate development environment | ||||||
| * Add homepage, link to it from the logo | * Add homepage, link to it from the logo | ||||||
| * Make it possible to add a new platform | * Make it possible to add a new platform | ||||||
| * Save calculated duration to database if both timestamps are set | * Save calculated duration to database if both timestamps are set | ||||||
| * Improve session listing | * Improve session listing | ||||||
| * Set version in the footer to fixed, fix main container height | * Set version in the footer to fixed, fix main container height | ||||||
|  | |||||||
							
								
								
									
										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 | FROM node as css | ||||||
| ENV VIRTUAL_ENV=/opt/venv | WORKDIR /app | ||||||
| RUN python3 -m venv pip $VIRTUAL_ENV | COPY . /app | ||||||
| ENV PATH="$VIRTUAL_ENV/bin:$PATH" | RUN npm install && \ | ||||||
| RUN pip install --no-cache-dir poetry |     npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify | ||||||
| RUN useradd --create-home --uid 1000 timetracker |  | ||||||
|  | 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 | 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 | ||||||
| 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 / | COPY entrypoint.sh / | ||||||
| RUN chmod +x /entrypoint.sh | RUN chmod +x /entrypoint.sh | ||||||
|  |  | ||||||
| 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 | ||||||
| ENV VERSION_NUMBER 0.1.0-4-g166dd71 |  | ||||||
| ENTRYPOINT [ "/entrypoint.sh" ] | ENTRYPOINT [ "/entrypoint.sh" ] | ||||||
							
								
								
									
										43
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										43
									
								
								Makefile
									
									
									
									
									
								
							| @ -1,8 +1,6 @@ | |||||||
| .PHONY: createsuperuser shell |  | ||||||
|  |  | ||||||
| all: css migrate | all: css migrate | ||||||
|  |  | ||||||
| initialize: npm css migrate loadplatforms | initialize: npm css migrate sethookdir loadplatforms | ||||||
|  |  | ||||||
| HTMLFILES := $(shell find src/web/tracker/templates -type f) | 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 | 	npx tailwindcss -i ./src/input.css -o  ./src/web/tracker/static/base.css --watch | ||||||
|  |  | ||||||
| makemigrations: | makemigrations: | ||||||
| 	python src/web/manage.py makemigrations | 	poetry run python src/web/manage.py makemigrations | ||||||
|  |  | ||||||
| migrate: makemigrations | migrate: makemigrations | ||||||
| 	python src/web/manage.py migrate | 	poetry run python src/web/manage.py migrate | ||||||
|  |  | ||||||
| dev: 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: | 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: | loadplatforms: | ||||||
| 	python src/web/manage.py loaddata platforms.yaml | 	poetry run python src/web/manage.py loaddata platforms.yaml | ||||||
|  |  | ||||||
| loadsample: | loadsample: | ||||||
| 	python src/web/manage.py loaddata sample.yaml | 	poetry run python src/web/manage.py loaddata sample.yaml | ||||||
|  |  | ||||||
| createsuperuser: | createsuperuser: | ||||||
| 	python src/web/manage.py createsuperuser | 	poetry run python src/web/manage.py createsuperuser | ||||||
|  |  | ||||||
| shell: | 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 | #!/bin/bash | ||||||
| # Apply database migrations | # Apply database migrations | ||||||
|  | set -euo pipefail | ||||||
| echo "Apply database migrations" | 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" | 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] | [tool.poetry] | ||||||
| name = "timetracker" | name = "timetracker" | ||||||
| version = "0.0.0" | version = "0.2.4" | ||||||
| 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" | ||||||
| @ -9,12 +9,27 @@ readme = "README.md" | |||||||
| [tool.poetry.dependencies] | [tool.poetry.dependencies] | ||||||
| python = "^3.10" | python = "^3.10" | ||||||
| django = "^4.1.4" | 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] | [tool.poetry.group.dev.dependencies] | ||||||
| black = "^22.12.0" | black = "^22.12.0" | ||||||
| mypy = "^0.991" | mypy = "^0.991" | ||||||
| pyyaml = "^6.0" | 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] | [build-system] | ||||||
| requires = ["poetry-core"] | requires = ["poetry-core"] | ||||||
| build-backend = "poetry.core.masonry.api" | build-backend = "poetry.core.masonry.api" | ||||||
|  |  | ||||||
|  | [tool.pytest.ini_options] | ||||||
|  | pythonpath = [ | ||||||
|  |   "src" | ||||||
|  | ] | ||||||
|  | |||||||
| @ -3,13 +3,20 @@ | |||||||
| @tailwind utilities; | @tailwind utilities; | ||||||
|  |  | ||||||
| form label { | form label { | ||||||
|     @apply dark:text-slate-400; |   @apply dark:text-slate-400; | ||||||
| } | } | ||||||
|  |  | ||||||
| form input,select,textarea { | form input, | ||||||
|     @apply dark:bg-slate-500 dark:border dark:border-slate-900 dark:text-slate-100; | select, | ||||||
|  | textarea { | ||||||
|  |   @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; | ||||||
| } | } | ||||||
|  |  | ||||||
| form input[type=submit] { | #session-table { | ||||||
|     @apply p-2 bg-purple-900; |   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 django.contrib import admin | ||||||
| from .models import Game, Purchase, Platform, Session |  | ||||||
|  | from .models import Game, Platform, Purchase, Session | ||||||
|  |  | ||||||
| # Register your models here. | # Register your models here. | ||||||
| admin.site.register(Game) | admin.site.register(Game) | ||||||
|  | |||||||
| @ -1,8 +1,26 @@ | |||||||
| from django import forms | from django import forms | ||||||
| from .models import Session, Purchase, Game, Platform |  | ||||||
|  | from .models import Game, Platform, Purchase, Session | ||||||
|  |  | ||||||
|  |  | ||||||
| class SessionForm(forms.ModelForm): | 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: |     class Meta: | ||||||
|         model = Session |         model = Session | ||||||
|         fields = [ |         fields = [ | ||||||
| @ -13,11 +31,27 @@ class SessionForm(forms.ModelForm): | |||||||
|             "note", |             "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 PurchaseForm(forms.ModelForm): | ||||||
|     class Meta: |     class Meta: | ||||||
|         model = Purchase |         model = Purchase | ||||||
|         fields = ["game", "platform", "date_purchased", "date_refunded"] |         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): | class GameForm(forms.ModelForm): | ||||||
|  | |||||||
| @ -1,7 +1,7 @@ | |||||||
| # Generated by Django 4.1.4 on 2023-01-02 18:27 | # Generated by Django 4.1.4 on 2023-01-02 18:27 | ||||||
|  |  | ||||||
| from django.db import migrations, models |  | ||||||
| import django.db.models.deletion | import django.db.models.deletion | ||||||
|  | from django.db import migrations, models | ||||||
|  |  | ||||||
|  |  | ||||||
| class Migration(migrations.Migration): | class Migration(migrations.Migration): | ||||||
|  | |||||||
| @ -1,6 +1,7 @@ | |||||||
| # Generated by Django 4.1.4 on 2023-01-02 18:55 | # Generated by Django 4.1.4 on 2023-01-02 18:55 | ||||||
|  |  | ||||||
| import datetime | import datetime | ||||||
|  |  | ||||||
| from django.db import migrations, models | 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 django.db import models | ||||||
| from datetime import timedelta | from django.db.models import F, Manager, Sum | ||||||
|  |  | ||||||
|  |  | ||||||
| class Game(models.Model): | class Game(models.Model): | ||||||
| @ -28,46 +34,58 @@ class Platform(models.Model): | |||||||
|         return self.name |         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): | class Session(models.Model): | ||||||
|     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) |     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) | ||||||
|     note = models.TextField(blank=True, null=True) |     note = models.TextField(blank=True, null=True) | ||||||
|  |  | ||||||
|  |     objects = SessionQuerySet.as_manager() | ||||||
|  |  | ||||||
|     def __str__(self): |     def __str__(self): | ||||||
|         mark = ", manual" if self.duration_manual != None else "" |         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): |     def finish_now(self): | ||||||
|         if self.timestamp_end == None or self.timestamp_start == None: |         self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|             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 duration_formatted(self): |     def start_now(): | ||||||
|         seconds = self.duration_seconds() |         self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|         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 duration_any(self): |     def duration_seconds(self) -> timedelta: | ||||||
|         return ( |         manual = timedelta(0) | ||||||
|             self.duration_formatted() |         calculated = timedelta(0) | ||||||
|             if self.duration_manual == None |         if not self.duration_manual in (None, 0, timedelta(0)): | ||||||
|             else self.duration_manual |             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): |     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: | ||||||
|  |             self.duration_calculated = timedelta(0) | ||||||
|         super(Session, self).save(*args, **kwargs) |         super(Session, self).save(*args, **kwargs) | ||||||
|  | |||||||
| @ -683,34 +683,66 @@ select { | |||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .\!container { | ||||||
|  |   width: 100% !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| @media (min-width: 640px) { | @media (min-width: 640px) { | ||||||
|   .container { |   .container { | ||||||
|     max-width: 640px; |     max-width: 640px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .\!container { | ||||||
|  |     max-width: 640px !important; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (min-width: 768px) { | @media (min-width: 768px) { | ||||||
|   .container { |   .container { | ||||||
|     max-width: 768px; |     max-width: 768px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .\!container { | ||||||
|  |     max-width: 768px !important; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (min-width: 1024px) { | @media (min-width: 1024px) { | ||||||
|   .container { |   .container { | ||||||
|     max-width: 1024px; |     max-width: 1024px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .\!container { | ||||||
|  |     max-width: 1024px !important; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (min-width: 1280px) { | @media (min-width: 1280px) { | ||||||
|   .container { |   .container { | ||||||
|     max-width: 1280px; |     max-width: 1280px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .\!container { | ||||||
|  |     max-width: 1280px !important; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media (min-width: 1536px) { | @media (min-width: 1536px) { | ||||||
|   .container { |   .container { | ||||||
|     max-width: 1536px; |     max-width: 1536px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .\!container { | ||||||
|  |     max-width: 1536px !important; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .visible { | ||||||
|  |   visibility: visible; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .collapse { | ||||||
|  |   visibility: collapse; | ||||||
| } | } | ||||||
|  |  | ||||||
| .static { | .static { | ||||||
| @ -721,6 +753,26 @@ select { | |||||||
|   position: fixed; |   position: fixed; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .\!fixed { | ||||||
|  |   position: fixed !important; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .absolute { | ||||||
|  |   position: absolute; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .relative { | ||||||
|  |   position: relative; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .sticky { | ||||||
|  |   position: sticky; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .\!sticky { | ||||||
|  |   position: sticky !important; | ||||||
|  | } | ||||||
|  |  | ||||||
| .left-2 { | .left-2 { | ||||||
|   left: 0.5rem; |   left: 0.5rem; | ||||||
| } | } | ||||||
| @ -734,6 +786,11 @@ select { | |||||||
|   margin-right: auto; |   margin-right: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .my-5 { | ||||||
|  |   margin-top: 1.25rem; | ||||||
|  |   margin-bottom: 1.25rem; | ||||||
|  | } | ||||||
|  |  | ||||||
| .mb-4 { | .mb-4 { | ||||||
|   margin-bottom: 1rem; |   margin-bottom: 1rem; | ||||||
| } | } | ||||||
| @ -742,16 +799,76 @@ select { | |||||||
|   margin-top: 1rem; |   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 { | .block { | ||||||
|   display: block; |   display: block; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .inline-block { | ||||||
|  |   display: inline-block; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .inline { | ||||||
|  |   display: inline; | ||||||
|  | } | ||||||
|  |  | ||||||
| .flex { | .flex { | ||||||
|   display: flex; |   display: flex; | ||||||
| } | } | ||||||
|  |  | ||||||
| .grid { | .table { | ||||||
|   display: grid; |   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 { | .min-h-screen { | ||||||
| @ -762,12 +879,28 @@ select { | |||||||
|   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; | ||||||
| } | } | ||||||
|  |  | ||||||
| .grid-cols-4 { | .resize { | ||||||
|   grid-template-columns: repeat(4, minmax(0, 1fr)); |   resize: both; | ||||||
| } | } | ||||||
|  |  | ||||||
| .flex-col { | .flex-col { | ||||||
| @ -782,6 +915,10 @@ select { | |||||||
|   align-items: center; |   align-items: center; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .justify-end { | ||||||
|  |   justify-content: flex-end; | ||||||
|  | } | ||||||
|  |  | ||||||
| .justify-center { | .justify-center { | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
| } | } | ||||||
| @ -798,6 +935,20 @@ select { | |||||||
|   align-self: center; |   align-self: center; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .overflow-hidden { | ||||||
|  |   overflow: hidden; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .truncate { | ||||||
|  |   overflow: hidden; | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .text-ellipsis { | ||||||
|  |   text-overflow: ellipsis; | ||||||
|  | } | ||||||
|  |  | ||||||
| .whitespace-nowrap { | .whitespace-nowrap { | ||||||
|   white-space: nowrap; |   white-space: nowrap; | ||||||
| } | } | ||||||
| @ -806,6 +957,10 @@ select { | |||||||
|   border-radius: 0.25rem; |   border-radius: 0.25rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .rounded-lg { | ||||||
|  |   border-radius: 0.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
| .rounded-xl { | .rounded-xl { | ||||||
|   border-radius: 0.75rem; |   border-radius: 0.75rem; | ||||||
| } | } | ||||||
| @ -819,24 +974,24 @@ select { | |||||||
|   border-color: rgb(229 231 235 / var(--tw-border-opacity)); |   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 { | .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-red-700 { | .bg-green-600 { | ||||||
|   --tw-bg-opacity: 1; |   --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 { | .p-4 { | ||||||
| @ -847,15 +1002,21 @@ select { | |||||||
|   padding: 0.5rem; |   padding: 0.5rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .p-1 { |  | ||||||
|   padding: 0.25rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .py-2 { | .py-2 { | ||||||
|   padding-top: 0.5rem; |   padding-top: 0.5rem; | ||||||
|   padding-bottom: 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 { | .pl-3 { | ||||||
|   padding-left: 0.75rem; |   padding-left: 0.75rem; | ||||||
| } | } | ||||||
| @ -868,6 +1029,10 @@ select { | |||||||
|   text-align: center; |   text-align: center; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .text-right { | ||||||
|  |   text-align: right; | ||||||
|  | } | ||||||
|  |  | ||||||
| .text-4xl { | .text-4xl { | ||||||
|   font-size: 2.25rem; |   font-size: 2.25rem; | ||||||
|   line-height: 2.5rem; |   line-height: 2.5rem; | ||||||
| @ -883,20 +1048,28 @@ select { | |||||||
|   line-height: 1rem; |   line-height: 1rem; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .text-base { | ||||||
|  |   font-size: 1rem; | ||||||
|  |   line-height: 1.5rem; | ||||||
|  | } | ||||||
|  |  | ||||||
| .text-lg { | .text-lg { | ||||||
|   font-size: 1.125rem; |   font-size: 1.125rem; | ||||||
|   line-height: 1.75rem; |   line-height: 1.75rem; | ||||||
| } | } | ||||||
|  |  | ||||||
| .text-sm { |  | ||||||
|   font-size: 0.875rem; |  | ||||||
|   line-height: 1.25rem; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .font-semibold { | .font-semibold { | ||||||
|   font-weight: 600; |   font-weight: 600; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .uppercase { | ||||||
|  |   text-transform: uppercase; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .lowercase { | ||||||
|  |   text-transform: lowercase; | ||||||
|  | } | ||||||
|  |  | ||||||
| .text-white { | .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)); | ||||||
| @ -907,18 +1080,75 @@ select { | |||||||
|   color: rgb(203 213 225 / var(--tw-text-opacity)); |   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 { | .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: 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); |   --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); |   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 { | .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)); | ||||||
| } | } | ||||||
|  |  | ||||||
| .dark form input,.dark select,.dark textarea { | .dark form input,.dark  | ||||||
|  | select,.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)); | ||||||
| @ -928,45 +1158,69 @@ select { | |||||||
|   color: rgb(241 245 249 / var(--tw-text-opacity)); |   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; |   --tw-bg-opacity: 1; | ||||||
|   background-color: rgb(88 28 135 / var(--tw-bg-opacity)); |   background-color: rgb(21 128 61 / var(--tw-bg-opacity)); | ||||||
|   padding: 0.5rem; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .hover\:border-dotted:hover { | .hover\:bg-blue-700: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 { |  | ||||||
|   --tw-bg-opacity: 1; |   --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; |   --tw-bg-opacity: 1; | ||||||
|   background-color: rgb(239 68 68 / var(--tw-bg-opacity)); |   background-color: rgb(185 28 28 / 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)); |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .hover\:underline:hover { | .hover\:underline:hover { | ||||||
|   text-decoration-line: underline; |   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 { | .dark .dark\:border-white { | ||||||
|   --tw-border-opacity: 1; |   --tw-border-opacity: 1; | ||||||
|   border-color: rgb(255 255 255 / var(--tw-border-opacity)); |   border-color: rgb(255 255 255 / var(--tw-border-opacity)); | ||||||
| @ -1019,4 +1273,4 @@ form input[type=submit] { | |||||||
|   .md\:flex-row { |   .md\:flex-row { | ||||||
|     flex-direction: row; |     flex-direction: row; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,13 +1,13 @@ | |||||||
| {% extends 'base.html' %} | {% extends "base.html" %} | ||||||
|  |  | ||||||
| {% block title %}{{ title }}{% endblock title %} | {% block title %}{{ title }}{% endblock title %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
| <form method="POST" enctype="multipart/form-data" class="mx-auto"> |     <form method="post" enctype="multipart/form-data" class="mx-auto"> | ||||||
|     {% csrf_token %} |         {% csrf_token %} | ||||||
|  |  | ||||||
|     {{ form.as_p }} |         {{ form.as_p }} | ||||||
|  |  | ||||||
|     <input type="submit" value="Submit"> |         <input type="submit" value="Submit"/> | ||||||
| </form> |     </form> | ||||||
| {% endblock content %} | {% 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> | <!doctype html> | ||||||
| <html lang="en"> | <html lang="en"> | ||||||
|  |  | ||||||
| {% load static %} |     {% load static %} | ||||||
|  |  | ||||||
| <head> |     <head> | ||||||
|     <meta charset="utf-8"> |         <meta charset="utf-8"/> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1"> |         <meta name="description" content="Self-hosted time-tracker."/> | ||||||
|     <title>Timetracker - {% block title %}Untitled{% endblock title %}</title> |         <meta name="keywords" content="time, tracking, video games, self-hosted"/> | ||||||
|     <link rel="stylesheet" href="https://rsms.me/inter/inter.css"> |         <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||||||
|     <link rel="stylesheet" href="{% static 'base.css' %}" /> |         <title>Timetracker - {% block title %}Untitled{% endblock title %}</title> | ||||||
| </head> |         <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/> | ||||||
|  |         <link rel="stylesheet" href="{% static 'base.css' %}" /> | ||||||
|  |     </head> | ||||||
|  |  | ||||||
| <body class="dark"> |     <body class="dark"> | ||||||
|     <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 'index' %}" 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 |                         <ul | ||||||
|                         class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> |                             class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> | ||||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> |                             <li><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> |                             <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 %} |                             {% 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> |                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> | ||||||
|                         {% endif %} |                             {% endif %} | ||||||
|                         {% if purchase_available %} |                             {% if purchase_available %} | ||||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> |                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> | ||||||
|                         {% endif %} |                             {% endif %} | ||||||
|                         {% if session_count > 0 %} |                             {% if session_count > 0 %} | ||||||
|                         <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> |                                 <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> | ||||||
|                         {% endif %} |                             {% endif %} | ||||||
|                     </ul> |                         </ul> | ||||||
|  |                     </div> | ||||||
|                 </div> |                 </div> | ||||||
|             </div> |             </nav> | ||||||
|         </nav> |             {% block content %}No content here.{% endblock content %} | ||||||
|         {% block content %}No content here.{% endblock %} |         </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> |     </body> | ||||||
| </body> |  | ||||||
|  |  | ||||||
| </html> | </html> | ||||||
| @ -1,13 +1,17 @@ | |||||||
| {% extends 'base.html' %} | {% extends "base.html" %} | ||||||
|  |  | ||||||
| {% block title %}{{ title }}{% endblock title %} | {% block title %}{{ 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. |             You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}. | ||||||
| {% else %} |         {% elif not game_available or not platform_available %} | ||||||
| Start by clicking the links at the top. To track playtime, you need to have at least 1 owned game. |             There are no games in the database. Start by clicking "New Game" and "New Platform". | ||||||
| {% endif %} |         {% elif not purchase_available %} | ||||||
| </div> |             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 %} | {% endblock content %} | ||||||
| @ -3,30 +3,79 @@ | |||||||
| {% block title %}Sessions{% endblock title %} | {% block title %}Sessions{% endblock title %} | ||||||
|  |  | ||||||
| {% block content %} | {% block content %} | ||||||
| {% if purchase %} |         <div class="text-center text-xl mb-4 dark:text-slate-400"> | ||||||
| <div class="text-center text-xl mb-4 dark:text-slate-400"> |             {% if dataset.count >= 2 %} | ||||||
|     <h1>Listing sessions only for purchase "{{ purchase }}"</h1> |             <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" /> | ||||||
|     <a class="dark:text-white hover:underline" href="{% url 'list_sessions' %}">View all sessions</a> |             {% endif %} | ||||||
| </div> |             {% if dataset.count >= 1 %} | ||||||
| {% endif %} |             <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> | ||||||
| <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"> |             {% endif %} | ||||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Name</div> |             {% if purchase or platform or game %} | ||||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Start</div> |             <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"> | ||||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">End</div> |                 <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" /> | ||||||
|     <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> |               </svg> | ||||||
|     {% for data in dataset %} |               </a><span>Filtering by "{% firstof purchase platform game %}"</span> | ||||||
|     <div class=""><a class="dark:text-white hover:underline" href="{% url 'list_sessions' data.purchase.id %}">{{ data.purchase }}</a></div> |             {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %} | ||||||
|     <div class="dark:text-slate-400">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> |             {% endif %} | ||||||
|     <div class="dark:text-slate-400"> |             {% if dataset.count >= 1 %} | ||||||
|         {% if data.unfinished %} |             <a class="block" href="{% url 'start_session' dataset.last.purchase.id %}"> | ||||||
|         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> |                 <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 "> | ||||||
|         {% elif data.duration_manual %} |                     <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"> | ||||||
|         MANUAL |                         <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" /> | ||||||
|         {% else %} |                     </svg> | ||||||
|         {{ data.timestamp_end | date:"d/m/Y H:i" }} |                     {{ last.purchase }} | ||||||
|         {% endif %} |                 </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> | ||||||
|     <div class="dark:text-slate-400">{{ data.duration_formatted }}{% if data.duration_manual %} (M){% endif %}</div> |  | ||||||
|     {% endfor %} |  | ||||||
| </div> |  | ||||||
| {% endblock content %} | {% endblock content %} | ||||||
| @ -1,6 +1,8 @@ | |||||||
| from django import template |  | ||||||
| import time |  | ||||||
| import os | import os | ||||||
|  | import time | ||||||
|  |  | ||||||
|  | from django import template | ||||||
|  | from django.conf import settings | ||||||
|  |  | ||||||
| register = template.Library() | register = template.Library() | ||||||
|  |  | ||||||
| @ -9,7 +11,11 @@ register = template.Library() | |||||||
| def version_date(): | def version_date(): | ||||||
|     return time.strftime( |     return time.strftime( | ||||||
|         "%d-%b-%Y %H:%m", |         "%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-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( | ||||||
|  |         "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("add-purchase/", views.add_purchase, name="add_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>", | ||||||
|         views.list_sessions, |         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 datetime import datetime | ||||||
| from zoneinfo import ZoneInfo | 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.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): | def model_counts(request): | ||||||
| @ -18,31 +21,67 @@ def model_counts(request): | |||||||
|  |  | ||||||
| def add_session(request): | def add_session(request): | ||||||
|     context = {} |     context = {} | ||||||
|     now = datetime.now() |     now = now_with_tz() | ||||||
|     initial = {"timestamp_start": now, "timestamp_end": now} |     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) |     form = SessionForm(request.POST or None, initial=initial) | ||||||
|     if form.is_valid(): |     if form.is_valid(): | ||||||
|         form.save() |         form.save() | ||||||
|  |         return redirect("list_sessions") | ||||||
|  |  | ||||||
|  |     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 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 = {} |     context = {} | ||||||
|  |  | ||||||
|     if purchase_id != None: |     if filter == "purchase": | ||||||
|         dataset = Session.objects.filter(purchase=purchase_id) |         dataset = Session.objects.filter(purchase=purchase_id) | ||||||
|         context["purchase"] = Purchase.objects.get(id=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: |     else: | ||||||
|         dataset = Session.objects.all() |         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 == None: |         if session.timestamp_end == None and session.duration_manual.seconds == 0: | ||||||
|             session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) |             session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) | ||||||
|             session.unfinished = True |             session.unfinished = True | ||||||
|  |  | ||||||
|  |     context["total_duration"] = dataset.total_duration() | ||||||
|     context["dataset"] = dataset |     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) |     return render(request, "list_sessions.html", context) | ||||||
|  |  | ||||||
| @ -54,6 +93,7 @@ def add_purchase(request): | |||||||
|     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() |         form.save() | ||||||
|  |         return redirect("index") | ||||||
|  |  | ||||||
|     context["form"] = form |     context["form"] = form | ||||||
|     context["title"] = "Add New Purchase" |     context["title"] = "Add New Purchase" | ||||||
| @ -65,6 +105,7 @@ def add_game(request): | |||||||
|     form = GameForm(request.POST or None) |     form = GameForm(request.POST or None) | ||||||
|     if form.is_valid(): |     if form.is_valid(): | ||||||
|         form.save() |         form.save() | ||||||
|  |         return redirect("index") | ||||||
|  |  | ||||||
|     context["form"] = form |     context["form"] = form | ||||||
|     context["title"] = "Add New Game" |     context["title"] = "Add New Game" | ||||||
| @ -76,6 +117,7 @@ def add_platform(request): | |||||||
|     form = PlatformForm(request.POST or None) |     form = PlatformForm(request.POST or None) | ||||||
|     if form.is_valid(): |     if form.is_valid(): | ||||||
|         form.save() |         form.save() | ||||||
|  |         return redirect("index") | ||||||
|  |  | ||||||
|     context["form"] = form |     context["form"] = form | ||||||
|     context["title"] = "Add New Platform" |     context["title"] = "Add New Platform" | ||||||
| @ -84,4 +126,6 @@ def add_platform(request): | |||||||
|  |  | ||||||
| def index(request): | def index(request): | ||||||
|     context = {} |     context = {} | ||||||
|  |     context["total_duration"] = Session().duration_sum | ||||||
|  |     context["title"] = "Index" | ||||||
|     return render(request, "index.html", context) |     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/ | https://docs.djangoproject.com/en/4.1/ref/settings/ | ||||||
| """ | """ | ||||||
|  |  | ||||||
| from pathlib import Path |  | ||||||
| import logging |  | ||||||
| import os | import os | ||||||
|  | from pathlib import Path | ||||||
|  |  | ||||||
| # Build paths inside the project like this: BASE_DIR / 'subdir'. | # Build paths inside the project like this: BASE_DIR / 'subdir'. | ||||||
| BASE_DIR = Path(__file__).resolve().parent.parent | 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@=" | 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! | # SECURITY WARNING: don't run with debug turned on in production! | ||||||
| DEBUG = True | DEBUG = False if os.environ.get("PROD") else True | ||||||
|  |  | ||||||
| ALLOWED_HOSTS = ["*"] | ALLOWED_HOSTS = ["*"] | ||||||
|  |  | ||||||
| @ -34,7 +33,6 @@ ALLOWED_HOSTS = ["*"] | |||||||
|  |  | ||||||
| INSTALLED_APPS = [ | INSTALLED_APPS = [ | ||||||
|     "tracker.apps.TrackerConfig", |     "tracker.apps.TrackerConfig", | ||||||
|     "django.contrib.admin", |  | ||||||
|     "django.contrib.auth", |     "django.contrib.auth", | ||||||
|     "django.contrib.contenttypes", |     "django.contrib.contenttypes", | ||||||
|     "django.contrib.sessions", |     "django.contrib.sessions", | ||||||
| @ -42,6 +40,10 @@ INSTALLED_APPS = [ | |||||||
|     "django.contrib.staticfiles", |     "django.contrib.staticfiles", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | if DEBUG: | ||||||
|  |     INSTALLED_APPS.append("django_extensions") | ||||||
|  |     INSTALLED_APPS.append("django.contrib.admin") | ||||||
|  |  | ||||||
| MIDDLEWARE = [ | MIDDLEWARE = [ | ||||||
|     "django.middleware.security.SecurityMiddleware", |     "django.middleware.security.SecurityMiddleware", | ||||||
|     "django.contrib.sessions.middleware.SessionMiddleware", |     "django.contrib.sessions.middleware.SessionMiddleware", | ||||||
| @ -109,7 +111,7 @@ AUTH_PASSWORD_VALIDATORS = [ | |||||||
|  |  | ||||||
| LANGUAGE_CODE = "en-us" | 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 | USE_I18N = True | ||||||
|  |  | ||||||
| @ -120,6 +122,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" | ||||||
|  |  | ||||||
| # 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 | ||||||
| @ -130,8 +133,19 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" | |||||||
| LOGGING = { | LOGGING = { | ||||||
|     "version": 1, |     "version": 1, | ||||||
|     "disable_existing_loggers": False, |     "disable_existing_loggers": False, | ||||||
|     "handlers": {"console": {"class": "logging.StreamHandler"}}, |     "handlers": { | ||||||
|     "root": {"handlers": ["console"], "level": "WARNING"}, |         "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 |     1. Import the include() function: from django.urls import include, path | ||||||
|     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) |     2. Add a URL to urlpatterns:  path('blog/', include('blog.urls')) | ||||||
| """ | """ | ||||||
|  | from django.conf import settings | ||||||
| from django.contrib import admin | from django.contrib import admin | ||||||
| from django.urls import include, path | from django.urls import include, path | ||||||
| from django.views.generic import RedirectView | from django.views.generic import RedirectView | ||||||
|  |  | ||||||
|  |  | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|     path("admin/", admin.site.urls), |  | ||||||
|     path("", RedirectView.as_view(url="/tracker/list-sessions")), |     path("", RedirectView.as_view(url="/tracker/list-sessions")), | ||||||
|     path("tracker/", include("tracker.urls")), |     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