1 Commits

Author SHA1 Message Date
118612ecea Improve how editions and purchases are displayed
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 23:45:41 +01:00
71 changed files with 1248 additions and 1808 deletions

View File

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

View File

@ -5,7 +5,7 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.12 image: python:3.10
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
@ -30,7 +30,9 @@ steps:
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
auto_tag: true tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when: when:
branch: branch:
exclude: exclude:

View File

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

View File

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

View File

@ -1,5 +1,16 @@
## Unreleased ## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00
## New ## New
* Add stat for finished this year's games * Add stat for finished this year's games
* Add purchase types: * Add purchase types:
@ -8,6 +19,9 @@
* Season Pass * Season Pass
* Battle Pass * Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00 ## 1.4.0 / 2023-11-09 21:01+01:00
### New ### New
@ -95,22 +109,24 @@
### Enhancements ### Enhancements
* Improve form appearance * Improve form appearance
* Add helper buttons next to datime fields
* Change recent session view to current year instead of last 30 days
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
* Add copy button on Add session page to copy times between fields
* Use the same form when editing a session as when adding a session
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Focus important fields on forms * Focus important fields on forms
* Use the same form when editing a session as when adding a session
* Change recent session view to current year instead of last 30 days
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53) * Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
* Change fonts to IBM Plex
* Only use local WOFF2 font files ### Fixes
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
## 1.0.3 / 2023-02-20 17:16+01:00 ## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions * Add wikidata ID and year for editions
* Add icons for game, edition, purchase filters
* Allow filtering by game, edition, purchase from the session list * Allow filtering by game, edition, purchase from the session list
* Add icons for the above * Allow editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00 ## 1.0.2 / 2023-02-18 21:48+01:00

View File

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

View File

@ -1,12 +1,18 @@
all: migrate all: css migrate
initialize: npm migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
npm: npm:
npm install npm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python manage.py makemigrations

109
common/input.css Normal file
View File

@ -0,0 +1,109 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
form label {
@apply dark:text-slate-400;
}
.responsive-table {
@apply dark:text-white mx-auto;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.max-w-20char {
max-width: 20ch;
}
.max-w-35char {
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
}
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
}
#button-container button {
@apply mx-1;
}
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}

View File

@ -1,5 +1,12 @@
import re import re
from datetime import timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):

View File

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

View File

@ -1,17 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: ["eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
overrides: [],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["react"],
rules: {},
parserOptions: {
ecmaFeatures: { jsx: true }
}
};

24
frontend/.gitignore vendored
View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,9 +0,0 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": false,
"singleAttributePerLine": true
}

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: replace with own icon -->
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<title>Timetracker</title>
</head>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -1,29 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.2.4"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.32.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.1",
"eslint-plugin-react-hooks": "^4.6.0",
"vite": "^4.0.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

View File

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

View File

@ -1,71 +0,0 @@
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<Link
to="/"
className="flex items-center"
>
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">
Timetracker
</span>
</Link>
<div className="w-full md:block md:w-auto">
<ul className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}"
>
New Game
</a>
</li>
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_platform' %}"
>
New Platform
</a>
</li>
{/* {% if game_available and platform_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_purchase' %}"
>
New Purchase
</a>
</li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_session' %}"
>
New Session
</a>
</li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li>
<Link
className="block py-2 pl-3 pr-4 hover:underline"
to="/sessions"
>
All Sessions
</Link>
</li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
);
}
export default Nav;

View File

@ -1,162 +0,0 @@
export default function SessionList() {
const data = [
{
"url": "http://localhost:8000/api/sessions/25/",
"timestamp_start": "2020-01-01T00:00:00+01:00",
"timestamp_end": null,
"duration_manual": "12:00:00",
"duration_calculated": "00:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/26/",
"timestamp_start": "2022-12-31T15:25:00+01:00",
"timestamp_end": "2022-12-31T17:25:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/2/"
},
{
"url": "http://localhost:8000/api/sessions/27/",
"timestamp_start": "2023-01-01T23:00:00+01:00",
"timestamp_end": "2023-01-02T00:28:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:28:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/28/",
"timestamp_start": "2023-01-02T22:08:00+01:00",
"timestamp_end": "2023-01-03T01:08:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "03:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/29/",
"timestamp_start": "2023-01-03T22:36:00+01:00",
"timestamp_end": "2023-01-04T00:12:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:36:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/30/",
"timestamp_start": "2023-01-04T20:35:00+01:00",
"timestamp_end": "2023-01-04T22:36:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:01:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/31/",
"timestamp_start": "2023-01-06T18:48:00+01:00",
"timestamp_end": "2023-01-06T23:39:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "04:51:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/32/",
"timestamp_start": "2023-01-07T23:49:00+01:00",
"timestamp_end": "2023-01-08T01:43:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:54:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/33/",
"timestamp_start": "2023-01-08T16:21:00+01:00",
"timestamp_end": "2023-01-08T18:27:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:06:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/34/",
"timestamp_start": "2023-01-08T19:04:00+01:00",
"timestamp_end": "2023-01-08T22:03:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:59:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/35/",
"timestamp_start": "2023-01-09T19:35:48+01:00",
"timestamp_end": "2023-01-09T22:13:20.519058+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:37:32.519058",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/36/",
"timestamp_start": "2023-01-10T15:50:12+01:00",
"timestamp_end": "2023-01-10T17:03:45.424429+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:13:33.424429",
"note": "",
"purchase": "http://localhost:8000/api/purchases/4/"
}
]
const header = ["url", "timestamp_start", "timestamp_end", "duration_manual", "duration_calculated", "note", "purchase"]
// const header = ["Name", "Platform", "Start", "End", "Duration", "Manage"]
return (
<>
<div id="session-table" className="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
{header.map(column => {
<div className="dark:border-white dark:text-slate-300 text-lg">{column}</div>
})}
{data.map(session => {
<>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.url }
</a>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_start }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_end }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_manual }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_calculated }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.note }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.purchase }
</a>
</div>
</div>
</>
})}
</div>
</>
)
}

View File

@ -1,16 +0,0 @@
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError()
console.error(error)
return (
<div className="container text-center">
<h1 className="text-3xl">Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
)
}

View File

@ -1,22 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

View File

@ -1,34 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
// import { loader as sessionLoader } from './routes/sessions'
import ErrorPage from "./error-page"
import SessionList from './components/SessionList'
// import Session from './routes/sessions'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
// loader: sessionLoader,
children: [
{
path: "sessions/",
element: <SessionList />
}
]
},
// {
// path: "sessions",
// element: <SessionList />
// }
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)

View File

@ -1,17 +0,0 @@
export async function api(url) {
const response = await fetch(url);
if (response.ok) {
const jsonValue = await response.json();
return Promise.resolve(jsonValue);
} else {
return Promise.reject('Response was not OK.');
}
}
export async function getSession(sessionId) {
return await api(`/api/sessions/${sessionId}/`);
}
export async function getSessionList() {
return await api(`/api/sessions/`);
}

View File

@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
};

View File

@ -1,12 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:8001",
},
},
});

View File

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

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@ -9,11 +8,6 @@ custom_datetime_widget = forms.DateTimeInput(
) )
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField( # purchase = forms.ModelChoiceField(
@ -56,21 +50,6 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
@ -79,14 +58,14 @@ class PurchaseForm(forms.ModelForm):
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( queryset=Purchase.objects.filter(type=Purchase.GAME).order_by(
"edition__sort_name" "edition__sort_name"
), )
required=False,
) )
class Meta: class Meta:
widgets = { widgets = {
"date_purchased": custom_date_widget, "date_purchased": custom_date_widget,
"date_refunded": custom_date_widget, "date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
} }
model = Purchase model = Purchase
fields = [ fields = [
@ -103,27 +82,6 @@ class PurchaseForm(forms.ModelForm):
"name", "name",
] ]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select): class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):
@ -151,7 +109,7 @@ class EditionForm(forms.ModelForm):
class Meta: class Meta:
model = Edition model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"] fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10 # Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14 # Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor): def rename_duplicates(apps, schema_editor):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53 # Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -5,6 +5,7 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
("games", "0026_purchase_type"), ("games", "0026_purchase_type"),
] ]

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,11 @@
from datetime import timedelta from datetime import datetime, timedelta
from typing import Any
from django.core.exceptions import ValidationError from zoneinfo import ZoneInfo
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
@ -14,7 +13,6 @@ class Game(models.Model):
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(null=True, blank=True, default=None) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -39,8 +37,11 @@ class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(default=datetime.today().year) platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
@ -119,16 +120,28 @@ class Purchase(models.Model):
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True) name = models.CharField(
max_length=255, default="Unknown Name", null=True, blank=True
)
related_purchase = models.ForeignKey( related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True "Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
) )
def __str__(self): def __str__(self):
platform_info = self.platform additional_info = [
if self.platform != self.edition.platform: self.get_type_display() if self.type != Purchase.GAME else "",
platform_info = f"{self.edition.platform} version on {self.platform}" f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})" if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
@ -136,17 +149,12 @@ class Purchase(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type == Purchase.GAME: if self.type == Purchase.GAME:
self.name = "" self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255, null=True, blank=True, default=None) group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -164,9 +172,6 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
@ -180,8 +185,6 @@ class Session(models.Model):
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -190,10 +193,10 @@ class Session(models.Model):
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = timezone.now() self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
@ -246,7 +249,6 @@ class Device(models.Model):
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

View File

View File

@ -755,6 +755,10 @@ select {
position: absolute; position: absolute;
} }
.relative {
position: relative;
}
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
@ -771,20 +775,51 @@ select {
top: 0.75rem; top: 0.75rem;
} }
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
}
.mx-auto { .mx-auto {
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.my-4 { .my-4 {
margin-top: 1rem; margin-top: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-10 {
margin-bottom: 2.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.ml-1 {
margin-left: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.mr-4 { .mr-4 {
margin-right: 1rem; margin-right: 1rem;
} }
@ -797,6 +832,10 @@ select {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@ -813,6 +852,14 @@ select {
display: none; display: none;
} }
.h-4 {
height: 1rem;
}
.h-5 {
height: 1.25rem;
}
.h-6 { .h-6 {
height: 1.5rem; height: 1.5rem;
} }
@ -821,10 +868,22 @@ select {
min-height: 100vh; min-height: 100vh;
} }
.w-5 {
width: 1.25rem;
}
.w-6 { .w-6 {
width: 1.5rem; width: 1.5rem;
} }
.w-7 {
width: 1.75rem;
}
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@ -833,6 +892,10 @@ select {
max-width: 1024px; max-width: 1024px;
} }
.max-w-sm {
max-width: 24rem;
}
.max-w-xs { .max-w-xs {
max-width: 20rem; max-width: 20rem;
} }
@ -859,10 +922,18 @@ select {
align-items: center; align-items: center;
} }
.justify-center {
justify-content: center;
}
.justify-between { .justify-between {
justify-content: space-between; justify-content: space-between;
} }
.gap-2 {
gap: 0.5rem;
}
.self-center { .self-center {
align-self: center; align-self: center;
} }
@ -885,16 +956,35 @@ select {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-sm {
border-radius: 0.125rem;
}
.border-gray-200 { .border-gray-200 {
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
} }
.border-slate-500 {
--tw-border-opacity: 1;
border-color: rgb(100 116 139 / var(--tw-border-opacity));
}
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-green-600 { .bg-green-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
} }
.bg-violet-600 {
--tw-bg-opacity: 1;
background-color: rgb(124 58 237 / var(--tw-bg-opacity));
}
.bg-white { .bg-white {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
@ -909,6 +999,11 @@ select {
padding-right: 0.5rem; padding-right: 0.5rem;
} }
.px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.py-1 { .py-1 {
padding-top: 0.25rem; padding-top: 0.25rem;
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
@ -927,6 +1022,10 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
@ -935,12 +1034,9 @@ select {
font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
.font-serif { .text-3xl {
font-family: IBM Plex Serif, ui-serif, Georgia, Cambria, "Times New Roman", Times, serif; font-size: 1.875rem;
} line-height: 2.25rem;
.font-sans {
font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
} }
.text-4xl { .text-4xl {
@ -948,11 +1044,21 @@ select {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-base { .text-base {
font-size: 1rem; font-size: 1rem;
line-height: 1.5rem; line-height: 1.5rem;
} }
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl { .text-xl {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem; line-height: 1.75rem;
@ -963,57 +1069,19 @@ select {
line-height: 1rem; line-height: 1rem;
} }
.text-2xl {
font-size: 1.5rem;
line-height: 2rem;
}
.text-9xl {
font-size: 8rem;
line-height: 1;
}
.text-8xl {
font-size: 6rem;
line-height: 1;
}
.text-7xl {
font-size: 4.5rem;
line-height: 1;
}
.text-6xl {
font-size: 3.75rem;
line-height: 1;
}
.text-5xl {
font-size: 3rem;
line-height: 1;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
.font-bold {
font-weight: 700;
}
.capitalize {
text-transform: capitalize;
}
.italic { .italic {
font-style: italic; font-style: italic;
} }
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-slate-300 { .text-slate-300 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
@ -1029,6 +1097,14 @@ select {
color: rgb(253 224 71 / var(--tw-text-opacity)); color: rgb(253 224 71 / var(--tw-text-opacity));
} }
.underline {
text-decoration-line: underline;
}
.decoration-slate-500 {
text-decoration-color: #64748b;
}
.shadow-md { .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: 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); --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
@ -1128,12 +1204,10 @@ select {
} }
.responsive-table thead th:not(:first-child), .responsive-table thead th:not(:first-child),
td:not(:first-child) { .responsive-table td:not(:first-child) {
border-left-width: 1px; border-left-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-left-color: rgb(100 116 139 / var(--tw-border-opacity)); border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
padding-left: 1rem;
padding-right: 1rem;
} }
:is(.dark form input),:is(.dark :is(.dark form input),:is(.dark
@ -1157,19 +1231,6 @@ textarea:disabled) {
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1265,11 +1326,21 @@ th label {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-violet-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(109 40 217 / var(--tw-bg-opacity));
}
.hover\:underline:hover { .hover\:underline:hover {
text-decoration-line: underline; text-decoration-line: underline;
} }
@ -1290,6 +1361,11 @@ th label {
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
} }
.focus\:ring-violet-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus { .focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px; --tw-ring-offset-width: 2px;
} }
@ -1298,6 +1374,14 @@ th label {
--tw-ring-offset-color: #bfdbfe; --tw-ring-offset-color: #bfdbfe;
} }
.focus\:ring-offset-violet-200:focus {
--tw-ring-offset-color: #ddd6fe;
}
.group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) { :is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
@ -1308,6 +1392,11 @@ th label {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-600) { :is(.dark .dark\:text-slate-600) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); color: rgb(71 85 105 / var(--tw-text-opacity));
@ -1319,6 +1408,10 @@ th label {
} }
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:inline {
display: inline;
}
.sm\:table-cell { .sm\:table-cell {
display: table-cell; display: table-cell;
} }
@ -1327,11 +1420,19 @@ th label {
max-width: 28rem; max-width: 28rem;
} }
.sm\:max-w-xl {
max-width: 36rem;
}
.sm\:px-4 { .sm\:px-4 {
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }
@ -1386,6 +1487,10 @@ th label {
display: table-cell; display: table-cell;
} }
.lg\:max-w-3xl {
max-width: 48rem;
}
.lg\:max-w-lg { .lg\:max-w-lg {
max-width: 32rem; max-width: 32rem;
} }

View File

@ -1,29 +1,24 @@
/** import { syncSelectInputUntilChanged } from './utils.js';
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
sourceElement.addEventListener("change", sourceElementHandler); let syncData = [
targetElement.addEventListener("focus", targetElementHandler); {
} "source": "#id_game",
"source_value": "dataset.name",
"target": "#id_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "textContent",
"target": "#id_sort_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "dataset.year",
"target": "#id_year_released",
"target_value": "value"
},
]
window.addEventListener("load", () => { syncSelectInputUntilChanged(syncData, "form");
syncSelectInputUntilChanged("#id_game", "#id_name");
});

View File

@ -1,4 +1,9 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [ let syncData = [
{ {
@ -11,21 +16,15 @@ let syncData = [
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
let myConfig = [ disableElementsWhenTrue("#id_type", "game", [
() => { "#id_name",
return getEl("#id_type").value == "game"; "#id_related_purchase",
}, ]);
["#id_name", "#id_related_purchase"], disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
} }
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig) document.addEventListener("DOMContentLoaded", setupElementHandlers);
getEl("#id_type").onchange = () => { getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig) setupElementHandlers();
} };

View File

@ -8,6 +8,9 @@ for (let button of document.querySelectorAll("[data-target]")) {
event.preventDefault(); event.preventDefault();
if (type == "now") { if (type == "now") {
targetElement.value = toISOUTCString(new Date); targetElement.value = toISOUTCString(new Date);
} else if (type == "copy") {
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
} else if (type == "toggle") { } else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text"; if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local"; else targetElement.type = "datetime-local";

View File

@ -3,9 +3,16 @@
* @param {Date} date * @param {Date} date
* @returns {string} * @returns {string}
*/ */
export function toISOUTCString(date) { function toISOUTCString(date) {
let month = (date.getMonth() + 1).toString().padStart(2, 0); function stringAndPad(number) {
return `${date.getFullYear()}-${month}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`; return number.toString().padStart(2, 0);
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
} }
/** /**
@ -92,37 +99,72 @@ function getEl(selector) {
return document.getElementsByClassName(selector) return document.getElementsByClassName(selector)
} }
else { else {
return document.getElementsByName(selector) return document.getElementsByTagName(selector)
} }
} }
/** /**
* @description Does something to elements when something happens. * @description Applies different behaviors to elements based on multiple conditional configurations.
* @param {() => boolean} condition The condition that is being tested. * Each configuration is an array containing a condition function, an array of target element selectors,
* @param {string[]} targetElements * and two callback functions for handling matched and unmatched conditions.
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches. * @param {...Array} configs Each configuration is an array of the form:
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match. * - 0: {function(): boolean} condition - Function that returns true or false based on a condition.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/ */
function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) { function conditionalElementHandler(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) { if (condition()) {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error("Element ${elementName} doesn't exist."); console.error(`Element ${elementName} doesn't exist.`);
} else { } else {
callbackfn1(el); callbackfn1(el);
} }
}); });
} else { } else {
targetElements.forEach((elementName) => { targetElements.forEach(elementName => {
let el = getEl(elementName); let el = getEl(elementName);
if (el === null) { if (el === null) {
console.error("Element ${elementName} doesn't exist."); console.error(`Element ${elementName} doesn't exist.`);
} else { } else {
callbackfn2(el); callbackfn2(el);
} }
}); });
} }
});
} }
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler }; function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -1,8 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
@ -16,3 +16,10 @@
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

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

View File

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

View File

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

View File

@ -20,6 +20,7 @@
<div class="basic-button-container"> <div class="basic-button-container">
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button> <button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button> <button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
</div> </div>
</td> </td>
{% endif %} {% endif %}

View File

@ -1,6 +1,8 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="description" content="Self-hosted time-tracker."/> <meta name="description" content="Self-hosted time-tracker."/>
@ -17,79 +19,54 @@
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"></span> <span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group"> <li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% if purchase_available %} {% if purchase_available %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_device' %}">Device</a>
</li>
{% endif %} {% endif %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_game' %}">Game</a>
</li>
{% if game_available and platform_available %} {% if game_available and platform_available %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_edition' %}">Edition</a>
</li>
{% endif %} {% endif %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_platform' %}">Platform</a>
</li>
{% if edition_available %} {% if edition_available %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_purchase' %}">Purchase</a>
</li>
{% endif %} {% endif %}
{% if purchase_available %} {% if purchase_available %}
<li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_session' %}">Session</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</li> </li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group"> <li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block"> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %} {% for year in stats_dropdown_year_range %}
<li> <li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" <a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
</li> </li>
<li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
<a class="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'list_sessions' %}">All Sessions</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
{% block content %} {% block content %}No content here.{% endblock content %}
No content here.
{% endblock content %}
</div> </div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %} {% block scripts %}{% endblock scripts %}
{% endblock scripts %}
</body> </body>
</html> </html>

View File

@ -1,10 +1,13 @@
<button {% comment %}
type="button" title
text
{% endcomment %}
<a
href="{{ link }}"
title="{{ title }}" title="{{ title }}"
autofocus class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
> >
<svg {% comment %} <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -18,5 +21,6 @@
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/> />
</svg> </svg>
{% endcomment %}
{{ text }} {{ text }}
</button> </a>

View File

@ -2,17 +2,25 @@
title title
text text
{% endcomment %} {% endcomment %}
<button type="button" <button
type="button"
title="{{ title }}" title="{{ title }}"
autofocus autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"> class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
<svg xmlns="http://www.w3.org/2000/svg" >
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
stroke="currentColor" stroke="currentColor"
class="self-center w-6 h-6 inline"> class="self-center w-6 h-6 inline"
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" /> >
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg> </svg>
{{ text }} {{ text }}
</button> </button>

View File

@ -1,13 +1,21 @@
<a href="{{ edit_url }}"> <a href="{{ edit_url }}">
<button type="button" <button
type="button"
title="Edit" title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"> class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"
<svg xmlns="http://www.w3.org/2000/svg" >
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
class="w-5 h-5"> 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" /> <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> </svg>
</button> </button>
</a> </a>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %} {% if session_count > 0 %}

View File

@ -10,8 +10,8 @@
<div class="mx-auto text-center my-4"> <div class="mx-auto text-center my-4">
<a <a
id="last-session-start" id="last-session-start"
href="{% url 'start_session' last.id %}" href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session' last.id %}" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-indicator="#indicator" hx-indicator="#indicator"
hx-swap="afterbegin" hx-swap="afterbegin"
hx-target=".responsive-table tbody" hx-target=".responsive-table tbody"
@ -19,7 +19,7 @@
onClick="document.querySelector('#last-session-start').classList.add('invisible')" onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}" class="{% if last.timestamp_end == null %}invisible{% endif %}"
> >
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %} {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
</a> </a>
</div> </div>
{% endif %} {% endif %}
@ -39,7 +39,11 @@
<td <td
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char" class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
> >
<a
class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' data.purchase.edition.game.id %}">
{{ data.purchase.edition }} {{ data.purchase.edition }}
</a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ data.timestamp_start | date:"d/m/Y H:i" }} {{ data.timestamp_start | date:"d/m/Y H:i" }}

View File

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

View File

@ -1,17 +1,15 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<form method="get" class="text-center"> <form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label> <label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" <select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %} {% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option> <option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %} {% endfor %}
@ -64,15 +62,11 @@
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td>
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
@ -98,8 +92,9 @@
{% for game in top_10_games_by_playtime %} {% for game in top_10_games_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
</a>
</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr> </tr>
@ -134,10 +129,7 @@
<tbody> <tbody>
{% for purchase in all_finished_this_year %} {% for purchase in all_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -154,10 +146,7 @@
<tbody> <tbody>
{% for purchase in this_year_finished_this_year %} {% for purchase in this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -174,10 +163,7 @@
<tbody> <tbody>
{% for purchase in purchased_this_year_finished_this_year %} {% for purchase in purchased_this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -1,8 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center"> <h1 class="text-4xl flex items-center">
@ -14,12 +15,10 @@
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ hours_sum }} <span class="dark:text-slate-500">total</span> {{ hours_sum }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) ({{ playrange }}) </h2>
</h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1"> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span></h1>
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
@ -34,50 +33,90 @@
{% url 'edit_edition' edition.id as edit_url %} {% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul> <ul>
{% for purchase in purchases %} {% for purchase in edition.game_purchases %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-6 flex items-center">
{{ purchase.platform }} {{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %} {% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %} </li>
<li>
<ul> <ul>
{% for related_purchase in purchase.related_purchases %} {% for related_purchase in purchase.nongame_related_purchases_prefetch %}
<li class="sm:pl-6 flex items-center"> <li class="sm:pl-12 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}}) {{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %} {% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endfor %}
</ul>
{% endfor %}
</ul>
{% comment %} <ul>
{% for edition in editions %}
<!-- Edition details -->
{{ edition }} (E)
{% if edition.game_purchases %}
<ul>
{% for purchase in edition.game_purchases %}
<li>
<!-- Game purchase details -->
{{ purchase }} (P)
{% if purchase.dlc_related_purchases %}
<ul>
{% for dlc_purchase in purchase.dlc_related_purchases %}
<li>
{{ dlc_purchase }} (D)
</li> </li>
{% endfor %}
</ul>
{% endif %} {% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %}
{% endfor %}
</ul> {% endcomment %}
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500">({{ sessions.count }})</span> <span class="dark:text-slate-500">
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %} {% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %} {% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul> <ul>
{% for session in sessions %} {% for session in sessions %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }} {{ session.timestamp_start | date:"d/m/Y" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %} {% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %}
</ul>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@ -44,11 +44,6 @@ urlpatterns = [
views.add_purchase, views.add_purchase,
name="add_purchase_for_edition", name="add_purchase_for_edition",
), ),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path( path(
"add-edition-for-game/<int:game_id>", "add-edition-for-game/<int:game_id>",
@ -93,4 +88,10 @@ urlpatterns = [
{"filter": "ownership_type"}, {"filter": "ownership_type"},
name="list_sessions_by_ownership_type", name="list_sessions_by_ownership_type",
), ),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
] ]

View File

@ -1,24 +1,24 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from common.time import now as now_with_tz
from django.conf import settings from django.conf import settings
from django.db.models import Sum, F, Count, Prefetch
from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from typing import Callable, Any
from zoneinfo import ZoneInfo
from common.time import format_duration
from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm,
EditionForm,
GameForm, GameForm,
PlatformForm, PlatformForm,
PurchaseForm, PurchaseForm,
SessionForm, SessionForm,
EditionForm,
DeviceForm,
) )
from .models import Edition, Game, Platform, Purchase, Session from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request): def model_counts(request):
@ -32,15 +32,19 @@ def model_counts(request):
def stats_dropdown_year_range(request): def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)} result = {
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
return result return result
def add_session(request, purchase_id=None): def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": timezone.now()} initial = {"timestamp_start": now_with_tz()}
last = Session.objects.last() last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
@ -133,23 +137,10 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None): def view_game(request, game_id=None):
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
context["title"] = "View Game" nongame_related_purchases_prefetch = Prefetch(
context["game"] = game "related_purchases",
context["editions"] = Edition.objects.filter(game_id=game_id) queryset=Purchase.objects.exclude(type=Purchase.GAME),
game_purchases = Purchase.objects.filter(edition__game_id=game_id).filter( to_attr="nongame_related_purchases_prefetch",
type=Purchase.GAME
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
) )
game_purchases_prefetch = Prefetch( game_purchases_prefetch = Prefetch(
"purchase_set", "purchase_set",
@ -167,8 +158,8 @@ def view_game(request, game_id=None):
sessions = Session.objects.filter(purchase__edition__game=game) sessions = Session.objects.filter(purchase__edition__game=game)
session_count = sessions.count() session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y") playrange_start = sessions.first().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y") playrange_end = sessions.last().timestamp_start.strftime("%b %Y")
playrange = ( playrange = (
playrange_start playrange_start
@ -185,7 +176,7 @@ def view_game(request, game_id=None):
"purchase_count": Purchase.objects.filter(edition__game=game).count(), "purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1), "session_average": round(total_hours / int(session_count), 1),
"session_count": session_count, "session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(), "sessions_with_notes": sessions.exclude(note=""),
"sessions": sessions.order_by("-timestamp_start"), "sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}", "title": f"Game Overview - {game.name}",
"hours_sum": total_hours, "hours_sum": total_hours,
@ -221,24 +212,17 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect @use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest() last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -251,7 +235,7 @@ def start_session_same_as_last(request, last_session_id: int):
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -259,6 +243,12 @@ def start_session_same_as_last(request, last_session_id: int):
return redirect("list_sessions") 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( def list_sessions(
request, request,
filter="", filter="",
@ -287,19 +277,21 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = datetime.now().year
first_day_of_year = datetime(current_year, 1, 1)
dataset = Session.objects.filter( dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30) timestamp_start__gte=first_day_of_year
) ).order_by("-timestamp_start")
context["title"] = "Last 30 days" context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual == timedelta( if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0 seconds=0
): ):
session.timestamp_end = timezone.now() session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration_formatted()
@ -315,7 +307,7 @@ def stats(request, year: int = 0):
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = timezone.now().year year = now_with_tz().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year) this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK" selected_currency = "CZK"
unique_days = ( unique_days = (
@ -448,7 +440,7 @@ def stats(request, year: int = 0):
def add_purchase(request, edition_id=None): def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": now_with_tz()}
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)
@ -543,3 +535,19 @@ def add_platform(request):
context["form"] = form context["form"] = form
context["title"] = "Add New Platform" context["title"] = "Add New Platform"
return render(request, "add.html", context) return render(request, "add.html", context)
def add_device(request):
context = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"devDependencies": {
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10",
"tailwindcss": "^3.3.3"
}
}

855
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,15 +7,11 @@ license = "GPL"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.10"
django = "^4.2.0" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2"
matplotlib = "^3.6.3"
django-rest-framework = "^0.1.0"
django-cors-headers = "^3.13.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"
@ -27,10 +23,6 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

19
tailwind.config.js Normal file
View File

@ -0,0 +1,19 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
darkMode: 'class',
content: ["./games/**/*.{html,js}"],
theme: {
extend: {
fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')
],
}

View File

@ -1,16 +1,15 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django import django
import os
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session from games.models import Game, Edition, Purchase, Session, Platform
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -1,16 +1,14 @@
import django
import os import os
from django.test import TestCase
from django.db import models
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Game, Edition, Purchase, Session
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from corsheaders.defaults import default_headers, default_methods
# 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
@ -39,16 +38,13 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework",
"corsheaders",
] ]
if DEBUG: # if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
MIDDLEWARE = [ MIDDLEWARE = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@ -127,7 +123,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static" STATIC_ROOT = BASE_DIR / "static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -154,24 +150,3 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
]
}
FRONTEND_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "frontend", "dist"))
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
"Accept-Language",
"Connection",
"Host",
"Origin",
"Referer",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
]

View File

@ -17,65 +17,12 @@ 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
from rest_framework import routers, serializers, viewsets
from games.models import Game, Purchase, Platform, Session
class GameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Game
fields = "__all__"
class PlatformSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Platform
fields = "__all__"
class PurchaseSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Purchase
fields = "__all__"
class SessionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Session
fields = "__all__"
class GameViewSet(viewsets.ModelViewSet):
queryset = Game.objects.all()
serializer_class = GameSerializer
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PurchaseViewSet(viewsets.ModelViewSet):
queryset = Purchase.objects.all()
serializer_class = PurchaseSerializer
class SessionViewSet(viewsets.ModelViewSet):
queryset = Session.objects.all()
serializer_class = SessionSerializer
router = routers.DefaultRouter()
router.register(r"games", GameViewSet)
router.register(r"platforms", PlatformViewSet)
router.register(r"purchases", PurchaseViewSet)
router.register(r"sessions", SessionViewSet)
urlpatterns = [ urlpatterns = [
path("api/", include(router.urls)), path("", RedirectView.as_view(url="/tracker")),
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), path("tracker/", include("games.urls")),
] ]
if settings.DEBUG: # if settings.DEBUG:
# urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls)) urlpatterns.append(path("admin/", admin.site.urls))