Compare commits

..

1 Commits

Author SHA1 Message Date
lukas 9de1695a1c Fix collectstaticfiles causing error
Fixes #23
2023-01-08 15:43:15 +01:00
216 changed files with 2793 additions and 18337 deletions
-25
View File
@@ -1,25 +0,0 @@
{
"name": "Django Time Tracker",
"dockerFile": "../devcontainer.Dockerfile",
"customizations": {
"vscode": {
"settings": {
"python.pythonPath": "/usr/local/bin/python",
"python.defaultInterpreterPath": "/usr/local/bin/python",
"terminal.integrated.defaultProfile.linux": "bash"
},
"extensions": [
"ms-python.python",
"ms-python.debugpy",
"ms-python.vscode-pylance",
"ms-azuretools.vscode-docker",
"batisteo.vscode-django",
"charliermarsh.ruff",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
},
"forwardPorts": [8000],
"postCreateCommand": "poetry install && poetry run python manage.py migrate && npm install && make dev",
}
+1 -17
View File
@@ -1,17 +1 @@
.git
.githooks
.mypy_cache
.pytest_cache
.venv
.vscode
node_modules
static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile
src/web/static/*
+4 -35
View File
@@ -5,52 +5,21 @@ name: default
steps:
- name: test
image: python:3.12
image: python:3.10
commands:
- python -m pip install poetry
- poetry install
- poetry env info
- poetry run python manage.py migrate
- poetry run pytest
- name: build-prod
- name: build container
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- latest
- 1.1.0
depends_on:
- "test"
when:
branch:
- main
- name: build-non-prod
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when:
branch:
exclude:
- main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger:
event:
- push
- cron
exclude:
- pull_request
-20
View File
@@ -1,20 +0,0 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2
[*.html]
insert_final_newline = false
-1
View File
@@ -1 +0,0 @@
use nix
View File
-50
View File
@@ -1,50 +0,0 @@
name: Django CI/CD
on:
push:
paths-ignore: [ 'README.md' ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: false
python-version: "3.14"
- name: Install dependencies
run: uv sync --frozen
- name: Run Migrations
run: uv run python manage.py migrate
# - name: Run Tests
# run: PROD=1 uv run pytest
build-and-push:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Set Version
run: echo "VERSION_NUMBER=1.6.1" >> $GITHUB_ENV
- 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 }}
# cache-from: type=gha
# cache-to: type=gha,mode=max
+2 -6
View File
@@ -1,12 +1,8 @@
__pycache__
.mypy_cache
.pytest_cache
.venv/
.venv
node_modules
package-lock.json
db.sqlite3
/static/
dist/
.DS_Store
.python-version
.direnv
src/web/static
-11
View File
@@ -1,11 +0,0 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
-26
View File
@@ -1,26 +0,0 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Django",
"type": "debugpy",
"request": "launch",
"args": [
"runserver"
],
"django": true,
"autoStartBrowser": false,
"program": "${workspaceFolder}/manage.py"
}
]
}
+1 -26
View File
@@ -4,30 +4,5 @@
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "strict",
"[python]": {
"editor.defaultFormatter": "charliermarsh.ruff",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit"
},
},
"ruff.path": ["/nix/store/jaibb3v0rrnlw5ib54qqq3452yhp1xcb-ruff-0.5.7/bin/ruff"],
"tailwind-fold.supportedLanguages": [
"html",
"typescriptreact",
"javascriptreact",
"typescript",
"javascript",
"vue-html",
"vue",
"php",
"markdown",
"coffeescript",
"svelte",
"astro",
"erb",
"django-html"
]
"python.analysis.typeCheckingMode": "basic"
}
-236
View File
@@ -1,241 +1,5 @@
## Unreleased
### Improved
* Add a prompt to set game to Abandoned upon refund
## 1.6.1 / 2026-01-30 11:48+01:00
### New
* Pre-fill time played into new playevent, also tracks time since last playevent
* Improve light theme and fix light/dark theme switcher
* Fix purchase form logic
* Update dependencies
## 1.6.0 / 2025-01-15 23:13+01:00
### New
* Visual overhaul of many pages
* Render notes as Markdown
* Require login by default
* Add stats for dropped purchases, monthly playtimes
* Allow deleting purchases
* Add all-time stats
* Manage purchases
* Automatically convert purchase prices
* Add emulated property to sessions
* Add today's and last 7 days playtime stats to navbar
### Improved
* mark refunded purchases red on game overview
* increase session count on game overview when starting a new session
* game overview:
* sort purchases also by date purchased (on top of date released)
* improve header format, make it more appealing
* ignore manual sessions when calculating session average
* stats: improve purchase name consistency
* session list: use display name instead of sort name
* unify the appearance of game links, and make them expand to full size on hover
### Fixed
* Fix title not being displayed on the Recent sessions page
* Avoid errors when displaying game overview with zero sessions
## 1.5.2 / 2024-01-14 21:27+01:00
## Improved
* game overview:
* improve how editions and purchases are displayed
* make it possible to end session from overview
* add purchase: only allow choosing purchases of selected edition
* session list:
* starting and ending sessions is much faster/doest not reload the page
* listing sessions is much faster
## 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
* Add stat for finished this year's games
* Add purchase types:
* Game (previously all of them were this type)
* DLC
* Season Pass
* Battle Pass
## Fixed
* Order purchases by date on game view
## 1.4.0 / 2023-11-09 21:01+01:00
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
* Backlog Decrease (count)
* New workflow:
* Adding Game, Edition, Purchase, and Session in a row is now much faster
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
* ignore English articles when sorting names
* added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
* new game: name and sort name after typing
* new edition: name, sort name, and year when selecting game
* new purchase: platform when selecting edition
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements
* Durations are formatted in a consisent manner across all pages
### Fixes
* Game Overview: display duration when >1 hour instead of displaying 0
## 1.1.1 / 2023-10-09 20:52+02:00
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* 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)
### 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
* 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 editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00
* Breaking
* Due to major re-arranging and re-naming of the folder structure, tables also had to be renamed.
* Fixed
* Sort form fields alphabetically (https://git.kucharczyk.xyz/lukas/timetracker/issues/39, https://git.kucharczyk.xyz/lukas/timetracker/issues/40)
* Start session button starts different game than it says (#44)
## 0.2.5 / 2023-01-18 17:01+01:00
* New
* When adding session, pre-select game with the last session
* Fixed
* Start session now button would take up 100% width, leading to accidental clicks (https://git.kucharczyk.xyz/lukas/timetracker/issues/37)
* Removed
* Session model property `last` is already implemented by Django method `last()`, thus it was removed (https://git.kucharczyk.xyz/lukas/timetracker/issues/38)
## 0.2.4 / 2023-01-16 19:39+01:00
* Fixed
* When filtering by game, the "Filtering by (...)" text would erroneously list an unrelated platform
* Playtime graph would display timeline backwards
* Playtime graph with many dates would overlap (https://git.kucharczyk.xyz/lukas/timetracker/issues/34)
* Manually added times (= without end timestamp) would make graphs look ugly and noisy (https://git.kucharczyk.xyz/lukas/timetracker/issues/35)
## 0.2.3 / 2023-01-15 23:13+01:00
* Allow filtering by platform and game on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/32)
* Order session by newest as preparation for https://git.kucharczyk.xyz/lukas/timetracker/issues/33
## 0.2.2 / 2023-01-15 17:59+01:00
* Display playtime graph on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/29)
* Fix error when showing session list with no sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/31)
## 0.2.1 / 2023-01-13 16:53+01:00
* List number of sessions when filtering on session list
* Start sessions of last purchase from list (https://git.kucharczyk.xyz/lukas/timetracker/issues/19)
## 0.2.0 / 2023-01-09 22:42+01:00
* Show playtime total on session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/6)
* Make formatting durations more robust, change default duration display to "X hours" (https://git.kucharczyk.xyz/lukas/timetracker/issues/26)
## 0.1.4 / 2023-01-08 15:45+01:00
* Fix collectstaticfiles causing error when restarting container (https://git.kucharczyk.xyz/lukas/timetracker/issues/23)
## 0.1.3 / 2023-01-08 15:23+01:00
+2 -2
View File
@@ -5,10 +5,10 @@
:8000 {
handle_path /static/* {
root * /usr/share/caddy
root * src/web/static/
file_server
}
handle {
reverse_proxy backend:8001
reverse_proxy :8001
}
}
+23 -33
View File
@@ -1,41 +1,31 @@
FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --minify
ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
FROM python:3.10.9-alpine
ENV VERSION_NUMBER 0.1.2-2-ge5a9b9a
ENV PROD 1
RUN apk add \
bash \
vim \
curl \
caddy
RUN adduser -D -u 1000 timetracker
WORKDIR /home/timetracker/app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --no-install-project --no-dev
COPY . .
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --frozen --no-dev
FROM python:3.14-slim-bookworm
ENV PROD=1 \
PYTHONUNBUFFERED=1 \
PATH="/home/timetracker/app/.venv/bin:$PATH"
RUN useradd -m --uid 1000 timetracker \
&& mkdir -p /var/www/django/static \
&& chown timetracker:timetracker /var/www/django/static
WORKDIR /home/timetracker/app
COPY --from=builder --chown=timetracker:timetracker /home/timetracker/app /home/timetracker/app
COPY --chown=timetracker:timetracker entrypoint.sh /
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css /app/src/web/tracker/static/base.css /home/timetracker/app/src/web/tracker/static/base.css
COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh
USER timetracker
ENV VERSION_NUMBER=1.6.1
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install --without dev
EXPOSE 8000
CMD [ "/entrypoint.sh" ]
ENTRYPOINT [ "/entrypoint.sh" ]
+28 -42
View File
@@ -2,77 +2,63 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
HTMLFILES := $(shell find src/web/tracker/templates -type f)
npm:
npm install
css: common/input.css
npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
css: src/input.css
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css
css-dev: css
npx tailwindcss -i ./src/input.css -o ./src/web/tracker/static/base.css --watch
makemigrations:
uv run python manage.py makemigrations
poetry run python src/web/manage.py makemigrations
migrate: makemigrations
uv run python manage.py migrate
init:
uv install $(PYTHON_VERSION)
uv sync
npm install
$(MAKE) sethookdir
$(MAKE) loadplatforms
sethookdir:
git config core.hooksPath .githooks
chmod +x .githooks/*
dev:
@npx concurrently \
--names "Django,Tailwind" \
--prefix-colors "blue,green" \
"uv run python -Wa manage.py runserver" \
"npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css --watch"
poetry run python src/web/manage.py migrate
dev: migrate sethookdir
poetry run python src/web/manage.py runserver_plus
caddy:
caddy run --watch
dev-prod: migrate collectstatic
PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
dev-prod: migrate collectstatic sethookdir
cd src/web/; PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames:
uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
dumptracker:
poetry run python src/web/manage.py dumpdata --format yaml tracker --output tracker_fixture.yaml
loadplatforms:
uv run python manage.py loaddata platforms.yaml
loadall:
uv run python manage.py loaddata data.yaml
poetry run python src/web/manage.py loaddata platforms.yaml
loadsample:
uv run python manage.py loaddata sample.yaml
poetry run python src/web/manage.py loaddata sample.yaml
createsuperuser:
uv run python manage.py createsuperuser
poetry run python src/web/manage.py createsuperuser
shell:
uv run python manage.py shell
poetry run python src/web/manage.py shell
collectstatic:
uv run python manage.py collectstatic --clear --no-input
poetry run python src/web/manage.py collectstatic --clear --no-input
uv.lock: pyproject.toml
uv sync
poetry.lock: pyproject.toml
poetry install
test: uv.lock
uv run --with pytest-django pytest
test: poetry.lock
poetry run pytest
sethookdir:
git config core.hooksPath .githooks
date:
uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
python3 -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic:
rm -r static/*
rm -r src/web/static/*
clean: cleanstatic
-12
View File
@@ -1,15 +1,3 @@
# Timetracker
A simple game catalogue and play session tracker.
# Development
The project uses `pyenv` to manage installed Python versions.
If you have `pyenv` installed, you can simply run:
```
make init
```
This will make sure the correct Python version is installed, and it will install all dependencies using `poetry`.
Afterwards, you can start the development server using `make dev`.
-157
View File
@@ -1,157 +0,0 @@
# Game & Purchase Status Definitions
## Game Statuses
Games have a `status` field with the following values:
| Status | Code | Description |
|--------|------|-------------|
| **Unplayed** | `u` | Game was purchased but never played |
| **Played** | `p` | Game was played but not yet finished |
| **Finished** | `f` | Game has been completed |
| **Retired** | `r` | Game was intentionally retired (e.g., no longer accessible, collector's item) |
| **Abandoned** | `a` | Game was played but the user gave up on it |
**Setting game status:**
- Users explicitly set game status via the UI (finish/drop purchase buttons, status change form)
- Status changes are tracked in `GameStatusChange` model
- Refunding a purchase always marks its games as abandoned
---
## Purchase-Level Status Concepts
These concepts determine whether a purchase appears in the "unfinished" or "dropped" lists in stats views.
### Finished
A purchase is considered **finished** when:
```
Game.status == "f" OR Purchase.games.* has a PlayEvent with an ended date
```
Either signal indicates the game is complete:
- **Explicit**: User marked the game as finished (`Game.status = "f"`)
- **Implicit**: A PlayEvent exists with `ended` date set (data-driven)
This uses **OR** logic during a transition period. Later, these signals should be kept in sync so only one source of truth is needed.
### Dropped
A purchase is considered **dropped** when:
```
Game.status == "a" OR Purchase.date_refunded IS NOT NULL
```
Either signal indicates the user no longer has an active interest in the game:
- **Explicit**: User marked the game as abandoned (`Game.status = "a"`)
- **Implicit**: User refunded the purchase (which automatically sets games to abandoned)
Note: Refunding a purchase always marks its games as abandoned. There is no option to refund without abandoning.
---
## Unfinished vs. Dropped
The stats views categorize purchases into **unfinished** and **dropped** lists.
### Unfinished
A purchase is **unfinished** when:
1. It was purchased in the relevant time period (this year for yearly stats, all time for all-time stats)
2. It was NOT refunded (only counts toward unfinished/backlog)
3. It is NOT finished (per the finished definition above)
4. It is NOT dropped (per the dropped definition above)
5. It is NOT infinite (subscription, etc.)
6. It IS a game or DLC (not season passes or battle passes)
**Unfinished = Active backlog** — games the user may still play.
### Dropped
A purchase is **dropped** when:
1. It was purchased in the relevant time period
2. It is NOT finished (per the finished definition above)
3. It matches at least one dropped signal (per the dropped definition above)
4. It is NOT infinite
5. It IS a game or DLC
**Dropped = Terminal state** — games the user has given up on or refunded.
### Summary Table
| Category | Includes Refunded? | Key Condition |
|----------|-------------------|---------------|
| **Unfinished** | No | NOT finished, NOT dropped |
| **Dropped** | Yes | Finished OR Abandoned/Retired |
| **Refunded** | Yes | `date_refunded IS NOT NULL` |
| **Infinite** | Yes | `infinite = True` |
---
## Query Patterns
### Checking if a game is finished
```python
game.finished() # Returns True if status="f" or has PlayEvent with ended date
```
### Checking if a game is abandoned
```python
game.abandoned() # Returns True if status="a"
```
### Getting finished purchases
```python
Purchase.objects.finished() # All purchases where games are finished
```
### Getting dropped purchases
```python
Purchase.objects.dropped() # All purchases that are abandoned or refunded
```
---
## Transition State
The system uses **OR logic** for both finished and dropped to catch any mismatch between explicit user actions and data signals:
- **Finished**: `status="f" OR PlayEvent.ended`
- **Dropped**: `status="a" OR date_refunded`
This bridges the gap between the old model (where `date_finished` and `date_dropped` were on the Purchase model) and the new model (where `Game.status` and `PlayEvent` are the sources of truth).
**Future:** These signals should be kept in sync. For example:
- Setting `Game.status = "f"` should create a PlayEvent with `ended` date
- When the sync is reliable, the OR can be simplified to a single check
Note: Refunding a purchase always automatically sets its games' status to Abandoned. This is not optional — there is no way to refund without abandoning.
---
## Edge Cases
### Unplayed games
- Unplayed games (`status="u"`) are considered **unfinished**, not dropped
- They appear in the unfinished/backlog list since they are still games the user may play
- Unplayed games that are refunded DO count as **dropped** (refund signal overrides)
### Multiple games per purchase
- A purchase can have multiple games via `Purchase.games` (many-to-many)
- A purchase is finished if ANY of its games is finished
- A purchase is dropped if ANY of its games is abandoned OR the purchase itself is refunded
### PlayEvents without ended date
- A PlayEvent with `started` but no `ended` does NOT count as finished
- This represents a game that was started but not completed
### Retired games
- Retired games (`status="r"`) are considered **dropped**
- Retirement is for games the user intentionally removed from their collection (collector's items, no longer accessible, etc.)
-46
View File
@@ -1,46 +0,0 @@
# Suggested Improvements to common/components.py
## Completed
### Caching on template rendering
- Added `functools.lru_cache` on `_render_cached()` wrapper around `render_to_string`
- Cache key: `(template_path, json.dumps(context, sort_keys=True))` — deterministic and unique
- `maxsize=4096` in production, disabled entirely in DEBUG mode (so template changes are reflected immediately)
- Only caches `template` path calls; `tag_name` calls are already nanosecond string ops
- Verified working: identical calls return identical output, different inputs produce separate cache entries
### Non-deterministic IDs
`randomid()` was replaced with `hashlib.sha1(content_hash.encode()).hexdigest()[:10]` for deterministic ID generation.
- `Popover()` passes content hash (`wrapped_content:popover_content:wrapped_classes`) so IDs are deterministic per unique content
- `games/templatetags/randomid.py` uses the same hash-based approach
- Fixes: caching (Popover output now cacheable), page consistency, thread safety
### Inconsistent return types
All component functions now return `SafeText` and are annotated accordingly. Redundant `mark_safe()` wrappers removed from `LinkedPurchase()` and `NameWithIcon()`.
### Fragile A() URL resolution
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Removed dead `Callable` type hint. `reverse()` now raises `NoReverseMatch` instead of silently falling back to literal text. Added mutual exclusion check — providing both parameters raises `ValueError`. Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`).
### Toast XSS vulnerability
The vulnerable `Toast()` component (which used unsafe string escaping for
Alpine.js interpolation) had no callers and was deleted entirely. Toast display
is handled by the existing event-driven pipeline: middleware → `HX-Trigger`
headers → `show-toast` CustomEvent → Alpine store.
### Default mutable arguments
All functions with mutable defaults (`attributes` and `children`) changed from `= []` to `| None = None` with `or []` conversion in the body.
What was fixed: `attributes: list[HTMLAttribute] = []` and `children: list[HTMLTag] | HTMLTag = []` are a classic Python gotcha — the default is shared across all callers and could silently corrupt state if ever mutated in place. Changed 8 functions (`Component`, `Popover`, `A`, `Button`, `Div`, `Input`, `Form`, `Icon`) to use the `None` sentinel pattern, preventing future bugs and eliminating linter warnings.
### NameWithIcon dead code and untestable design
The `NameWithIcon()` function had a `platform` parameter that was immediately overwritten by `platform = None` and never used (dead code). The function mixed data lookup (database queries via IDs) with rendering, making it untestable.
**Fix**: Refactored `NameWithIcon()` to follow the `LinkedPurchase` pattern — accepts model objects (`Game`, `Session`) instead of IDs. Extracted `_resolve_name_with_icon()` helper for testable computation logic (name resolution, platform extraction, link creation). Fixed bug where `platform` was not extracted when `session` parameter was passed. Removed dead `platform` parameter from the public API. Updated all 3 production call sites (already using model objects). Added 10 unit tests for `_resolve_name_with_icon()` covering session override, custom names, linkify behavior, platform resolution, and edge cases. Updated 6 integration tests to use model-based parameters.
### No tests
Zero test coverage for the entire component system.
**Fix**: Add unit tests for each component function — basic rendering, edge cases,
and cache hit/miss verification.
**Done**: 96 unit tests covering all component functions (`Component`, `randomid`, `Popover`, `PopoverTruncated`, `A`, `Button`, `Div`, `Icon`, `Form`, `Input`, `NameWithIcon`, `LinkedPurchase`, `PurchasePrice`, `_render_cached`, `enable_cache`). Includes template rendering, deterministic ID generation, LRU cache behavior, HTML output validation, edge cases, error handling, and model-dependent integration tests.
-344
View File
@@ -1,344 +0,0 @@
import hashlib
import json
from functools import lru_cache
from typing import Any
from django.conf import settings
from django.template import TemplateDoesNotExist
from django.template.defaultfilters import floatformat
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.safestring import SafeText, mark_safe
from common.utils import truncate
from games.models import Game, Purchase, Session
HTMLAttribute = tuple[str, str | int | bool]
HTMLTag = str
def _render_cached_impl(template: str, context_json: str) -> str:
context = json.loads(context_json)
context["slot"] = mark_safe(context["slot"])
return render_to_string(template, context)
if not settings.DEBUG:
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
else:
_render_cached = _render_cached_impl
def enable_cache():
"""Wrap _render_cached with LRU cache (for testing in DEBUG mode)."""
global _render_cached
_render_cached = lru_cache(maxsize=4096)(_render_cached_impl)
def Component(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
template: str = "",
tag_name: str = "",
) -> SafeText:
attributes = attributes or []
children = children or []
if not tag_name and not template:
raise ValueError("One of template or tag_name is required.")
if isinstance(children, str):
children = [children]
childrenBlob = "\n".join(children)
if len(attributes) == 0:
attributesBlob = ""
else:
attributesList = [f'{name}="{value}"' for name, value in attributes]
# make attribute list into a string
# and insert space between tag and attribute list
attributesBlob = f" {' '.join(attributesList)}"
tag: str = ""
if tag_name != "":
tag = f"<{tag_name}{attributesBlob}>{childrenBlob}</{tag_name}>"
elif template != "":
context = {name: value for name, value in attributes} | {"slot": "\n".join(children)}
tag = _render_cached(template, json.dumps(context, sort_keys=True))
return mark_safe(tag)
def randomid(seed: str = "", content: str = "", length: int = 10) -> str:
if not seed and not content:
return seed
hash_input = f"{seed}:{content}" if seed else content
content_hash = hashlib.sha1(hash_input.encode()).hexdigest()
base = content_hash[:length] if not seed else content_hash[:max(0, length - len(seed))]
return seed + base
def Popover(
popover_content: str,
wrapped_content: str = "",
wrapped_classes: str = "",
children: list[HTMLTag] | None = None,
attributes: list[HTMLAttribute] | None = None,
) -> str:
attributes = attributes or []
children = children or []
if not wrapped_content and not children:
raise ValueError("One of wrapped_content or children is required.")
id = randomid(content=f"{wrapped_content}:{popover_content}:{wrapped_classes}")
return Component(
attributes=attributes
+ [
("id", id),
("wrapped_content", wrapped_content),
("popover_content", popover_content),
("wrapped_classes", wrapped_classes),
],
children=children,
template="cotton/popover.html",
)
def PopoverTruncated(
input_string: str,
popover_content: str = "",
popover_if_not_truncated: bool = False,
length: int = 30,
ellipsis: str = "",
endpart: str = "",
) -> str:
"""
Returns `input_string` truncated after `length` of characters
and displays the untruncated text in a popover HTML element.
The truncated text ends in `ellipsis`, and optionally
an always-visible `endpart` can be specified.
`popover_content` can be specified if:
1. It needs to be always displayed regardless if text is truncated.
2. It needs to differ from `input_string`.
"""
if (truncated := truncate(input_string, length, ellipsis, endpart)) != input_string:
return Popover(
wrapped_content=truncated,
popover_content=popover_content if popover_content else input_string,
)
else:
if popover_content and popover_if_not_truncated:
return Popover(
wrapped_content=input_string,
popover_content=popover_content if popover_content else "",
)
else:
return input_string
def A(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
url_name: str | None = None,
href: str | None = None,
) -> SafeText:
"""
Returns an anchor <a> tag.
Accepts one of two mutually-exclusive URL specifications:
- url_name: URL pattern name, resolved via reverse()
- href: Literal path string passed through as-is
"""
attributes = attributes or []
children = children or []
if url_name is not None and href is not None:
raise ValueError("Provide exactly one of 'url_name' or 'href', not both.")
additional_attributes = []
if url_name is not None:
additional_attributes = [("href", reverse(url_name))]
elif href is not None:
additional_attributes = [("href", href)]
return Component(
tag_name="a", attributes=attributes + additional_attributes, children=children
)
def Button(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
size: str = "base",
icon: bool = False,
color: str = "blue",
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
template="cotton/button.html",
attributes=attributes
+ [
("size", size),
("icon", icon),
("color", color),
("class", "hover:cursor-pointer"),
],
children=children,
)
def Div(
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(tag_name="div", attributes=attributes, children=children)
def Input(
type: str = "text",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
tag_name="input", attributes=attributes + [("type", type)], children=children
)
def Form(
action="",
method="get",
attributes: list[HTMLAttribute] | None = None,
children: list[HTMLTag] | HTMLTag | None = None,
) -> SafeText:
attributes = attributes or []
children = children or []
return Component(
tag_name="form",
attributes=attributes + [("action", action), ("method", method)],
children=children,
)
def Icon(
name: str,
attributes: list[HTMLAttribute] | None = None,
) -> SafeText:
attributes = attributes or []
try:
result = Component(template=f"cotton/icon/{name}.html", attributes=attributes)
except TemplateDoesNotExist:
result = Icon(name="unspecified", attributes=attributes)
return result
def LinkedPurchase(purchase: Purchase) -> SafeText:
link = reverse("games:view_purchase", args=[int(purchase.id)])
link_content = ""
popover_content = ""
game_count = purchase.games.count()
popover_if_not_truncated = False
if game_count == 1:
link_content += purchase.games.first().name
popover_content = link_content
if game_count > 1:
if purchase.name:
link_content += f"{purchase.name}"
popover_content += f"<h1>{purchase.name}</h1><br>"
else:
link_content += f"{game_count} games"
popover_if_not_truncated = True
popover_content += f"""
<ul class="list-disc list-inside">
{"".join(f"<li>{game.name}</li>" for game in purchase.games.all())}
</ul>
"""
icon = purchase.platform.icon if game_count == 1 else "unspecified"
if link_content == "":
raise ValueError("link_content is empty!!")
a_content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
icon,
[("title", "Multiple")],
),
PopoverTruncated(
input_string=link_content,
popover_content=mark_safe(popover_content),
popover_if_not_truncated=popover_if_not_truncated,
),
],
)
return A(href=link, children=[a_content])
def NameWithIcon(
name: str = "",
game: Game | None = None,
session: Session | None = None,
linkify: bool = True,
emulated: bool = False,
) -> SafeText:
_name, platform, final_emulated, create_link, link = _resolve_name_with_icon(
name, game, session, linkify
)
content = Div(
[("class", "inline-flex gap-2 items-center")],
[
Icon(
platform.icon,
[("title", platform.name)],
)
if platform
else "",
Icon("emulated", [("title", "Emulated")]) if final_emulated else "",
PopoverTruncated(_name),
],
)
return (
A(
href=link,
children=[content],
)
if create_link
else content
)
def _resolve_name_with_icon(
name: str,
game: Game | None,
session: Session | None,
linkify: bool,
) -> tuple[str, Any, bool, bool, str]:
create_link = False
link = ""
platform = None
final_emulated = False
if session is not None:
game = session.game
platform = game.platform
final_emulated = session.emulated
if linkify:
create_link = True
link = reverse("games:view_game", args=[int(game.pk)])
elif game is not None:
platform = game.platform
if linkify:
create_link = True
link = reverse("games:view_game", args=[int(game.pk)])
_name = name or (game.name if game else "")
return _name, platform, final_emulated, create_link, link
def PurchasePrice(purchase) -> SafeText:
return Popover(
popover_content=f"{floatformat(purchase.price)} {purchase.price_currency}",
wrapped_content=f"{floatformat(purchase.converted_price)} {purchase.converted_currency}",
wrapped_classes="underline decoration-dotted",
)
-30
View File
@@ -1,30 +0,0 @@
import csv
from typing import TypeAlias
from games.models import Game
DataList: TypeAlias = list[dict[str, str]] | None
def read_csv(filename: str) -> DataList:
with open(filename, "r") as csvfile:
writer = csv.DictReader(csvfile)
return writer
def import_data(data: DataList):
matching_names = {}
for line in data:
name = line["name"]
if name not in matching_names:
# try exact match first
try:
game_id = Game.objects.get(name__iexact=name)
except:
pass
matching_names[name] = game_id
print(f"Exact matched {len(matching_names)} games.")
def import_from_file(filename: str):
import_data(read_csv(filename))
-233
View File
@@ -1,233 +0,0 @@
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
@plugin 'flowbite/plugin';
@source "../node_modules/flowbite";
@import "flowbite/src/themes/default";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans:
IBM Plex Sans, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--font-mono:
IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--font-serif:
IBM Plex Serif, ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-condensed:
IBM Plex Sans Condensed, ui-sans-serif, system-ui, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
--color-accent: #7c3aed;
--color-background: #1f2937;
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility min-w-20char {
min-width: 20ch;
}
@utility max-w-20char {
max-width: 20ch;
}
@utility min-w-30char {
min-width: 30ch;
}
@utility max-w-30char {
max-width: 30ch;
}
@utility max-w-35char {
max-width: 35ch;
}
@utility max-w-40char {
max-width: 40ch;
}
@layer 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;
}
@font-face {
font-family: 'IBM Plex Serif';
src: url('fonts/IBMPlexSerif-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: 'IBM Plex Sans Condensed';
src: url('fonts/IBMPlexSansCondensed-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
.responsive-table {
@apply dark:text-white mx-auto table-fixed;
}
.responsive-table tr:nth-child(even) {
@apply bg-indigo-100 dark:bg-slate-800;
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-indigo-200 dark: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;
}
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
}
.errorlist {
@apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
}
#button-container button {
@apply mx-1;
}
.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-sm shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-hidden focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}
.markdown-content ul {
list-style-type: disc;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ol {
list-style-type: decimal;
list-style-position: inside;
padding-left: 1em;
}
.markdown-content ul,
.markdown-content ol {
list-style-position: outside;
padding-left: 1em;
}
.markdown-content ul ul,
.markdown-content ul ol,
.markdown-content ol ul,
.markdown-content ol ol {
list-style-type: circle;
margin-top: 0.5em;
margin-bottom: 0.5em;
padding-left: 1em;
}
#add-form {
label + select, input, textarea {
@apply mt-1;
}
form {
@apply flex flex-col gap-3;
}
.form-row-button-group {
display: flex;
flex-direction: row;
@apply gap-0 p-0;
button {
@apply mr-0;
&:first-child {
@apply rounded-e-none;
}
&:nth-child(2) {
@apply rounded-none;
}
&:last-child {
@apply rounded-s-none;
}
}
}
label {
@apply mb-2.5 text-sm font-medium text-heading;
}
input:not([type="checkbox"]) {
@apply mb-3 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full px-3 py-2.5 shadow-xs placeholder:text-body;
}
input[type="checkbox"] {
@apply w-4 h-4 border border-default-medium rounded-xs bg-neutral-secondary-medium focus:ring-2 focus:ring-brand-soft;
}
select {
@apply w-full px-3 py-2.5 bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand shadow-xs placeholder:text-body;
}
textarea {
@apply bg-neutral-secondary-medium border border-default-medium text-heading text-sm rounded-base focus:ring-brand focus:border-brand block w-full p-3.5 shadow-xs placeholder:text-body;
}
:has(> label + input[type="checkbox"]) {
@apply mt-3; /* needed because compared to all other form elements checkbox and its label are on the same row */
display: flex;
flex-direction: row;
justify-content: space-between;
}
}
@layer utilities {
.toast-container {
@apply fixed z-50 flex flex-col items-end bottom-0 right-0 p-4;
}
}
-169
View File
@@ -1,169 +0,0 @@
import re
from datetime import date, datetime, timedelta
from django.utils import timezone
from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
return timedelta(0)
elif isinstance(duration, int):
return timedelta(seconds=duration)
elif isinstance(duration, timedelta):
return duration
def format_duration(
duration: timedelta | int | float | None, format_string: str = "%H hours"
) -> str:
"""
Format timedelta into the specified format_string.
Valid format variables:
- %H hours
- %m minutes
- %s seconds
- %r total seconds
Values don't change into higher units if those units are missing
from the formatting string. For example:
- 61 seconds as "%s" = 61 seconds
- 61 seconds as "%m %s" = 1 minutes 1 seconds"
Format specifiers can include width and precision options:
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
"""
minute_seconds = 60
hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds
safe_duration = _safe_timedelta(duration)
# we don't need float
seconds_total = int(safe_duration.total_seconds())
# timestamps where end is before start
if seconds_total < 0:
seconds_total = 0
days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total
if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds)
if re.search(r"%\d*\.?\d*H", format_string):
hours_float, remainder = divmod(remainder, hour_seconds)
hours = float(hours_float) + remainder / hour_seconds
if re.search(r"%\d*\.?\d*m", format_string):
minutes, seconds = divmod(remainder, minute_seconds)
literals = {
"d": str(days),
"H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes),
"s": str(seconds),
"r": str(seconds_total),
}
formatted_string = format_string
for pattern, replacement in literals.items():
# Match format specifiers with optional width and precision
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string
def local_strftime(datetime: datetime, format: str = datetimeformat) -> str:
return timezone.localtime(datetime).strftime(format)
def daterange(start: date, end: date, end_inclusive: bool = False) -> list[date]:
time_between: timedelta = end - start
if (days_between := time_between.days) < 1:
raise ValueError("start and end have to be at least 1 day apart.")
if end_inclusive:
print(f"{end_inclusive=}")
print(f"{days_between=}")
days_between += 1
print(f"{days_between=}")
return [start + timedelta(x) for x in range(days_between)]
def streak(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if len(datelist) == 1:
return {"days": 1, "dates": (datelist[0], datelist[0])}
else:
print(f"Processing {len(datelist)} dates.")
missing = sorted(
set(
datelist[0] + timedelta(x)
for x in range((datelist[-1] - datelist[0]).days)
)
- set(datelist)
)
print(f"{len(missing)} days missing.")
datelist_with_missing = sorted(datelist + missing)
ranges = list(generate_split_ranges(datelist_with_missing, missing))
print(f"{len(ranges)} ranges calculated.")
longest_consecutive_days = timedelta(0)
longest_range: tuple[date, date] = (date(1970, 1, 1), date(1970, 1, 1))
for start, end in ranges:
if (current_streak := end - start) > longest_consecutive_days:
longest_consecutive_days = current_streak
longest_range = (start, end)
return {"days": longest_consecutive_days.days + 1, "dates": longest_range}
def streak_bruteforce(datelist: list[date]) -> dict[str, int | tuple[date, date]]:
if (datelist_length := len(datelist)) == 0:
raise ValueError("Number of dates in the list is 0.")
datelist.sort()
current_streak = 1
current_start = datelist[0]
current_end = datelist[0]
current_date = datelist[0]
highest_streak = 1
highest_streak_daterange = (current_start, current_end)
def update_highest_streak():
nonlocal highest_streak, highest_streak_daterange
if current_streak > highest_streak:
highest_streak = current_streak
highest_streak_daterange = (current_start, current_end)
def reset_streak():
nonlocal current_start, current_end, current_streak
current_start = current_end = current_date
current_streak = 1
def increment_streak():
nonlocal current_end, current_streak
current_end = current_date
current_streak += 1
for i, datelist_item in enumerate(datelist, start=1):
current_date = datelist_item
if current_date == current_start or current_date == current_end:
continue
if current_date - timedelta(1) != current_end and i != datelist_length:
update_highest_streak()
reset_streak()
elif current_date - timedelta(1) == current_end and i == datelist_length:
increment_streak()
update_highest_streak()
else:
increment_streak()
return {"days": highest_streak, "dates": highest_streak_daterange}
def available_stats_year_range():
return range(datetime.now().year, 1999, -1)
-167
View File
@@ -1,167 +0,0 @@
import operator
from dataclasses import dataclass
from datetime import date
from functools import reduce, wraps
from typing import Any, Callable, Generator, Literal, TypeVar
from urllib.parse import urlencode
from django.db.models import Q
from django.http import HttpRequest
from django.shortcuts import redirect
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0
def safe_getattr(obj: object, attr_chain: str, default: Any | None = None) -> object:
"""
Safely get the nested attribute from an object.
Parameters:
obj (object): The object from which to retrieve the attribute.
attr_chain (str): The chain of attributes, separated by dots.
default: The default value to return if any attribute in the chain does not exist.
Returns:
The value of the nested attribute if it exists, otherwise the default value.
"""
attrs = attr_chain.split(".")
for attr in attrs:
try:
obj = getattr(obj, attr)
except AttributeError:
return default
return obj
def truncate_(input_string: str, length: int = 30, ellipsis: str = "") -> str:
return (
(f"{input_string[: length - len(ellipsis)].rstrip()}{ellipsis}")
if len(input_string) > length
else input_string
)
def truncate(
input_string: str, length: int = 30, ellipsis: str = "", endpart: str = ""
) -> str:
max_content_length = length - len(endpart)
if max_content_length < 0:
raise ValueError("Length cannot be shorter than the length of endpart.")
if len(input_string) > max_content_length:
return f"{input_string[: max_content_length - len(ellipsis)].rstrip()}{ellipsis}{endpart}"
return (
f"{input_string}{endpart}"
if len(input_string) + len(endpart) <= length
else f"{input_string[: length - len(ellipsis) - len(endpart)].rstrip()}{ellipsis}{endpart}"
)
T = TypeVar("T", str, int, date)
def generate_split_ranges(
value_list: list[T], split_points: list[T]
) -> Generator[tuple[T, T], None, None]:
for x in range(0, len(split_points) + 1):
if x == 0:
start = 0
elif x >= len(split_points):
start = value_list.index(split_points[x - 1]) + 1
else:
start = value_list.index(split_points[x - 1]) + 1
try:
end = value_list.index(split_points[x])
except IndexError:
end = len(value_list)
yield (value_list[start], value_list[end - 1])
def format_float_or_int(number: int | float):
return int(number) if float(number).is_integer() else f"{number:03.2f}"
OperatorType = Literal["|", "&"]
@dataclass
class FilterEntry:
condition: Q
operator: OperatorType = "&"
def build_dynamic_filter(
filters: list[FilterEntry | Q], default_operator: OperatorType = "&"
):
"""
Constructs a Django Q filter from a list of filter conditions.
Args:
filters (list): A list where each item is either:
- A Q object (default AND logic applied)
- A tuple of (Q object, operator) where operator is "|" (OR) or "&" (AND)
Returns:
Q: A combined Q object that can be passed to Django's filter().
"""
op_map: dict[OperatorType, Callable[[Q, Q], Q]] = {
"|": operator.or_,
"&": operator.and_,
}
# Convert all plain Q objects into (Q, "&") for default AND behavior
processed_filters = [
FilterEntry(f, default_operator) if isinstance(f, Q) else f for f in filters
]
# Reduce with dynamic operators
return reduce(
lambda combined_filters, filter: op_map[filter.operator](
combined_filters, filter.condition
),
processed_filters,
Q(),
)
def redirect_to(default_view: str, *default_args):
"""
A decorator that redirects the user back to the referring page or a default view if no 'next' parameter is provided.
:param default_view: The name of the default view to redirect to if 'next' is missing.
:param default_args: Any arguments required for the default view.
"""
def decorator(view_func):
@wraps(view_func)
def wrapped_view(request: HttpRequest, *args, **kwargs):
next_url = request.GET.get("next")
if not next_url:
from django.urls import (
reverse, # Import inside function to avoid circular imports
)
next_url = reverse(default_view, args=default_args)
response = view_func(
request, *args, **kwargs
) # Execute the original view logic
return redirect(next_url)
return wrapped_view
return decorator
def add_next_param_to_url(url: str, nexturl: str) -> str:
return f"{url}?{urlencode({'next': nexturl})}"
@@ -1,33 +0,0 @@
from datetime import datetime
import requests
url = "https://data.kurzy.cz/json/meny/b[6]den[{0}].json"
date_format = "%Y%m%d"
years = range(2000, datetime.now().year + 1)
dates = [
datetime.strftime(datetime(day=1, month=1, year=year), format=date_format)
for year in years
]
for date in dates:
final_url = url.format(date)
year = date[:4]
response = requests.get(final_url)
response.raise_for_status()
data = response.json()
if kurzy := data.get("kurzy"):
with open("output.yaml", mode="a") as o:
rates = [
f"""
- model: games.exchangerate
fields:
currency_from: {currency_name}
currency_to: CZK
year: {year}
rate: {kurzy.get(currency_name, {}).get("dev_stred", 0)}
"""
for currency_name in ["EUR", "USD", "CNY"]
if kurzy.get(currency_name)
]
o.writelines(rates)
# time.sleep(0.5)
-65
View File
@@ -1,65 +0,0 @@
import sys
import yaml
def load_yaml(filename):
with open(filename, "r", encoding="utf-8") as file:
return yaml.safe_load(file) or []
def save_yaml(filename, data):
with open(filename, "w", encoding="utf-8") as file:
yaml.safe_dump(data, file, allow_unicode=True, default_flow_style=False)
def extract_existing_combinations(data):
return {
(
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
for entry in data
if entry["model"] == "games.exchangerate"
}
def filter_new_entries(existing_combinations, additional_files):
new_entries = []
for filename in additional_files:
data = load_yaml(filename)
for entry in data:
if entry["model"] == "games.exchangerate":
key = (
entry["fields"]["currency_from"],
entry["fields"]["currency_to"],
entry["fields"]["year"],
)
if key not in existing_combinations:
new_entries.append(entry)
return new_entries
def main():
if len(sys.argv) < 3:
print("Usage: script.py example.yaml additions1.yaml [additions2.yaml ...]")
sys.exit(1)
example_file = sys.argv[1]
additional_files = sys.argv[2:]
output_file = "filtered_output.yaml"
existing_data = load_yaml(example_file)
existing_combinations = extract_existing_combinations(existing_data)
new_entries = filter_new_entries(existing_combinations, additional_files)
save_yaml(output_file, new_entries)
print(f"Filtered data saved to {output_file}")
if __name__ == "__main__":
main()
-24
View File
@@ -1,24 +0,0 @@
FROM python:3.13-slim
# Set up environment
ENV PYTHONUNBUFFERED=1
WORKDIR /workspace
# Install Poetry
RUN apt-get update && apt-get install -y \
curl \
make \
npm \
&& rm -rf /var/lib/apt/lists/*
RUN curl -sSL https://install.python-poetry.org | python3 -
ENV PATH="/root/.local/bin:$PATH"
# Copy pyproject.toml and poetry.lock for dependency installation
COPY pyproject.toml poetry.lock* ./
RUN poetry install --no-root
# Copy the rest of the application code
COPY . .
# Set up Django development server
EXPOSE 8000
-17
View File
@@ -1,17 +0,0 @@
---
services:
timetracker:
image: registry.kucharczyk.xyz/timetracker
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
# volumes:
# - "db:/home/timetracker/app/src/timetracker/db.sqlite3"
ports:
- "8000:8000"
restart: unless-stopped
+5 -18
View File
@@ -1,30 +1,17 @@
---
services:
backend:
timetracker:
image: registry.kucharczyk.xyz/timetracker
build:
context: .
dockerfile: Dockerfile
container_name: timetracker
environment:
- TZ=Europe/Prague
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000"
volumes:
- "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped
frontend:
image: caddy
volumes:
- "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile"
# volumes:
# - "db:/home/timetracker/app/src/web/db.sqlite3"
ports:
- "8000:8000"
depends_on:
- backend
volumes:
static-files:
restart: unless-stopped
+6 -16
View File
@@ -2,22 +2,12 @@
# Apply database migrations
set -euo pipefail
echo "Apply database migrations"
python manage.py migrate
poetry run python src/web/manage.py migrate
echo "Collect static files"
python manage.py collectstatic --clear --no-input
poetry run python src/web/manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
kill -SIGTERM "$django_q_pid"
}
trap _term SIGTERM
echo "Starting Django-Q cluster"
python manage.py qcluster & django_q_pid=$!
echo "Starting app"
python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid" "$django_q_pid"
echo "Starting server"
caddy start
cd src/web || exit
poetry run python -m gunicorn --bind 0.0.0.0:8001 web.asgi:application -k uvicorn.workers.UvicornWorker
-116
View File
@@ -1,116 +0,0 @@
from datetime import date, datetime
from typing import List
from django.contrib import messages
from django.shortcuts import get_object_or_404
from django.utils.timezone import now as django_timezone_now
from ninja import Field, ModelSchema, NinjaAPI, Router, Schema, Status
from games.models import Game, PlayEvent, Session
api = NinjaAPI()
playevent_router = Router()
game_router = Router()
NOW_FACTORY = django_timezone_now
class GameStatusUpdate(Schema):
status: str
class PlayEventIn(Schema):
game_id: int
started: date | None = None
ended: date | None = None
note: str = ""
days_to_finish: int | None = None
class AutoPlayEventIn(ModelSchema):
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note"]
class UpdatePlayEventIn(Schema):
started: date | None = None
ended: date | None = None
note: str = ""
class PlayEventOut(Schema):
id: int
game: str = Field(..., alias="game.name")
started: date | None = None
ended: date | None = None
days_to_finish: int | None = None
note: str = ""
updated_at: datetime
created_at: datetime
@game_router.patch("/{game_id}/status", response={204: None})
def partial_update_game(request, game_id: int, payload: GameStatusUpdate):
game = get_object_or_404(Game, id=game_id)
setattr(game, "status", payload.status)
game.save()
messages.success(request, "Status updated")
return Status(204, None)
@playevent_router.get("/", response=List[PlayEventOut])
def list_playevents(request):
return PlayEvent.objects.all()
@playevent_router.post("/", response={201: PlayEventOut})
def create_playevent(request, payload: PlayEventIn):
playevent = PlayEvent.objects.create(**payload.dict())
messages.success(request, "Game played!")
return playevent
@playevent_router.get("/{playevent_id}", response=PlayEventOut)
def get_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
return playevent
@playevent_router.patch("/{playevent_id}", response=PlayEventOut)
def partial_update_playevent(request, playevent_id: int, payload: UpdatePlayEventIn):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
for attr, value in payload.dict(exclude_unset=True).items():
setattr(playevent, attr, value)
playevent.save()
return playevent
@playevent_router.delete("/{playevent_id}", response={204: None})
def delete_playevent(request, playevent_id: int):
playevent = get_object_or_404(PlayEvent, id=playevent_id)
playevent.delete()
return Status(204, None)
api.add_router("/playevent", playevent_router)
api.add_router("/games", game_router)
session_router = Router()
class SessionDeviceUpdate(Schema):
device_id: int
@session_router.patch("/{session_id}/device", response={204: None})
def partial_update_session_device(request, session_id: int, payload: SessionDeviceUpdate):
session = get_object_or_404(Session, id=session_id)
session.device_id = payload.device_id
session.save()
messages.success(request, "Device updated")
return Status(204, None)
api.add_router("/session", session_router)
-46
View File
@@ -1,46 +0,0 @@
# from datetime import timedelta
from django.apps import AppConfig
from django.core.management import call_command
from django.db.models.signals import post_migrate
# from django.utils.timezone import now
class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "games"
def ready(self):
import games.signals # noqa: F401
post_migrate.connect(schedule_tasks, sender=self)
def schedule_tasks(sender, **kwargs):
# from django_q.models import Schedule
# from django_q.tasks import schedule
# if not Schedule.objects.filter(name="Update converted prices").exists():
# schedule(
# "games.tasks.convert_prices",
# name="Update converted prices",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
# if not Schedule.objects.filter(name="Update price per game").exists():
# schedule(
# "games.tasks.calculate_price_per_game",
# name="Update price per game",
# schedule_type=Schedule.MINUTES,
# next_run=now() + timedelta(seconds=30),
# catchup=False,
# )
from games.models import ExchangeRate
if not ExchangeRate.objects.exists():
print("ExchangeRate table is empty. Loading fixture...")
call_command("loaddata", "exchangerates.yaml")
File diff suppressed because it is too large Load Diff
-504
View File
@@ -1,504 +0,0 @@
- model: games.exchangerate
pk: 1
fields:
currency_from: USD
currency_to: CZK
year: 2024
rate: 23.4
- model: games.exchangerate
pk: 2
fields:
currency_from: CNY
currency_to: CZK
year: 2024
rate: 3.267
- model: games.exchangerate
pk: 3
fields:
currency_from: USD
currency_to: CZK
year: 2019
rate: 22.466
- model: games.exchangerate
pk: 4
fields:
currency_from: USD
currency_to: CZK
year: 2023
rate: 22.63
- model: games.exchangerate
pk: 5
fields:
currency_from: USD
currency_to: CZK
year: 2017
rate: 25.819
- model: games.exchangerate
pk: 6
fields:
currency_from: USD
currency_to: CZK
year: 2013
rate: 19.023
- model: games.exchangerate
pk: 7
fields:
currency_from: CNY
currency_to: CZK
year: 2019
rate: 3.295
- model: games.exchangerate
pk: 8
fields:
currency_from: CNY
currency_to: CZK
year: 2016
rate: 3.795
- model: games.exchangerate
pk: 9
fields:
currency_from: CNY
currency_to: CZK
year: 2015
rate: 3.707
- model: games.exchangerate
pk: 10
fields:
currency_from: CNY
currency_to: CZK
year: 2020
rate: 3.26
- model: games.exchangerate
pk: 11
fields:
currency_from: EUR
currency_to: CZK
year: 2012
rate: 25.51
- model: games.exchangerate
pk: 12
fields:
currency_from: EUR
currency_to: CZK
year: 2010
rate: 26.465
- model: games.exchangerate
pk: 13
fields:
currency_from: EUR
currency_to: CZK
year: 2014
rate: 27.52
- model: games.exchangerate
pk: 14
fields:
currency_from: EUR
currency_to: CZK
year: 2024
rate: 25.21
- model: games.exchangerate
pk: 15
fields:
currency_from: EUR
currency_to: CZK
year: 2022
rate: 24.325
- model: games.exchangerate
pk: 16
fields:
currency_from: CNY
currency_to: CZK
year: 2018
rate: 3.268
- model: games.exchangerate
pk: 17
fields:
currency_from: CNY
currency_to: CZK
year: 2023
rate: 3.281
- model: games.exchangerate
pk: 18
fields:
currency_from: EUR
currency_to: CZK
year: 2009
rate: 26.445
- model: games.exchangerate
pk: 19
fields:
currency_from: CNY
currency_to: CZK
year: 2025
rate: 3.35
- model: games.exchangerate
pk: 20
fields:
currency_from: EUR
currency_to: CZK
year: 2016
rate: 27.033
- model: games.exchangerate
pk: 21
fields:
currency_from: EUR
currency_to: CZK
year: 2025
rate: 25.2021966
- model: games.exchangerate
pk: 22
fields:
currency_from: EUR
currency_to: CZK
year: 2017
rate: 26.33
- model: games.exchangerate
pk: 23
fields:
currency_from: EUR
currency_to: CZK
year: 2000
rate: 36.13
- model: games.exchangerate
pk: 24
fields:
currency_from: USD
currency_to: CZK
year: 2000
rate: 35.979
- model: games.exchangerate
pk: 25
fields:
currency_from: EUR
currency_to: CZK
year: 2001
rate: 35.09
- model: games.exchangerate
pk: 26
fields:
currency_from: USD
currency_to: CZK
year: 2001
rate: 37.813
- model: games.exchangerate
pk: 27
fields:
currency_from: EUR
currency_to: CZK
year: 2002
rate: 31.98
- model: games.exchangerate
pk: 28
fields:
currency_from: USD
currency_to: CZK
year: 2002
rate: 36.259
- model: games.exchangerate
pk: 29
fields:
currency_from: EUR
currency_to: CZK
year: 2003
rate: 31.6
- model: games.exchangerate
pk: 30
fields:
currency_from: USD
currency_to: CZK
year: 2003
rate: 30.141
- model: games.exchangerate
pk: 31
fields:
currency_from: EUR
currency_to: CZK
year: 2004
rate: 32.405
- model: games.exchangerate
pk: 32
fields:
currency_from: USD
currency_to: CZK
year: 2004
rate: 25.654
- model: games.exchangerate
pk: 33
fields:
currency_from: EUR
currency_to: CZK
year: 2005
rate: 30.465
- model: games.exchangerate
pk: 34
fields:
currency_from: USD
currency_to: CZK
year: 2005
rate: 22.365
- model: games.exchangerate
pk: 35
fields:
currency_from: EUR
currency_to: CZK
year: 2006
rate: 29.005
- model: games.exchangerate
pk: 36
fields:
currency_from: USD
currency_to: CZK
year: 2006
rate: 24.588
- model: games.exchangerate
pk: 37
fields:
currency_from: CNY
currency_to: CZK
year: 2006
rate: 3.047
- model: games.exchangerate
pk: 38
fields:
currency_from: EUR
currency_to: CZK
year: 2007
rate: 27.495
- model: games.exchangerate
pk: 39
fields:
currency_from: USD
currency_to: CZK
year: 2007
rate: 20.876
- model: games.exchangerate
pk: 40
fields:
currency_from: CNY
currency_to: CZK
year: 2007
rate: 2.674
- model: games.exchangerate
pk: 41
fields:
currency_from: EUR
currency_to: CZK
year: 2008
rate: 26.62
- model: games.exchangerate
pk: 42
fields:
currency_from: USD
currency_to: CZK
year: 2008
rate: 18.078
- model: games.exchangerate
pk: 43
fields:
currency_from: CNY
currency_to: CZK
year: 2008
rate: 2.475
- model: games.exchangerate
pk: 44
fields:
currency_from: USD
currency_to: CZK
year: 2009
rate: 19.346
- model: games.exchangerate
pk: 45
fields:
currency_from: CNY
currency_to: CZK
year: 2009
rate: 2.836
- model: games.exchangerate
pk: 46
fields:
currency_from: USD
currency_to: CZK
year: 2010
rate: 18.368
- model: games.exchangerate
pk: 47
fields:
currency_from: CNY
currency_to: CZK
year: 2010
rate: 2.691
- model: games.exchangerate
pk: 48
fields:
currency_from: EUR
currency_to: CZK
year: 2011
rate: 25.06
- model: games.exchangerate
pk: 49
fields:
currency_from: USD
currency_to: CZK
year: 2011
rate: 18.751
- model: games.exchangerate
pk: 50
fields:
currency_from: CNY
currency_to: CZK
year: 2011
rate: 2.845
- model: games.exchangerate
pk: 51
fields:
currency_from: USD
currency_to: CZK
year: 2012
rate: 19.94
- model: games.exchangerate
pk: 52
fields:
currency_from: CNY
currency_to: CZK
year: 2012
rate: 3.168
- model: games.exchangerate
pk: 53
fields:
currency_from: EUR
currency_to: CZK
year: 2013
rate: 25.14
- model: games.exchangerate
pk: 54
fields:
currency_from: CNY
currency_to: CZK
year: 2013
rate: 3.059
- model: games.exchangerate
pk: 55
fields:
currency_from: USD
currency_to: CZK
year: 2014
rate: 19.894
- model: games.exchangerate
pk: 56
fields:
currency_from: CNY
currency_to: CZK
year: 2014
rate: 3.286
- model: games.exchangerate
pk: 57
fields:
currency_from: EUR
currency_to: CZK
year: 2015
rate: 27.725
- model: games.exchangerate
pk: 58
fields:
currency_from: USD
currency_to: CZK
year: 2015
rate: 22.834
- model: games.exchangerate
pk: 59
fields:
currency_from: USD
currency_to: CZK
year: 2016
rate: 24.824
- model: games.exchangerate
pk: 60
fields:
currency_from: CNY
currency_to: CZK
year: 2017
rate: 3.693
- model: games.exchangerate
pk: 61
fields:
currency_from: EUR
currency_to: CZK
year: 2018
rate: 25.54
- model: games.exchangerate
pk: 62
fields:
currency_from: USD
currency_to: CZK
year: 2018
rate: 21.291
- model: games.exchangerate
pk: 63
fields:
currency_from: EUR
currency_to: CZK
year: 2019
rate: 25.725
- model: games.exchangerate
pk: 64
fields:
currency_from: EUR
currency_to: CZK
year: 2020
rate: 25.41
- model: games.exchangerate
pk: 65
fields:
currency_from: USD
currency_to: CZK
year: 2020
rate: 22.621
- model: games.exchangerate
pk: 66
fields:
currency_from: EUR
currency_to: CZK
year: 2021
rate: 26.245
- model: games.exchangerate
pk: 67
fields:
currency_from: USD
currency_to: CZK
year: 2021
rate: 21.387
- model: games.exchangerate
pk: 68
fields:
currency_from: CNY
currency_to: CZK
year: 2021
rate: 3.273
- model: games.exchangerate
pk: 69
fields:
currency_from: USD
currency_to: CZK
year: 2022
rate: 21.951
- model: games.exchangerate
pk: 70
fields:
currency_from: CNY
currency_to: CZK
year: 2022
rate: 3.458
- model: games.exchangerate
pk: 71
fields:
currency_from: EUR
currency_to: CZK
year: 2023
rate: 24.115
- model: games.exchangerate
pk: 72
fields:
currency_from: USD
currency_to: CZK
year: 2025
rate: 24.237
-225
View File
@@ -1,225 +0,0 @@
name,platform,start,end
Nioh 2,PS5,2022-12-17 19:34,2022-12-17 22:53
Nioh 2,PS5,2022-12-15 02:25,2022-12-15 03:57
Nioh 2,PS5,2022-12-13 02:41,2022-12-13 04:34
Nioh 2,PS5,2022-12-11 21:01,2022-12-11 23:21
VALKYRIE ELYSIUM,PS5,2022-12-11 06:07,2022-12-11 06:20
Metal: Hellsinger,PS5,2022-12-11 05:50,2022-12-11 06:07
Nioh 2,PS5,2022-12-11 04:31,2022-12-11 05:50
Nioh 2,PS5,2022-12-11 04:11,2022-12-11 04:26
Forspoken,PS5,2022-12-10 22:29,2022-12-10 23:10
Nioh 2,PS5,2022-12-10 19:44,2022-12-10 22:29
Nioh 2,PS5,2022-12-09 02:14,2022-12-09 04:16
Nioh 2,PS5,2022-12-08 01:03,2022-12-08 01:57
Nioh 2,PS5,2022-12-07 00:43,2022-12-07 04:16
Nioh 2,PS5,2022-12-04 20:48,2022-12-04 23:31
Nioh 2,PS5,2022-12-04 04:26,2022-12-04 07:01
Nioh 2,PS5,2022-12-04 04:20,2022-12-04 04:22
Nioh 2,PS5,2022-11-26 19:18,2022-11-26 21:28
Nioh 2,PS5,2022-11-26 19:16,2022-11-26 19:18
Nioh 2,PS5,2022-11-26 02:46,2022-11-26 03:56
Nioh 2,PS5,2022-11-26 02:01,2022-11-26 02:43
God of War Ragnarök,PS5,2022-11-24 23:03,2022-11-25 01:32
God of War Ragnarök,PS5,2022-11-23 00:41,2022-11-23 07:52
God of War Ragnarök,PS5,2022-11-21 22:52,2022-11-22 04:51
God of War Ragnarök,PS5,2022-11-21 02:11,2022-11-21 05:13
God of War Ragnarök,PS5,2022-11-20 21:34,2022-11-20 22:50
God of War Ragnarök,PS5,2022-11-20 03:46,2022-11-20 05:52
God of War Ragnarök,PS5,2022-11-19 14:30,2022-11-19 16:14
God of War Ragnarök,PS5,2022-11-18 23:15,2022-11-19 04:16
God of War Ragnarök,PS5,2022-11-18 19:58,2022-11-18 20:40
God of War Ragnarök,PS5,2022-11-18 03:50,2022-11-18 06:25
God of War Ragnarök,PS5,2022-11-17 19:36,2022-11-18 00:26
God of War Ragnarök,PS5,2022-11-17 13:16,2022-11-17 16:13
God of War Ragnarök,PS5,2022-11-16 21:45,2022-11-16 22:38
God of War Ragnarök,PS5,2022-11-16 00:14,2022-11-16 04:28
God of War Ragnarök,PS5,2022-11-15 01:33,2022-11-15 05:06
God of War Ragnarök,PS5,2022-11-14 00:37,2022-11-14 04:43
God of War Ragnarök,PS5,2022-11-12 23:32,2022-11-13 03:45
God of War Ragnarök,PS5,2022-11-12 03:17,2022-11-12 05:00
Grand Theft Auto V (PlayStation®5),PS5,2022-10-03 02:01,2022-10-03 02:23
Grand Theft Auto V (PlayStation®5),PS5,2022-10-02 13:59,2022-10-02 15:54
Grand Theft Auto V (PlayStation®5),PS5,2022-09-30 22:40,2022-10-01 02:50
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 22:38,2022-09-28 00:16
Grand Theft Auto V (PlayStation®5),PS5,2022-09-27 19:27,2022-09-27 21:09
Grand Theft Auto V (PlayStation®5),PS5,2022-09-26 20:58,2022-09-26 23:38
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 23:56,2022-09-26 02:36
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 14:57,2022-09-25 16:38
Grand Theft Auto V (PlayStation®5),PS5,2022-09-25 02:04,2022-09-25 02:12
Grand Theft Auto V (PlayStation®5),PS5,2022-09-23 20:33,2022-09-23 23:38
Wo Long: Fallen Dynasty,PS5,2022-09-18 15:26,2022-09-18 16:58
Grand Theft Auto V (PlayStation®5),PS5,2022-08-31 00:42,2022-08-31 01:15
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 13:43,2022-08-18 15:12
Grand Theft Auto V (PlayStation®5),PS5,2022-08-18 00:42,2022-08-18 01:58
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-18 00:40,2022-08-18 00:42
Tony Hawk's™ Pro Skater™ 1 + 2,PS5,2022-08-14 15:46,2022-08-14 16:10
FINAL FANTASY VII,PS5,2022-07-26 18:56,2022-07-26 20:22
FINAL FANTASY VII REMAKE,PS5,2022-07-26 17:39,2022-07-26 18:53
FINAL FANTASY VII REMAKE,PS5,2022-07-25 22:12,2022-07-26 04:37
FINAL FANTASY VII REMAKE,PS5,2022-07-24 00:09,2022-07-24 05:33
FINAL FANTASY VII REMAKE,PS5,2022-07-23 23:34,2022-07-23 23:48
FINAL FANTASY VII REMAKE,PS5,2022-07-23 18:05,2022-07-23 19:44
FINAL FANTASY VII REMAKE,PS5,2022-07-22 17:07,2022-07-23 01:48
FINAL FANTASY VII REMAKE,PS5,2022-07-22 15:26,2022-07-22 15:59
FINAL FANTASY VII REMAKE,PS5,2022-07-21 21:27,2022-07-21 22:43
FINAL FANTASY VII REMAKE,PS5,2022-07-21 20:48,2022-07-21 20:58
FINAL FANTASY VII REMAKE,PS5,2022-07-21 18:35,2022-07-21 18:36
Stray,PS5,2022-07-21 01:24,2022-07-21 02:34
Stray,PS5,2022-07-19 23:49,2022-07-20 03:24
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-04 03:26,2022-07-04 03:34
Red Dead Redemption,PS3,2022-07-04 02:36,2022-07-04 03:14
Ghost of Tsushima,PS5,2022-07-04 01:38,2022-07-04 02:34
Dark Cloud™,PS4,2022-07-01 23:48,2022-07-02 00:04
Atelier Ayesha ~The Alchemist of Dusk~,PS3,2022-07-01 23:20,2022-07-01 23:46
Resident Evil Directors Cut,PS5,2022-07-01 23:14,2022-07-01 23:20
ELEX II,PS5,2022-07-01 22:48,2022-07-01 23:13
OlliOlli World,PS5,2022-07-01 21:30,2022-07-01 22:30
Deep Rock Galactic,PS5,2022-06-16 05:30,2022-06-16 06:14
Curse of the Dead Gods,PS5,2022-06-16 05:00,2022-06-16 05:22
Persona 5: Dancing in Starlight,PS5,2022-04-29 20:14,2022-04-29 20:15
Persona 5: Dancing in Starlight,PS5,2022-04-29 00:18,2022-04-29 00:44
Dying Light 2,PS5,2022-04-14 01:26,2022-04-14 01:27
Grand Theft Auto V (PlayStation®5),PS5,2022-03-24 16:26,2022-03-24 16:27
Grand Theft Auto V (PlayStation®5),PS5,2022-03-21 15:52,2022-03-21 15:59
Horizon Forbidden West,PS5,2022-02-23 19:37,2022-02-24 00:24
Horizon Forbidden West,PS5,2022-02-23 13:57,2022-02-23 17:44
Horizon Forbidden West,PS5,2022-02-22 18:05,2022-02-23 05:26
Horizon Forbidden West,PS5,2022-02-22 15:39,2022-02-22 17:02
Horizon Forbidden West,PS5,2022-02-22 00:05,2022-02-22 04:08
Horizon Forbidden West,PS5,2022-02-20 15:39,2022-02-20 23:08
Horizon Forbidden West,PS5,2022-02-20 14:54,2022-02-20 15:09
Horizon Forbidden West,PS5,2022-02-19 23:37,2022-02-20 04:45
Horizon Forbidden West,PS5,2022-02-18 23:15,2022-02-19 03:27
Assassin's Creed® Origins,PS5,2022-02-18 21:49,2022-02-18 23:15
Assassin's Creed® Origins,PS5,2022-01-17 02:38,2022-01-17 02:50
Deep Rock Galactic,PS5,2022-01-17 00:57,2022-01-17 02:35
HITMAN 3,PS5,2021-11-17 00:35,2021-11-17 01:17
HITMAN 3,PS5,2021-11-08 01:59,2021-11-08 06:17
HITMAN 3,PS5,2021-11-07 03:10,2021-11-07 05:23
HITMAN 3,PS5,2021-11-06 04:23,2021-11-06 08:49
HITMAN 3,PS5,2021-11-06 02:17,2021-11-06 03:31
HITMAN 3,PS5,2021-11-05 21:33,2021-11-05 23:24
HITMAN 3,PS5,2021-11-05 03:09,2021-11-05 03:34
HITMAN 3,PS5,2021-11-05 00:47,2021-11-05 02:26
HITMAN 3,PS5,2021-11-04 20:27,2021-11-04 23:32
HITMAN 3,PS5,2021-11-04 01:34,2021-11-04 05:33
RESIDENT EVIL 3,PS5,2021-11-03 23:14,2021-11-03 23:56
RESIDENT EVIL 3,PS5,2021-11-02 23:56,2021-11-03 05:10
RESIDENT EVIL 3,PS5,2021-11-02 21:22,2021-11-02 23:23
RESIDENT EVIL 3,PS5,2021-11-02 05:36,2021-11-02 06:56
HITMAN 3,PS5,2021-11-02 03:00,2021-11-02 05:36
HITMAN 3,PS5,2021-11-02 01:19,2021-11-02 01:25
HITMAN™ 2,PS5,2021-11-02 01:09,2021-11-02 01:19
HITMAN 3,PS5,2021-11-01 23:45,2021-11-02 01:09
RESIDENT EVIL 3,PS5,2021-11-01 19:32,2021-11-01 19:47
Marvel's Spider-Man: Miles Morales,PS5,2021-10-17 01:06,2021-10-17 03:27
Marvel's Spider-Man: Miles Morales,PS5,2021-10-16 20:58,2021-10-16 22:00
Marvel's Spider-Man: Miles Morales,PS5,2021-10-05 02:30,2021-10-05 03:27
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 23:12,2021-10-04 01:21
Marvel's Spider-Man: Miles Morales,PS5,2021-10-03 03:02,2021-10-03 04:42
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 20:12,2021-10-02 21:10
Marvel's Spider-Man: Miles Morales,PS5,2021-10-02 01:40,2021-10-02 03:36
Marvel's Spider-Man: Miles Morales,PS5,2021-10-01 04:34,2021-10-01 05:30
DEATHLOOP,PS5,2021-10-01 01:12,2021-10-01 04:27
DEATHLOOP,PS5,2021-09-30 03:04,2021-09-30 06:30
DEATHLOOP,PS5,2021-09-29 00:28,2021-09-29 05:08
Persona 5 Royal,PS5,2021-09-28 00:36,2021-09-28 03:08
Persona 5 Royal,PS5,2021-09-27 02:16,2021-09-27 05:56
Persona 5 Royal,PS5,2021-09-26 14:54,2021-09-26 16:32
Persona 5 Royal,PS5,2021-09-25 18:43,2021-09-25 23:26
Persona 5 Royal,PS5,2021-09-24 21:41,2021-09-25 03:40
Persona 5 Royal,PS5,2021-09-23 00:18,2021-09-23 06:26
Persona 5 Royal,PS5,2021-09-21 20:27,2021-09-22 05:43
Persona 5 Royal,PS5,2021-09-21 01:07,2021-09-21 06:06
Borderlands: The Handsome Collection,PS5,2021-09-20 23:59,2021-09-21 01:07
Persona 5 Royal,PS5,2021-09-20 23:53,2021-09-20 23:59
DEATHLOOP,PS5,2021-09-20 02:03,2021-09-20 06:29
DEATHLOOP,PS5,2021-09-19 19:49,2021-09-20 01:16
Borderlands: The Handsome Collection,PS5,2021-09-19 00:51,2021-09-19 03:41
Borderlands: The Handsome Collection,PS5,2021-09-17 23:45,2021-09-18 01:48
Borderlands: The Handsome Collection,PS5,2021-09-17 23:40,2021-09-17 23:41
DEATHLOOP,PS5,2021-09-17 16:48,2021-09-17 18:56
DEATHLOOP,PS5,2021-09-17 03:02,2021-09-17 04:39
DEATHLOOP,PS5,2021-09-17 00:03,2021-09-17 02:53
DEATHLOOP,PS5,2021-09-16 18:39,2021-09-16 21:12
Persona 5 Royal,PS5,2021-09-16 18:29,2021-09-16 18:30
Persona 5 Royal,PS5,2021-09-16 02:26,2021-09-16 06:13
Persona 5 Royal,PS5,2021-09-16 02:20,2021-09-16 02:21
Persona 5 Royal,PS5,2021-09-15 01:48,2021-09-15 06:07
Persona 5 Royal,PS5,2021-09-14 22:21,2021-09-15 01:22
Persona 5 Royal,PS5,2021-09-14 02:01,2021-09-14 05:48
Persona 5 Royal,PS5,2021-09-14 00:24,2021-09-14 01:46
Persona 5 Royal,PS5,2021-08-12 05:04,2021-08-12 07:05
Persona 5 Royal,PS5,2021-08-11 05:02,2021-08-11 06:48
Persona 5 Royal,PS5,2021-08-09 00:37,2021-08-09 06:15
Persona 5 Royal,PS5,2021-08-08 00:31,2021-08-08 08:01
Persona 5 Royal,PS5,2021-08-07 19:51,2021-08-07 22:50
Persona 5 Royal,PS5,2021-08-06 23:51,2021-08-07 01:35
Persona 5 Royal,PS5,2021-08-06 19:26,2021-08-06 22:26
Persona 5 Royal,PS5,2021-08-06 02:42,2021-08-06 06:51
Persona 5 Royal,PS5,2021-08-06 00:37,2021-08-06 01:54
Far Cry® 5,PS5,2021-08-01 23:27,2021-08-02 02:09
Far Cry® 5,PS5,2021-08-01 18:10,2021-08-01 19:40
STAR WARS™: Squadrons,PS5,2021-08-01 18:02,2021-08-01 18:10
STAR WARS™: Squadrons,PS5,2021-08-01 00:24,2021-08-01 00:30
STEEP,PS5,2021-08-01 00:15,2021-08-01 00:24
Red Dead Redemption 2,PS5,2021-07-31 23:48,2021-08-01 00:13
Persona 5 Royal,PS5,2021-07-30 19:09,2021-07-30 19:10
Persona 5 Royal,PS5,2021-07-29 03:41,2021-07-29 04:59
Persona 5 Royal,PS5,2021-07-28 02:32,2021-07-28 03:07
Demon's Souls,PS5,2021-07-28 00:12,2021-07-28 02:32
Red Dead Redemption 2,PS5,2021-07-27 23:20,2021-07-27 23:23
Red Dead Redemption 2,PS5,2021-07-26 00:42,2021-07-26 01:13
Ghost of Tsushima,PS5,2021-07-25 19:03,2021-07-25 22:12
Ghost of Tsushima,PS5,2021-07-25 18:52,2021-07-25 18:55
Rez Infinite,PS5,2021-07-25 18:32,2021-07-25 18:52
Returnal,PS5,2021-07-25 05:24,2021-07-25 05:26
Tom Clancy's The Division® 2,PS5,2021-07-25 02:12,2021-07-25 05:24
Returnal,PS5,2021-07-25 00:00,2021-07-25 02:12
Returnal,PS5,2021-07-24 16:13,2021-07-24 17:39
Returnal,PS5,2021-07-24 03:02,2021-07-24 07:02
Returnal,PS5,2021-07-23 18:08,2021-07-23 20:39
Returnal,PS5,2021-07-23 14:36,2021-07-23 15:38
Titanfall™ 2,PS5,2021-07-22 02:42,2021-07-22 03:20
Returnal,PS5,2021-07-20 02:12,2021-07-20 05:40
Returnal,PS5,2021-07-19 03:37,2021-07-19 05:24
Concrete Genie,PS5,2021-07-19 03:35,2021-07-19 03:37
Concrete Genie,PS5,2021-07-18 05:04,2021-07-18 05:30
Stranded Deep,PS5,2021-07-18 04:32,2021-07-18 04:58
Sniper Elite 4,PS5,2021-07-18 04:16,2021-07-18 04:32
Oddworld: Soulstorm,PS5,2021-07-18 04:00,2021-07-18 04:14
Zombie Army 4: Dead War,PS5,2021-07-18 03:48,2021-07-18 03:58
Sekiro™: Shadows Die Twice,PS5,2021-07-18 03:00,2021-07-18 03:48
Returnal,PS5,2021-07-17 17:41,2021-07-17 23:32
Returnal,PS5,2021-07-17 01:35,2021-07-17 06:07
Returnal,PS5,2021-07-17 00:23,2021-07-17 01:21
Another World - 20th Anniversary Edition,PS5,2021-07-15 22:09,2021-07-15 23:33
Sekiro™: Shadows Die Twice,PS5,2021-07-15 21:52,2021-07-15 22:09
Sekiro™: Shadows Die Twice,PS5,2021-07-15 19:07,2021-07-15 20:13
Sekiro™: Shadows Die Twice,PS5,2021-07-15 01:50,2021-07-15 03:31
Sekiro™: Shadows Die Twice,PS5,2021-07-15 00:12,2021-07-15 01:14
Sekiro™: Shadows Die Twice,PS5,2021-07-14 03:19,2021-07-14 05:13
Persona 5,PS5,2021-07-13 21:56,2021-07-13 21:58
Persona 5,PS5,2021-07-13 01:32,2021-07-13 02:59
Maquette,PS5,2021-07-13 01:30,2021-07-13 01:32
Maquette,PS5,2021-07-12 23:59,2021-07-13 00:34
ASTRO's PLAYROOM,PS5,2021-07-12 23:06,2021-07-12 23:59
Crash Bandicoot N. Sane Trilogy,PS5,2021-07-12 23:01,2021-07-12 23:06
Virtua Fighter 5 Ultimate Showdown,PS4,2021-07-02 19:46,2021-07-02 20:57
Bloodborne™,PS4,2021-04-03 19:50,2021-04-03 23:52
Tom Clancy's The Division® 2,PS4,2021-04-03 02:10,2021-04-03 05:22
Bloodborne™,PS4,2021-04-02 21:34,2021-04-03 02:10
Bloodborne™,PS4,2021-04-02 06:01,2021-04-02 08:11
Tom Clancy's The Division® 2,PS4,2021-04-02 04:24,2021-04-02 06:01
Tom Clancy's The Division® 2,PS4,2021-03-31 02:55,2021-03-31 05:50
Tom Clancy's The Division® 2,PS4,2021-03-29 02:00,2021-03-29 02:02
Tom Clancy's The Division® 2,PS4,2021-03-27 03:31,2021-03-27 06:29
Tom Clancy's The Division® 2,PS4,2021-03-26 05:10,2021-03-26 06:03
Remnant: From the Ashes,PS4,2021-03-23 03:20,2021-03-23 05:58
DARK SOULS™ II: Scholar of the First Sin,PS4,2021-03-20 01:31,2021-03-20 01:34
Remnant: From the Ashes,PS4,2021-03-12 01:29,2021-03-12 01:30
Remnant: From the Ashes,PS4,2021-03-08 02:41,2021-03-08 05:38
Remnant: From the Ashes,PS4,2021-03-07 03:21,2021-03-07 06:49
13 Sentinels: Aegis Rim,PS4,2021-03-07 03:20,2021-03-07 03:21
DARK SOULS™ II: Scholar of the First Sin,PS4,2020-10-24 23:43,2020-10-25 01:18
Ghost of Tsushima,PS4,2020-10-24 23:14,2020-10-24 23:22
1 name platform start end
2 Nioh 2 PS5 2022-12-17 19:34 2022-12-17 22:53
3 Nioh 2 PS5 2022-12-15 02:25 2022-12-15 03:57
4 Nioh 2 PS5 2022-12-13 02:41 2022-12-13 04:34
5 Nioh 2 PS5 2022-12-11 21:01 2022-12-11 23:21
6 VALKYRIE ELYSIUM PS5 2022-12-11 06:07 2022-12-11 06:20
7 Metal: Hellsinger PS5 2022-12-11 05:50 2022-12-11 06:07
8 Nioh 2 PS5 2022-12-11 04:31 2022-12-11 05:50
9 Nioh 2 PS5 2022-12-11 04:11 2022-12-11 04:26
10 Forspoken PS5 2022-12-10 22:29 2022-12-10 23:10
11 Nioh 2 PS5 2022-12-10 19:44 2022-12-10 22:29
12 Nioh 2 PS5 2022-12-09 02:14 2022-12-09 04:16
13 Nioh 2 PS5 2022-12-08 01:03 2022-12-08 01:57
14 Nioh 2 PS5 2022-12-07 00:43 2022-12-07 04:16
15 Nioh 2 PS5 2022-12-04 20:48 2022-12-04 23:31
16 Nioh 2 PS5 2022-12-04 04:26 2022-12-04 07:01
17 Nioh 2 PS5 2022-12-04 04:20 2022-12-04 04:22
18 Nioh 2 PS5 2022-11-26 19:18 2022-11-26 21:28
19 Nioh 2 PS5 2022-11-26 19:16 2022-11-26 19:18
20 Nioh 2 PS5 2022-11-26 02:46 2022-11-26 03:56
21 Nioh 2 PS5 2022-11-26 02:01 2022-11-26 02:43
22 God of War Ragnarök PS5 2022-11-24 23:03 2022-11-25 01:32
23 God of War Ragnarök PS5 2022-11-23 00:41 2022-11-23 07:52
24 God of War Ragnarök PS5 2022-11-21 22:52 2022-11-22 04:51
25 God of War Ragnarök PS5 2022-11-21 02:11 2022-11-21 05:13
26 God of War Ragnarök PS5 2022-11-20 21:34 2022-11-20 22:50
27 God of War Ragnarök PS5 2022-11-20 03:46 2022-11-20 05:52
28 God of War Ragnarök PS5 2022-11-19 14:30 2022-11-19 16:14
29 God of War Ragnarök PS5 2022-11-18 23:15 2022-11-19 04:16
30 God of War Ragnarök PS5 2022-11-18 19:58 2022-11-18 20:40
31 God of War Ragnarök PS5 2022-11-18 03:50 2022-11-18 06:25
32 God of War Ragnarök PS5 2022-11-17 19:36 2022-11-18 00:26
33 God of War Ragnarök PS5 2022-11-17 13:16 2022-11-17 16:13
34 God of War Ragnarök PS5 2022-11-16 21:45 2022-11-16 22:38
35 God of War Ragnarök PS5 2022-11-16 00:14 2022-11-16 04:28
36 God of War Ragnarök PS5 2022-11-15 01:33 2022-11-15 05:06
37 God of War Ragnarök PS5 2022-11-14 00:37 2022-11-14 04:43
38 God of War Ragnarök PS5 2022-11-12 23:32 2022-11-13 03:45
39 God of War Ragnarök PS5 2022-11-12 03:17 2022-11-12 05:00
40 Grand Theft Auto V (PlayStation®5) PS5 2022-10-03 02:01 2022-10-03 02:23
41 Grand Theft Auto V (PlayStation®5) PS5 2022-10-02 13:59 2022-10-02 15:54
42 Grand Theft Auto V (PlayStation®5) PS5 2022-09-30 22:40 2022-10-01 02:50
43 Grand Theft Auto V (PlayStation®5) PS5 2022-09-27 22:38 2022-09-28 00:16
44 Grand Theft Auto V (PlayStation®5) PS5 2022-09-27 19:27 2022-09-27 21:09
45 Grand Theft Auto V (PlayStation®5) PS5 2022-09-26 20:58 2022-09-26 23:38
46 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 23:56 2022-09-26 02:36
47 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 14:57 2022-09-25 16:38
48 Grand Theft Auto V (PlayStation®5) PS5 2022-09-25 02:04 2022-09-25 02:12
49 Grand Theft Auto V (PlayStation®5) PS5 2022-09-23 20:33 2022-09-23 23:38
50 Wo Long: Fallen Dynasty PS5 2022-09-18 15:26 2022-09-18 16:58
51 Grand Theft Auto V (PlayStation®5) PS5 2022-08-31 00:42 2022-08-31 01:15
52 Grand Theft Auto V (PlayStation®5) PS5 2022-08-18 13:43 2022-08-18 15:12
53 Grand Theft Auto V (PlayStation®5) PS5 2022-08-18 00:42 2022-08-18 01:58
54 Tony Hawk's™ Pro Skater™ 1 + 2 PS5 2022-08-18 00:40 2022-08-18 00:42
55 Tony Hawk's™ Pro Skater™ 1 + 2 PS5 2022-08-14 15:46 2022-08-14 16:10
56 FINAL FANTASY VII PS5 2022-07-26 18:56 2022-07-26 20:22
57 FINAL FANTASY VII REMAKE PS5 2022-07-26 17:39 2022-07-26 18:53
58 FINAL FANTASY VII REMAKE PS5 2022-07-25 22:12 2022-07-26 04:37
59 FINAL FANTASY VII REMAKE PS5 2022-07-24 00:09 2022-07-24 05:33
60 FINAL FANTASY VII REMAKE PS5 2022-07-23 23:34 2022-07-23 23:48
61 FINAL FANTASY VII REMAKE PS5 2022-07-23 18:05 2022-07-23 19:44
62 FINAL FANTASY VII REMAKE PS5 2022-07-22 17:07 2022-07-23 01:48
63 FINAL FANTASY VII REMAKE PS5 2022-07-22 15:26 2022-07-22 15:59
64 FINAL FANTASY VII REMAKE PS5 2022-07-21 21:27 2022-07-21 22:43
65 FINAL FANTASY VII REMAKE PS5 2022-07-21 20:48 2022-07-21 20:58
66 FINAL FANTASY VII REMAKE PS5 2022-07-21 18:35 2022-07-21 18:36
67 Stray PS5 2022-07-21 01:24 2022-07-21 02:34
68 Stray PS5 2022-07-19 23:49 2022-07-20 03:24
69 Atelier Ayesha ~The Alchemist of Dusk~ PS3 2022-07-04 03:26 2022-07-04 03:34
70 Red Dead Redemption PS3 2022-07-04 02:36 2022-07-04 03:14
71 Ghost of Tsushima PS5 2022-07-04 01:38 2022-07-04 02:34
72 Dark Cloud™ PS4 2022-07-01 23:48 2022-07-02 00:04
73 Atelier Ayesha ~The Alchemist of Dusk~ PS3 2022-07-01 23:20 2022-07-01 23:46
74 Resident Evil Director’s Cut PS5 2022-07-01 23:14 2022-07-01 23:20
75 ELEX II PS5 2022-07-01 22:48 2022-07-01 23:13
76 OlliOlli World PS5 2022-07-01 21:30 2022-07-01 22:30
77 Deep Rock Galactic PS5 2022-06-16 05:30 2022-06-16 06:14
78 Curse of the Dead Gods PS5 2022-06-16 05:00 2022-06-16 05:22
79 Persona 5: Dancing in Starlight PS5 2022-04-29 20:14 2022-04-29 20:15
80 Persona 5: Dancing in Starlight PS5 2022-04-29 00:18 2022-04-29 00:44
81 Dying Light 2 PS5 2022-04-14 01:26 2022-04-14 01:27
82 Grand Theft Auto V (PlayStation®5) PS5 2022-03-24 16:26 2022-03-24 16:27
83 Grand Theft Auto V (PlayStation®5) PS5 2022-03-21 15:52 2022-03-21 15:59
84 Horizon Forbidden West PS5 2022-02-23 19:37 2022-02-24 00:24
85 Horizon Forbidden West PS5 2022-02-23 13:57 2022-02-23 17:44
86 Horizon Forbidden West PS5 2022-02-22 18:05 2022-02-23 05:26
87 Horizon Forbidden West PS5 2022-02-22 15:39 2022-02-22 17:02
88 Horizon Forbidden West PS5 2022-02-22 00:05 2022-02-22 04:08
89 Horizon Forbidden West PS5 2022-02-20 15:39 2022-02-20 23:08
90 Horizon Forbidden West PS5 2022-02-20 14:54 2022-02-20 15:09
91 Horizon Forbidden West PS5 2022-02-19 23:37 2022-02-20 04:45
92 Horizon Forbidden West PS5 2022-02-18 23:15 2022-02-19 03:27
93 Assassin's Creed® Origins PS5 2022-02-18 21:49 2022-02-18 23:15
94 Assassin's Creed® Origins PS5 2022-01-17 02:38 2022-01-17 02:50
95 Deep Rock Galactic PS5 2022-01-17 00:57 2022-01-17 02:35
96 HITMAN 3 PS5 2021-11-17 00:35 2021-11-17 01:17
97 HITMAN 3 PS5 2021-11-08 01:59 2021-11-08 06:17
98 HITMAN 3 PS5 2021-11-07 03:10 2021-11-07 05:23
99 HITMAN 3 PS5 2021-11-06 04:23 2021-11-06 08:49
100 HITMAN 3 PS5 2021-11-06 02:17 2021-11-06 03:31
101 HITMAN 3 PS5 2021-11-05 21:33 2021-11-05 23:24
102 HITMAN 3 PS5 2021-11-05 03:09 2021-11-05 03:34
103 HITMAN 3 PS5 2021-11-05 00:47 2021-11-05 02:26
104 HITMAN 3 PS5 2021-11-04 20:27 2021-11-04 23:32
105 HITMAN 3 PS5 2021-11-04 01:34 2021-11-04 05:33
106 RESIDENT EVIL 3 PS5 2021-11-03 23:14 2021-11-03 23:56
107 RESIDENT EVIL 3 PS5 2021-11-02 23:56 2021-11-03 05:10
108 RESIDENT EVIL 3 PS5 2021-11-02 21:22 2021-11-02 23:23
109 RESIDENT EVIL 3 PS5 2021-11-02 05:36 2021-11-02 06:56
110 HITMAN 3 PS5 2021-11-02 03:00 2021-11-02 05:36
111 HITMAN 3 PS5 2021-11-02 01:19 2021-11-02 01:25
112 HITMAN™ 2 PS5 2021-11-02 01:09 2021-11-02 01:19
113 HITMAN 3 PS5 2021-11-01 23:45 2021-11-02 01:09
114 RESIDENT EVIL 3 PS5 2021-11-01 19:32 2021-11-01 19:47
115 Marvel's Spider-Man: Miles Morales PS5 2021-10-17 01:06 2021-10-17 03:27
116 Marvel's Spider-Man: Miles Morales PS5 2021-10-16 20:58 2021-10-16 22:00
117 Marvel's Spider-Man: Miles Morales PS5 2021-10-05 02:30 2021-10-05 03:27
118 Marvel's Spider-Man: Miles Morales PS5 2021-10-03 23:12 2021-10-04 01:21
119 Marvel's Spider-Man: Miles Morales PS5 2021-10-03 03:02 2021-10-03 04:42
120 Marvel's Spider-Man: Miles Morales PS5 2021-10-02 20:12 2021-10-02 21:10
121 Marvel's Spider-Man: Miles Morales PS5 2021-10-02 01:40 2021-10-02 03:36
122 Marvel's Spider-Man: Miles Morales PS5 2021-10-01 04:34 2021-10-01 05:30
123 DEATHLOOP PS5 2021-10-01 01:12 2021-10-01 04:27
124 DEATHLOOP PS5 2021-09-30 03:04 2021-09-30 06:30
125 DEATHLOOP PS5 2021-09-29 00:28 2021-09-29 05:08
126 Persona 5 Royal PS5 2021-09-28 00:36 2021-09-28 03:08
127 Persona 5 Royal PS5 2021-09-27 02:16 2021-09-27 05:56
128 Persona 5 Royal PS5 2021-09-26 14:54 2021-09-26 16:32
129 Persona 5 Royal PS5 2021-09-25 18:43 2021-09-25 23:26
130 Persona 5 Royal PS5 2021-09-24 21:41 2021-09-25 03:40
131 Persona 5 Royal PS5 2021-09-23 00:18 2021-09-23 06:26
132 Persona 5 Royal PS5 2021-09-21 20:27 2021-09-22 05:43
133 Persona 5 Royal PS5 2021-09-21 01:07 2021-09-21 06:06
134 Borderlands: The Handsome Collection PS5 2021-09-20 23:59 2021-09-21 01:07
135 Persona 5 Royal PS5 2021-09-20 23:53 2021-09-20 23:59
136 DEATHLOOP PS5 2021-09-20 02:03 2021-09-20 06:29
137 DEATHLOOP PS5 2021-09-19 19:49 2021-09-20 01:16
138 Borderlands: The Handsome Collection PS5 2021-09-19 00:51 2021-09-19 03:41
139 Borderlands: The Handsome Collection PS5 2021-09-17 23:45 2021-09-18 01:48
140 Borderlands: The Handsome Collection PS5 2021-09-17 23:40 2021-09-17 23:41
141 DEATHLOOP PS5 2021-09-17 16:48 2021-09-17 18:56
142 DEATHLOOP PS5 2021-09-17 03:02 2021-09-17 04:39
143 DEATHLOOP PS5 2021-09-17 00:03 2021-09-17 02:53
144 DEATHLOOP PS5 2021-09-16 18:39 2021-09-16 21:12
145 Persona 5 Royal PS5 2021-09-16 18:29 2021-09-16 18:30
146 Persona 5 Royal PS5 2021-09-16 02:26 2021-09-16 06:13
147 Persona 5 Royal PS5 2021-09-16 02:20 2021-09-16 02:21
148 Persona 5 Royal PS5 2021-09-15 01:48 2021-09-15 06:07
149 Persona 5 Royal PS5 2021-09-14 22:21 2021-09-15 01:22
150 Persona 5 Royal PS5 2021-09-14 02:01 2021-09-14 05:48
151 Persona 5 Royal PS5 2021-09-14 00:24 2021-09-14 01:46
152 Persona 5 Royal PS5 2021-08-12 05:04 2021-08-12 07:05
153 Persona 5 Royal PS5 2021-08-11 05:02 2021-08-11 06:48
154 Persona 5 Royal PS5 2021-08-09 00:37 2021-08-09 06:15
155 Persona 5 Royal PS5 2021-08-08 00:31 2021-08-08 08:01
156 Persona 5 Royal PS5 2021-08-07 19:51 2021-08-07 22:50
157 Persona 5 Royal PS5 2021-08-06 23:51 2021-08-07 01:35
158 Persona 5 Royal PS5 2021-08-06 19:26 2021-08-06 22:26
159 Persona 5 Royal PS5 2021-08-06 02:42 2021-08-06 06:51
160 Persona 5 Royal PS5 2021-08-06 00:37 2021-08-06 01:54
161 Far Cry® 5 PS5 2021-08-01 23:27 2021-08-02 02:09
162 Far Cry® 5 PS5 2021-08-01 18:10 2021-08-01 19:40
163 STAR WARS™: Squadrons PS5 2021-08-01 18:02 2021-08-01 18:10
164 STAR WARS™: Squadrons PS5 2021-08-01 00:24 2021-08-01 00:30
165 STEEP PS5 2021-08-01 00:15 2021-08-01 00:24
166 Red Dead Redemption 2 PS5 2021-07-31 23:48 2021-08-01 00:13
167 Persona 5 Royal PS5 2021-07-30 19:09 2021-07-30 19:10
168 Persona 5 Royal PS5 2021-07-29 03:41 2021-07-29 04:59
169 Persona 5 Royal PS5 2021-07-28 02:32 2021-07-28 03:07
170 Demon's Souls PS5 2021-07-28 00:12 2021-07-28 02:32
171 Red Dead Redemption 2 PS5 2021-07-27 23:20 2021-07-27 23:23
172 Red Dead Redemption 2 PS5 2021-07-26 00:42 2021-07-26 01:13
173 Ghost of Tsushima PS5 2021-07-25 19:03 2021-07-25 22:12
174 Ghost of Tsushima PS5 2021-07-25 18:52 2021-07-25 18:55
175 Rez Infinite PS5 2021-07-25 18:32 2021-07-25 18:52
176 Returnal PS5 2021-07-25 05:24 2021-07-25 05:26
177 Tom Clancy's The Division® 2 PS5 2021-07-25 02:12 2021-07-25 05:24
178 Returnal PS5 2021-07-25 00:00 2021-07-25 02:12
179 Returnal PS5 2021-07-24 16:13 2021-07-24 17:39
180 Returnal PS5 2021-07-24 03:02 2021-07-24 07:02
181 Returnal PS5 2021-07-23 18:08 2021-07-23 20:39
182 Returnal PS5 2021-07-23 14:36 2021-07-23 15:38
183 Titanfall™ 2 PS5 2021-07-22 02:42 2021-07-22 03:20
184 Returnal PS5 2021-07-20 02:12 2021-07-20 05:40
185 Returnal PS5 2021-07-19 03:37 2021-07-19 05:24
186 Concrete Genie PS5 2021-07-19 03:35 2021-07-19 03:37
187 Concrete Genie PS5 2021-07-18 05:04 2021-07-18 05:30
188 Stranded Deep PS5 2021-07-18 04:32 2021-07-18 04:58
189 Sniper Elite 4 PS5 2021-07-18 04:16 2021-07-18 04:32
190 Oddworld: Soulstorm PS5 2021-07-18 04:00 2021-07-18 04:14
191 Zombie Army 4: Dead War PS5 2021-07-18 03:48 2021-07-18 03:58
192 Sekiro™: Shadows Die Twice PS5 2021-07-18 03:00 2021-07-18 03:48
193 Returnal PS5 2021-07-17 17:41 2021-07-17 23:32
194 Returnal PS5 2021-07-17 01:35 2021-07-17 06:07
195 Returnal PS5 2021-07-17 00:23 2021-07-17 01:21
196 Another World - 20th Anniversary Edition PS5 2021-07-15 22:09 2021-07-15 23:33
197 Sekiro™: Shadows Die Twice PS5 2021-07-15 21:52 2021-07-15 22:09
198 Sekiro™: Shadows Die Twice PS5 2021-07-15 19:07 2021-07-15 20:13
199 Sekiro™: Shadows Die Twice PS5 2021-07-15 01:50 2021-07-15 03:31
200 Sekiro™: Shadows Die Twice PS5 2021-07-15 00:12 2021-07-15 01:14
201 Sekiro™: Shadows Die Twice PS5 2021-07-14 03:19 2021-07-14 05:13
202 Persona 5 PS5 2021-07-13 21:56 2021-07-13 21:58
203 Persona 5 PS5 2021-07-13 01:32 2021-07-13 02:59
204 Maquette PS5 2021-07-13 01:30 2021-07-13 01:32
205 Maquette PS5 2021-07-12 23:59 2021-07-13 00:34
206 ASTRO's PLAYROOM PS5 2021-07-12 23:06 2021-07-12 23:59
207 Crash Bandicoot N. Sane Trilogy PS5 2021-07-12 23:01 2021-07-12 23:06
208 Virtua Fighter 5 Ultimate Showdown PS4 2021-07-02 19:46 2021-07-02 20:57
209 Bloodborne™ PS4 2021-04-03 19:50 2021-04-03 23:52
210 Tom Clancy's The Division® 2 PS4 2021-04-03 02:10 2021-04-03 05:22
211 Bloodborne™ PS4 2021-04-02 21:34 2021-04-03 02:10
212 Bloodborne™ PS4 2021-04-02 06:01 2021-04-02 08:11
213 Tom Clancy's The Division® 2 PS4 2021-04-02 04:24 2021-04-02 06:01
214 Tom Clancy's The Division® 2 PS4 2021-03-31 02:55 2021-03-31 05:50
215 Tom Clancy's The Division® 2 PS4 2021-03-29 02:00 2021-03-29 02:02
216 Tom Clancy's The Division® 2 PS4 2021-03-27 03:31 2021-03-27 06:29
217 Tom Clancy's The Division® 2 PS4 2021-03-26 05:10 2021-03-26 06:03
218 Remnant: From the Ashes PS4 2021-03-23 03:20 2021-03-23 05:58
219 DARK SOULS™ II: Scholar of the First Sin PS4 2021-03-20 01:31 2021-03-20 01:34
220 Remnant: From the Ashes PS4 2021-03-12 01:29 2021-03-12 01:30
221 Remnant: From the Ashes PS4 2021-03-08 02:41 2021-03-08 05:38
222 Remnant: From the Ashes PS4 2021-03-07 03:21 2021-03-07 06:49
223 13 Sentinels: Aegis Rim PS4 2021-03-07 03:20 2021-03-07 03:21
224 DARK SOULS™ II: Scholar of the First Sin PS4 2020-10-24 23:43 2020-10-25 01:18
225 Ghost of Tsushima PS4 2020-10-24 23:14 2020-10-24 23:22
-71
View File
@@ -1,71 +0,0 @@
- model: games.game
pk: 1
fields:
name: Nioh 2
wikidata: Q67482292
- model: games.game
pk: 2
fields:
name: Elden Ring
wikidata: Q64826862
- model: games.game
pk: 3
fields:
name: Cyberpunk 2077
wikidata: Q3182559
- model: games.purchase
pk: 1
fields:
game: 1
platform: 1
date_purchased: 2021-02-13
date_refunded: null
- model: games.purchase
pk: 2
fields:
game: 2
platform: 1
date_purchased: 2022-02-24
date_refunded: null
- model: games.purchase
pk: 3
fields:
game: 3
platform: 1
date_purchased: 2020-12-07
date_refunded: null
- model: games.platform
pk: 1
fields:
name: Steam
group: PC
- model: games.platform
pk: 3
fields:
name: Xbox Gamepass
group: PC
- model: games.platform
pk: 4
fields:
name: Epic Games Store
group: PC
- model: games.platform
pk: 5
fields:
name: Playstation 5
group: Playstation
- model: games.platform
pk: 6
fields:
name: Playstation 4
group: Playstation
- model: games.platform
pk: 7
fields:
name: Nintendo Switch
group: Nintendo
- model: games.platform
pk: 8
fields:
name: Nintendo 3DS
group: Nintendo
-267
View File
@@ -1,267 +0,0 @@
from django import forms
from django.db import transaction
from django.urls import reverse
from common.utils import safe_getattr
from games.models import (
Device,
Game,
GameStatusChange,
Platform,
PlayEvent,
Purchase,
Session,
)
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class MultipleGameChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SingleGameChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
class SessionForm(forms.ModelForm):
game = SingleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
duration_manual = forms.DurationField(
required=False,
widget=forms.TextInput(
attrs={"x-mask": "99:99:99", "placeholder": "HH:MM:SS", "x-data": ""}
),
label="Manual duration",
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
mark_as_played = forms.BooleanField(
required=False,
initial={"mark_as_played": True},
label="Set game status to Played if Unplayed",
)
class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session
fields = [
"game",
"timestamp_start",
"timestamp_end",
"duration_manual",
"emulated",
"device",
"note",
"mark_as_played",
]
def save(self, commit=True):
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_played"):
game_instance = session.game
if game_instance.status == "u":
game_instance.status = "p"
if commit:
game_instance.save()
if commit:
session.save()
return session
class IncludePlatformSelect(forms.SelectMultiple):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if platform_id := safe_getattr(value, "instance.platform.id"):
option["attrs"]["data-platform"] = platform_id
return option
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 game.
related_purchase_by_game_url = reverse("games:related_purchase_by_game")
self.fields["games"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_game_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
games = MultipleGameChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME),
required=False,
)
price_currency = forms.CharField(
widget=forms.TextInput(
attrs={
"x-mask": "aaa",
"placeholder": "CZK",
"x-data": "",
"class": "uppercase",
}
),
label="Currency",
)
class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
}
model = Purchase
fields = [
"games",
"platform",
"date_purchased",
"date_refunded",
"infinite",
"price",
"price_currency",
"ownership_type",
"type",
"related_purchase",
"name",
]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
if value:
option["attrs"]["data-name"] = value.instance.name
option["attrs"]["data-year"] = value.instance.year_released
return option
class GameModelChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj):
# Use sort_name as the label for the option
return obj.sort_name
class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Game
fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm):
class Meta:
model = Platform
fields = [
"name",
"icon",
"group",
]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
class PlayEventForm(forms.ModelForm):
game = GameModelChoiceField(
queryset=Game.objects.order_by("sort_name"),
widget=forms.Select(attrs={"autofocus": "autofocus"}),
)
mark_as_finished = forms.BooleanField(
required=False,
initial={"mark_as_finished": True},
label="Set game status to Finished",
)
class Meta:
model = PlayEvent
fields = ["game", "started", "ended", "note", "mark_as_finished"]
widgets = {
"started": custom_date_widget,
"ended": custom_date_widget,
}
def save(self, commit=True):
with transaction.atomic():
session = super().save(commit=False)
if self.cleaned_data.get("mark_as_finished"):
game_instance = session.game
game_instance.status = "f"
game_instance.save()
session.save()
return session
class GameStatusChangeForm(forms.ModelForm):
class Meta:
model = GameStatusChange
fields = [
"game",
"old_status",
"new_status",
"timestamp",
]
widgets = {
"timestamp": custom_datetime_widget,
}
-64
View File
@@ -1,64 +0,0 @@
import json
from django.conf import settings
from django.contrib import messages as django_messages
from django.contrib.messages import constants as message_constants
MESSAGE_LEVEL_MAP = {
message_constants.DEBUG: "debug",
message_constants.INFO: "info",
message_constants.SUCCESS: "success",
message_constants.WARNING: "warning",
message_constants.ERROR: "error",
}
class HTMXMessagesMiddleware:
"""
Converts Django messages into HX-Trigger headers so toasts display
automatically without changes to views.
Works for HTMX requests (processed natively by HTMX client),
vanilla fetch() calls using fetchWithHtmxTriggers(), and is harmless
for full-page loads (browsers ignore HX-Trigger).
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
# Skip HX-Trigger and don't consume messages if there's an HX-Redirect
# so the message persists in the session for the redirect target page
if "HX-Redirect" in response:
return response
min_level = message_constants.DEBUG if settings.DEBUG else message_constants.INFO
backend = django_messages.get_messages(request)
if hasattr(backend, '_set_level') and backend._get_level() > min_level:
backend._set_level(min_level)
messages = list(backend)
if not messages:
return response
triggers = []
for msg in messages:
toast_type = MESSAGE_LEVEL_MAP.get(msg.level, "info")
triggers.append(
{
"message": msg.message,
"type": toast_type,
}
)
if triggers:
# Use last message (most recent) as the primary toast
trigger = triggers[-1]
response["HX-Trigger"] = json.dumps(
{
"show-toast": trigger,
}
)
return response
@@ -1,24 +0,0 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.utils.timezone import now
from django_q.models import Schedule
from django_q.tasks import schedule
class Command(BaseCommand):
help = "Manually schedule the next update_converted_prices task"
def handle(self, *args, **kwargs):
if not Schedule.objects.filter(name="Update converted prices").exists():
schedule(
"games.tasks.convert_prices",
name="Update converted prices",
schedule_type=Schedule.MINUTES,
next_run=now() + timedelta(seconds=30),
)
self.stdout.write(
self.style.SUCCESS("Scheduled the update_converted_prices task.")
)
else:
self.stdout.write(self.style.WARNING("Task is already scheduled."))
-104
View File
@@ -1,104 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-29 21:26
import datetime
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Device',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('type', models.CharField(choices=[('PC', 'PC'), ('Console', 'Console'), ('Handheld', 'Handheld'), ('Mobile', 'Mobile'), ('Single-board computer', 'Single-board computer'), ('Unknown', 'Unknown')], default='Unknown', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Platform',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
('icon', models.SlugField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='ExchangeRate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('currency_from', models.CharField(max_length=255)),
('currency_to', models.CharField(max_length=255)),
('year', models.PositiveIntegerField()),
('rate', models.FloatField()),
],
options={
'unique_together': {('currency_from', 'currency_to', 'year')},
},
),
migrations.CreateModel(
name='Game',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('sort_name', models.CharField(blank=True, default=None, max_length=255, null=True)),
('year_released', models.IntegerField(blank=True, default=None, null=True)),
('wikidata', models.CharField(blank=True, default=None, max_length=50, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.platform')),
],
options={
'unique_together': {('name', 'platform', 'year_released')},
},
),
migrations.CreateModel(
name='Purchase',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_purchased', models.DateField()),
('date_refunded', models.DateField(blank=True, null=True)),
('date_finished', models.DateField(blank=True, null=True)),
('date_dropped', models.DateField(blank=True, null=True)),
('infinite', models.BooleanField(default=False)),
('price', models.FloatField(default=0)),
('price_currency', models.CharField(default='USD', max_length=3)),
('converted_price', models.FloatField(null=True)),
('converted_currency', models.CharField(max_length=3, null=True)),
('ownership_type', models.CharField(choices=[('ph', 'Physical'), ('di', 'Digital'), ('du', 'Digital Upgrade'), ('re', 'Rented'), ('bo', 'Borrowed'), ('tr', 'Trial'), ('de', 'Demo'), ('pi', 'Pirated')], default='di', max_length=2)),
('type', models.CharField(choices=[('game', 'Game'), ('dlc', 'DLC'), ('season_pass', 'Season Pass'), ('battle_pass', 'Battle Pass')], default='game', max_length=255)),
('name', models.CharField(blank=True, default='', max_length=255, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('games', models.ManyToManyField(blank=True, related_name='purchases', to='games.game')),
('platform', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='games.platform')),
('related_purchase', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase')),
],
),
migrations.CreateModel(
name='Session',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('timestamp_start', models.DateTimeField()),
('timestamp_end', models.DateTimeField(blank=True, null=True)),
('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
('duration_calculated', models.DurationField(blank=True, null=True)),
('note', models.TextField(blank=True, null=True)),
('emulated', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('modified_at', models.DateTimeField(auto_now=True)),
('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
],
options={
'get_latest_by': 'timestamp_start',
},
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.FloatField(null=True),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:12
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0002_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]
@@ -1,28 +0,0 @@
# Generated by Django 5.1.5 on 2025-01-30 11:57
from django.db import migrations, models
from django.db.models import Count
def initialize_num_purchases(apps, schema_editor):
Purchase = apps.get_model("games", "Purchase")
purchases = Purchase.objects.annotate(num_games=Count("games"))
for purchase in purchases:
purchase.num_purchases = purchase.num_games
purchase.save(update_fields=["num_purchases"])
class Migration(migrations.Migration):
dependencies = [
("games", "0003_purchase_updated_at"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="num_purchases",
field=models.IntegerField(default=0),
),
migrations.RunPython(initialize_num_purchases),
]
@@ -1,38 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-01 19:18
from django.db import migrations, models
def set_finished_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.filter(purchases__date_finished__isnull=False).update(status="f")
class Migration(migrations.Migration):
dependencies = [
("games", "0004_purchase_num_purchases"),
]
operations = [
migrations.AddField(
model_name="game",
name="mastered",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="game",
name="status",
field=models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
default="u",
max_length=1,
),
),
migrations.RunPython(set_finished_status),
]
@@ -1,59 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-01 12:52
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0005_game_mastered_game_status'),
]
operations = [
migrations.AlterField(
model_name='game',
name='sort_name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='game',
name='wikidata',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='platform',
name='group',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='converted_currency',
field=models.CharField(blank=True, default='', max_length=3),
),
migrations.AlterField(
model_name='purchase',
name='games',
field=models.ManyToManyField(related_name='purchases', to='games.game'),
),
migrations.AlterField(
model_name='purchase',
name='name',
field=models.CharField(blank=True, default='', max_length=255),
),
migrations.AlterField(
model_name='purchase',
name='related_purchase',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_purchases', to='games.purchase'),
),
migrations.AlterField(
model_name='session',
name='game',
field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game'),
),
migrations.AlterField(
model_name='session',
name='note',
field=models.TextField(blank=True, default=''),
),
]
-18
View File
@@ -1,18 +0,0 @@
# Generated by Django 5.1.5 on 2025-03-17 07:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0006_alter_game_sort_name_alter_game_wikidata_and_more'),
]
operations = [
migrations.AddField(
model_name='game',
name='updated_at',
field=models.DateTimeField(auto_now=True),
),
]
@@ -1,190 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-19 13:11
import django.db.models.deletion
import django.db.models.expressions
from django.db import migrations, models
from django.db.models import F, Min
def copy_year_released(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game.objects.update(original_year_released=F("year_released"))
def set_abandoned_status(apps, schema_editor):
Game = apps.get_model("games", "Game")
Game = apps.get_model("games", "Game")
PlayEvent = apps.get_model("games", "PlayEvent")
Game.objects.filter(purchases__date_refunded__isnull=False).update(status="a")
Game.objects.filter(purchases__date_dropped__isnull=False).update(status="a")
finished = Game.objects.filter(purchases__date_finished__isnull=False)
for game in finished:
for purchase in game.purchases.all():
first_session = game.sessions.filter(
timestamp_start__gte=purchase.date_purchased
).aggregate(Min("timestamp_start"))["timestamp_start__min"]
first_session_date = first_session.date() if first_session else None
if purchase.date_finished:
play_event = PlayEvent(
game=game,
started=first_session_date
if first_session_date
else purchase.date_purchased,
ended=purchase.date_finished,
)
play_event.save()
def create_game_status_changes(apps, schema_editor):
Game = apps.get_model("games", "Game")
GameStatusChange = apps.get_model("games", "GameStatusChange")
# if game has any sessions, find the earliest session and create a status change from unplayed to played with that sessions's timestamp_start
for game in Game.objects.filter(sessions__isnull=False).distinct():
if game.sessions.exists():
earliest_session = game.sessions.earliest()
GameStatusChange.objects.create(
game=game,
old_status="u",
new_status="p",
timestamp=earliest_session.timestamp_start,
)
for game in Game.objects.filter(purchases__date_dropped__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_dropped,
)
for game in Game.objects.filter(purchases__date_refunded__isnull=False):
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="a",
timestamp=game.purchases.first().date_refunded,
)
# check if game has any playevents, if so create a status change from current status to finished based on playevent's ended date
# consider only the first playevent
for game in Game.objects.filter(playevents__isnull=False):
first_playevent = game.playevents.first()
GameStatusChange.objects.create(
game=game,
old_status="p",
new_status="f",
timestamp=first_playevent.ended,
)
class Migration(migrations.Migration):
dependencies = [
("games", "0007_game_updated_at"),
]
operations = [
migrations.AddField(
model_name="game",
name="original_year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(copy_year_released),
migrations.CreateModel(
name="GameStatusChange",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"old_status",
models.CharField(
blank=True,
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
null=True,
),
),
(
"new_status",
models.CharField(
choices=[
("u", "Unplayed"),
("p", "Played"),
("f", "Finished"),
("r", "Retired"),
("a", "Abandoned"),
],
max_length=1,
),
),
("timestamp", models.DateTimeField(null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="status_changes",
to="games.game",
),
),
],
options={
"ordering": ["-timestamp"],
},
),
migrations.CreateModel(
name="PlayEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("started", models.DateField(blank=True, null=True)),
("ended", models.DateField(blank=True, null=True)),
(
"days_to_finish",
models.GeneratedField(
db_persist=True,
expression=django.db.models.expressions.RawSQL(
"\n COALESCE(\n CASE \n WHEN date(ended) = date(started) THEN 1\n ELSE julianday(ended) - julianday(started)\n END, 0\n )\n ",
[],
),
output_field=models.IntegerField(),
),
),
("note", models.CharField(blank=True, default="", max_length=255)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="playevents",
to="games.game",
),
),
],
),
migrations.RunPython(set_abandoned_status),
migrations.RunPython(create_game_status_changes),
]
@@ -1,21 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-20 11:35
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0008_game_original_year_released_gamestatuschange_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='date_dropped',
),
migrations.RemoveField(
model_name='purchase',
name='date_finished',
),
]
@@ -1,17 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('games', '0009_remove_purchase_date_dropped_and_more'),
]
operations = [
migrations.RemoveField(
model_name='purchase',
name='price_per_game',
),
]
@@ -1,20 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-22 17:46
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0010_remove_purchase_price_per_game'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='price_per_game',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(django.db.models.functions.comparison.Coalesce(models.F('converted_price'), models.F('price'), 0), '/', models.F('num_purchases')), output_field=models.FloatField()),
),
]
@@ -1,32 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:30
import django.db.models.expressions
import django.db.models.functions.comparison
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_purchase_price_per_game"),
]
operations = [
migrations.RemoveField(
model_name="session",
name="duration_calculated",
),
migrations.AddField(
model_name="session",
name="duration_calculated",
field=models.GeneratedField(
db_persist=True,
expression=django.db.models.functions.comparison.Coalesce(
django.db.models.expressions.CombinedExpression(
models.F("timestamp_end"), "-", models.F("timestamp_start")
),
0,
),
output_field=models.DurationField(),
),
),
]
-35
View File
@@ -1,35 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:33
import datetime
from django.db import migrations, models
from django.db.models import F, Sum
def calculate_game_playtime(apps, schema_editor):
Game = apps.get_model("games", "Game")
games = Game.objects.all()
for game in games:
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_total"))
)["total_playtime"]
if total_playtime:
game.playtime = total_playtime
game.save(update_fields=["playtime"])
class Migration(migrations.Migration):
dependencies = [
("games", "0012_alter_session_duration_calculated"),
]
operations = [
migrations.AddField(
model_name="game",
name="playtime",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), editable=False
),
),
migrations.RunPython(calculate_game_playtime),
]
@@ -1,19 +0,0 @@
# Generated by Django 5.1.7 on 2025-03-25 20:46
import django.db.models.expressions
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0013_game_playtime'),
]
operations = [
migrations.AddField(
model_name='session',
name='duration_total',
field=models.GeneratedField(db_persist=True, expression=django.db.models.expressions.CombinedExpression(models.F('duration_calculated'), '+', models.F('duration_manual')), output_field=models.DurationField()),
),
]
@@ -1,39 +0,0 @@
# Generated by Django 5.1.7 on 2026-01-15 15:37
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0014_session_duration_total'),
]
operations = [
migrations.AlterField(
model_name='purchase',
name='date_purchased',
field=models.DateField(verbose_name='Purchased'),
),
migrations.AlterField(
model_name='purchase',
name='date_refunded',
field=models.DateField(blank=True, null=True, verbose_name='Refunded'),
),
migrations.AlterField(
model_name='session',
name='duration_manual',
field=models.DurationField(blank=True, default=datetime.timedelta(0), null=True, verbose_name='Manual duration'),
),
migrations.AlterField(
model_name='session',
name='timestamp_end',
field=models.DateTimeField(blank=True, null=True, verbose_name='End'),
),
migrations.AlterField(
model_name='session',
name='timestamp_start',
field=models.DateTimeField(verbose_name='Start'),
),
]
@@ -1,22 +0,0 @@
# Generated by Django 6.0.1 on 2026-05-12 11:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('games', '0015_alter_purchase_date_purchased_and_more'),
]
operations = [
migrations.AddField(
model_name='purchase',
name='needs_price_update',
field=models.BooleanField(db_index=True, default=True),
),
migrations.RunSQL(
"UPDATE games_purchase SET needs_price_update = FALSE WHERE converted_price IS NOT NULL AND converted_currency != ''",
reverse_sql="UPDATE games_purchase SET needs_price_update = TRUE WHERE converted_price IS NOT NULL AND converted_currency != ''",
),
]
-483
View File
@@ -1,483 +0,0 @@
import logging
from datetime import timedelta
import requests
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Q, Sum
from django.db.models.expressions import RawSQL
from django.db.models.fields.generated import GeneratedField
from django.db.models.functions import Coalesce
from django.template.defaultfilters import floatformat, pluralize, slugify
from django.utils import timezone
from common.time import format_duration
logger = logging.getLogger("games")
class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255)
sort_name = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(null=True, blank=True, default=None)
original_year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, blank=True, default="")
platform = models.ForeignKey(
"Platform", on_delete=models.SET_DEFAULT, null=True, blank=True, default=None
)
playtime = models.DurationField(blank=True, editable=False, default=timedelta(0))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Status(models.TextChoices):
UNPLAYED = (
"u",
"Unplayed",
)
PLAYED = (
"p",
"Played",
)
FINISHED = (
"f",
"Finished",
)
RETIRED = (
"r",
"Retired",
)
ABANDONED = (
"a",
"Abandoned",
)
status = models.CharField(max_length=1, choices=Status, default=Status.UNPLAYED)
mastered = models.BooleanField(default=False)
session_average: float | int | timedelta | None
session_count: int | None
def __str__(self):
return self.name
def finished(self):
return (self.status == self.Status.FINISHED or
self.playevents.filter(ended__isnull=False).exists())
def abandoned(self):
return self.status == self.Status.ABANDONED
def retired(self):
return self.status == self.Status.RETIRED
def played(self):
return self.status == self.Status.PLAYED
def unplayed(self):
return self.status == self.Status.UNPLAYED
def playtime_formatted(self):
return format_duration(self.playtime, "%2.1H")
def save(self, *args, **kwargs):
if self.platform is None:
self.platform = get_sentinel_platform()
super().save(*args, **kwargs)
def get_sentinel_platform():
return Platform.objects.get_or_create(
name="Unspecified", icon="unspecified", group="Unspecified"
)[0]
class Platform(models.Model):
name = models.CharField(max_length=255)
group = models.CharField(max_length=255, blank=True, default="")
icon = models.SlugField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.name
def save(self, *args, **kwargs):
if not self.icon:
self.icon = slugify(self.name)
super().save(*args, **kwargs)
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def games_only(self):
return self.filter(type=Purchase.GAME)
def finished(self):
return self.filter(
Q(games__status="f") | Q(games__playevents__ended__isnull=False)
).distinct()
def abandoned(self):
return self.filter(games__status="a").distinct()
def dropped(self):
return self.filter(
Q(games__status="a") | Q(date_refunded__isnull=False)
).distinct()
class Purchase(models.Model):
PHYSICAL = "ph"
DIGITAL = "di"
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
objects = PurchaseQueryset().as_manager()
games = models.ManyToManyField(Game, related_name="purchases")
platform = models.ForeignKey(
Platform, on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField(verbose_name="Purchased")
date_refunded = models.DateField(blank=True, null=True, verbose_name="Refunded")
infinite = models.BooleanField(default=False)
price = models.FloatField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
converted_price = models.FloatField(null=True)
converted_currency = models.CharField(max_length=3, blank=True, default="")
needs_price_update = models.BooleanField(default=True, db_index=True)
price_per_game = GeneratedField(
expression=Coalesce(F("converted_price"), F("price"), 0) / F("num_purchases"),
output_field=models.FloatField(),
db_persist=True,
editable=False,
)
num_purchases = models.IntegerField(default=0)
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, blank=True, default="")
related_purchase = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
default=None,
null=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def standardized_price(self):
return (
f"{floatformat(self.converted_price, 0)} {self.converted_currency}"
if self.converted_price
else None
)
@property
def has_one_item(self):
return self.games.count() == 1
@property
def standardized_name(self):
return self.name or self.first_game.name
@property
def first_game(self):
return self.games.first()
def __str__(self):
return self.standardized_name
@property
def full_name(self):
additional_info = [
str(item)
for item in [
f"{self.num_purchases} game{pluralize(self.num_purchases)}",
self.date_purchased,
self.standardized_price,
]
if item
]
return f"{self.standardized_name} ({', '.join(additional_info)})"
def is_game(self):
return self.type == self.GAME
def refund(self):
self.date_refunded = timezone.now()
self.save()
def save(self, *args, **kwargs):
if self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs)
class SessionQuerySet(models.QuerySet):
def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual"))
)
return result["duration"]
def calculated_duration_formatted(self):
return format_duration(self.calculated_duration_unformatted())
def calculated_duration_unformatted(self):
result = self.aggregate(duration=Sum(F("duration_calculated")))
return result["duration"]
def without_manual(self):
return self.exclude(duration_calculated__iexact=0)
def only_manual(self):
return self.filter(duration_calculated__iexact=0)
class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
game = models.ForeignKey(
Game,
on_delete=models.CASCADE,
null=True,
default=None,
related_name="sessions",
)
timestamp_start = models.DateTimeField(verbose_name="Start")
timestamp_end = models.DateTimeField(blank=True, null=True, verbose_name="End")
duration_manual = models.DurationField(
blank=True, null=True, default=timedelta(0), verbose_name="Manual duration"
)
duration_calculated = GeneratedField(
expression=Coalesce(F("timestamp_end") - F("timestamp_start"), 0),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
duration_total = GeneratedField(
expression=F("duration_calculated") + F("duration_manual"),
output_field=models.DurationField(),
db_persist=True,
editable=False,
)
device = models.ForeignKey(
"Device",
on_delete=models.SET_DEFAULT,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, default="")
emulated = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager()
def __str__(self):
mark = "*" if self.is_manual() else ""
return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self):
self.timestamp_end = timezone.now()
def start_now():
self.timestamp_start = timezone.now()
def duration_formatted(self) -> str:
result = format_duration(self.duration_total, "%02.1H")
return result
def duration_formatted_with_mark(self) -> str:
mark = "*" if self.is_manual() else ""
return f"{self.duration_formatted()}{mark}"
def is_manual(self) -> bool:
return not self.duration_manual == timedelta(0)
def save(self, *args, **kwargs) -> None:
if not isinstance(self.duration_manual, timedelta):
self.duration_manual = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "PC"
CONSOLE = "Console"
HANDHELD = "Handheld"
MOBILE = "Mobile"
SBC = "Single-board computer"
UNKNOWN = "Unknown"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.type})"
class ExchangeRate(models.Model):
currency_from = models.CharField(max_length=255)
currency_to = models.CharField(max_length=255)
year = models.PositiveIntegerField()
rate = models.FloatField()
class Meta:
unique_together = ("currency_from", "currency_to", "year")
def __str__(self):
return f"{self.currency_from}/{self.currency_to} - {self.rate} ({self.year})"
def get_or_create_rate(currency_from: str, currency_to: str, year: int) -> float | None:
exchange_rate = None
result = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
)
if result:
exchange_rate = result[0].rate
else:
try:
# this API endpoint only accepts lowercase currency string
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
exchange_rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
return exchange_rate
class PlayEvent(models.Model):
game = models.ForeignKey(Game, related_name="playevents", on_delete=models.CASCADE)
started = models.DateField(null=True, blank=True)
ended = models.DateField(null=True, blank=True)
days_to_finish = GeneratedField(
# special cases:
# missing ended, started, or both = 0
# same day = 1 day to finish
expression=RawSQL(
"""
COALESCE(
CASE
WHEN date(ended) = date(started) THEN 1
ELSE julianday(ended) - julianday(started)
END, 0
)
""",
[],
),
output_field=models.IntegerField(),
db_persist=True,
editable=False,
blank=True,
)
note = models.CharField(max_length=255, blank=True, default="")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
# class PlayMarker(models.Model):
# game = models.ForeignKey(Game, related_name="markers", on_delete=models.CASCADE)
# played_since = models.DurationField()
# played_total = models.DurationField()
# note = models.CharField(max_length=255)
class GameStatusChange(models.Model):
"""
Tracks changes to the status of a Game.
"""
game = models.ForeignKey(
Game, on_delete=models.CASCADE, related_name="status_changes"
)
old_status = models.CharField(
max_length=1, choices=Game.Status.choices, blank=True, null=True
)
new_status = models.CharField(max_length=1, choices=Game.Status.choices)
timestamp = models.DateTimeField(null=True)
def __str__(self):
return f"{self.game.name}: {self.old_status or 'None'} -> {self.new_status} at {self.timestamp}"
class Meta:
ordering = ["-timestamp"]
-112
View File
@@ -1,112 +0,0 @@
import logging
from datetime import timedelta
from django.db.models import F, Sum
from django.db.models.signals import (
m2m_changed,
post_delete,
post_save,
pre_delete,
pre_save,
)
from django.dispatch import receiver
from django.utils.timezone import now
from games.models import Game, GameStatusChange, Purchase, Session
logger = logging.getLogger("games")
@receiver(pre_save, sender=Purchase)
def store_purchase_price_snapshot(sender, instance, **kwargs):
"""Store old price values before save so we can detect changes."""
if instance.pk is not None:
try:
old_instance = sender.objects.get(pk=instance.pk)
instance._old_price = old_instance.price
instance._old_currency = old_instance.price_currency
except sender.DoesNotExist:
pass
@receiver(post_save, sender=Purchase)
def mark_needs_price_update(sender, instance, created, **kwargs):
"""Mark purchase for price update if price or currency changed."""
if not created and hasattr(instance, "_old_price"):
if (
instance.price != instance._old_price
or instance.price_currency != instance._old_currency
):
sender.objects.filter(pk=instance.pk).update(needs_price_update=True)
@receiver(m2m_changed, sender=Purchase.games.through)
def update_num_purchases(sender, instance, action, reverse, **kwargs):
if not reverse and action.startswith("post_"):
instance.num_purchases = instance.games.count()
instance.updated_at = now()
instance.save(update_fields=["num_purchases", "updated_at"])
@receiver(pre_delete, sender=Game)
def update_purchase_counts_on_game_delete(sender, instance, **kwargs):
"""
Update num_purchases on related Purchase objects when a Game is deleted.
m2m_changed is not fired when a related object is deleted.
"""
for purchase in instance.purchases.all():
if purchase.num_purchases > 0:
purchase.num_purchases -= 1
if purchase.num_purchases == 0:
purchase.delete()
else:
purchase.updated_at = now()
purchase.save(update_fields=["num_purchases", "updated_at"])
@receiver([post_save, post_delete], sender=Session)
def update_game_playtime(sender, instance, **kwargs):
# During cascade deletes the related Game may already have been removed.
# Use the FK id to look up the Game safely and bail out if it no longer exists.
game_pk = getattr(instance, "game_id", None)
if not game_pk:
return
game = Game.objects.filter(pk=game_pk).first()
if not game:
return
total_playtime = game.sessions.aggregate(
total_playtime=Sum(F("duration_calculated") + F("duration_manual"))
)["total_playtime"]
game.playtime = total_playtime if total_playtime else timedelta(0)
game.save(update_fields=["playtime"])
@receiver(pre_save, sender=Game)
def game_status_changed(sender, instance, **kwargs):
"""
Signal handler to create a GameStatusChange record whenever a Game's status is updated.
"""
try:
old_instance = sender.objects.get(pk=instance.pk)
old_status = old_instance.status
logger.info("[game_status_changed]: Previous status exists.")
except sender.DoesNotExist:
# Handle the case where the instance was deleted before the signal was sent
logger.info("[game_status_changed]: Previous status does not exist.")
return
if old_status != instance.status:
logger.info(
"[game_status_changed]: Status changed from {} to {}".format(
old_status, instance.status
)
)
GameStatusChange.objects.create(
game=instance,
old_status=old_status,
new_status=instance.status,
timestamp=now(),
)
else:
logger.info("[game_status_changed]: Status has not changed")
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 504 B

-24
View File
@@ -1,24 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
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",
},
];
syncSelectInputUntilChanged(syncData, "form");
-12
View File
@@ -1,12 +0,0 @@
import { syncSelectInputUntilChanged } from "./utils.js";
let syncData = [
{
source: "#id_name",
source_value: "value",
target: "#id_sort_name",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
-31
View File
@@ -1,31 +0,0 @@
import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenValueNotEqual,
} from "./utils.js";
let syncData = [
{
source: "#id_games",
source_value: "dataset.platform",
target: "#id_platform",
target_value: "value",
},
];
syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").addEventListener("change", () => {
setupElementHandlers();
}
);
-23
View File
@@ -1,23 +0,0 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") {
const oppositeName =
targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
-37
View File
@@ -1,37 +0,0 @@
(function() {
htmx.defineExtension("hx-redirect-toast", {
isInlineSwap: function(swapStyle) {
return swapStyle === "hx-redirect-toast";
},
handleSwap: function(swapStyle, target, fragment, settleInfo, htmxConfig) {
var xhr = htmxConfig.xhr;
var hxRedirect = xhr.getResponseHeader("HX-Redirect");
var hxTrigger = xhr.getResponseHeader("HX-Trigger");
// Redirect immediately (toast will be shown on the new page)
if (hxRedirect) {
window.location.href = hxRedirect;
}
// Only dispatch HX-Trigger events for toasts when not redirecting
if (!hxRedirect && hxTrigger) {
var triggers = JSON.parse(hxTrigger);
var events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach(function(triggerObj) {
Object.entries(triggerObj).forEach(function(entry) {
var name = entry[0];
var detail = entry[1];
try { detail = JSON.parse(detail); } catch(e) {}
target.dispatchEvent(new CustomEvent(name, {
detail: detail,
bubbles: true,
cancelable: true
}));
});
});
}
// Return null to prevent any DOM swap
return null;
}
});
})();
-1
View File
File diff suppressed because one or more lines are too long
-173
View File
@@ -1,173 +0,0 @@
document.addEventListener("alpine:init", () => {
let idCounter = 0;
console.log("[toast] Alpine available:", typeof Alpine !== "undefined");
Alpine.store("toasts", {
toasts: [],
addToast(message, type) {
console.log("[toast] addToast called:", { message, type });
if (!type) type = "info";
const validTypes = ["success", "error", "info", "warning", "debug"];
if (!validTypes.includes(type)) type = "info";
if (this.toasts.length >= 3) {
console.log("[toast] max 3 toasts reached, removing oldest");
this.toasts.shift();
}
const id = ++idCounter;
console.log("[toast] toast added, count:", this.toasts.length);
this.toasts.push({ id, message, type, visible: true, timer: null, pausedAt: null });
if (type !== "error") {
const toast = this.toasts[this.toasts.length - 1];
const autoDismissDelay = type === "debug" ? 3000 : 5000;
toast.timer = setTimeout(() => {
console.log("[toast] auto-dismiss after " + (autoDismissDelay / 1000) + "s");
this.dismissToast(id);
}, autoDismissDelay);
}
},
dismissToast(id) {
console.log("[toast] dismissToast for id:", id);
const idx = this.toasts.findIndex((t) => t.id === id);
if (idx === -1) { console.log("[toast] toast not found"); return; }
const toast = this.toasts[idx];
if (toast.timer) clearTimeout(toast.timer);
toast.visible = false;
setTimeout(() => {
this.toasts = this.toasts.filter((t) => t.id !== id);
console.log("[toast] after dismiss, count:", this.toasts.length);
}, 300);
},
clearToastTimer(id) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.timer) {
console.log("[toast] pause timer for toast id:", id);
clearTimeout(toast.timer);
toast.timer = null;
toast.pausedAt = Date.now();
}
},
resumeToastTimer(id, duration) {
const toast = this.toasts.find((t) => t.id === id);
if (toast?.pausedAt && toast.timer === null) {
console.log("[toast] resume timer for toast id:", id);
toast.timer = setTimeout(() => {
this.dismissToast(id);
}, duration);
toast.pausedAt = null;
}
},
});
Alpine.data("toastStore", () => ({
init() {
console.log("[toast] toastStore.init running");
console.log("[toast] Alpine store toasts:", Alpine.store("toasts").toasts);
window.addEventListener("show-toast", (e) => {
console.log("[toast] show-toast event received:", e.detail);
if (Array.isArray(e.detail)) {
e.detail.forEach((msg) => {
Alpine.store("toasts").addToast(msg.message, msg.type);
});
} else {
Alpine.store("toasts").addToast(e.detail.message, e.detail.type);
}
});
try {
const script = document.getElementById("django-messages");
if (script) {
const msgs = JSON.parse(
script.textContent || script.innerText || "[]"
);
console.log("[toast] django-messages script found:", msgs);
if (Array.isArray(msgs)) {
msgs.forEach((msg) => {
console.log("[toast] loading django-message:", msg);
Alpine.store("toasts").addToast(msg.message, msg.type || "info");
});
}
}
} catch (e) {
console.error("[toast] localStorage restore failed:", e);
// ignore parse errors
}
},
addToast(message, type) {
console.log("[toast] toastStore.addToast delegating:", { message, type });
Alpine.store("toasts").addToast(message, type);
},
dismissToast(id) {
console.log("[toast] toastStore.dismissToast delegating:", id);
Alpine.store("toasts").dismissToast(id);
},
}));
});
function toast(message, type) {
console.log("[toast] toast() called:", { message, type });
const evt = new CustomEvent("show-toast", {
detail: { message, type },
bubbles: true,
});
document.dispatchEvent(evt);
console.log("[toast] CustomEvent dispatched, type:", evt.type);
}
window.toast = toast;
/**
* Wrapper around fetch() that dispatches HTMX HX-Trigger events.
* Use this for any fetch() call that expects HX-Trigger headers
* (e.g., to show toasts via the HTMX middleware).
*
* @todo Migrate these call sites to hx-post + hx-on::after-request
* for HTMX-native toast handling.
*/
window.fetchWithHtmxTriggers = function fetchWithHtmxTriggers(url, options = {}) {
console.log("[fetchWithHtmxTriggers] fetching:", url);
return fetch(url, options).then(async (response) => {
console.log("[fetchWithHtmxTriggers] response status:", response.status);
const htmxTrigger = response.headers.get("HX-Trigger");
console.log("[fetchWithHtmxTriggers] HX-Trigger header:", htmxTrigger);
if (htmxTrigger) {
let triggers;
try {
triggers = JSON.parse(htmxTrigger);
console.log("[fetchWithHtmxTriggers] parsed triggers:", triggers);
} catch {
console.warn("[fetchWithHtmxTriggers] failed to parse HX-Trigger JSON");
return response;
}
// Handle both single object and array of events
const events = Array.isArray(triggers) ? triggers : [triggers];
events.forEach((triggerObj) => {
Object.entries(triggerObj).forEach(([name, detail]) => {
console.log("[fetchWithHtmxTriggers] dispatching event:", name, detail);
let parsedDetail = detail;
try {
parsedDetail = JSON.parse(detail);
} catch {
// keep as string
}
document.dispatchEvent(new CustomEvent(name, {
detail: parsedDetail,
bubbles: true,
}));
});
});
}
return response;
});
};
-212
View File
@@ -1,212 +0,0 @@
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
function toISOUTCString(date) {
function stringAndPad(number) {
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}`;
}
/**
* @description Sync values between source and target elements based on syncData configuration.
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
*/
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
const parentElement =
parentSelector === document
? document
: document.querySelector(parentSelector);
if (!parentElement) {
console.error(`The parent selector "${parentSelector}" is not valid.`);
return;
}
// Set up a single change event listener on the document for handling all source changes
parentElement.addEventListener("change", function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the change event target matches the source selector
if (event.target.matches(syncItem.source)) {
const sourceElement = event.target;
const valueToSync = getValueFromProperty(
sourceElement,
syncItem.source_value
);
const targetElement = document.querySelector(syncItem.target);
if (targetElement && valueToSync !== null) {
console.log(`Changing value of ${syncItem.target} to ${valueToSync}`)
targetElement[syncItem.target_value] = valueToSync;
}
}
});
});
// Set up a single focus event listener on the document for handling all target focuses
parentElement.addEventListener(
"focus",
function (event) {
// Loop through each sync configuration item
syncData.forEach((syncItem) => {
// Check if the focus event target matches the target selector
if (event.target.matches(syncItem.target)) {
// Remove the change event listener to stop syncing
// This assumes you want to stop syncing once any target receives focus
// You may need a more sophisticated way to remove listeners if you want to stop
// syncing selectively based on other conditions
document.removeEventListener("change", syncSelectInputUntilChanged);
}
});
},
true
); // Use capture phase to ensure the event is captured during focus, not bubble
}
/**
* @description Retrieve the value from the source element based on the provided property.
* @param {Element} sourceElement - The source HTML element.
* @param {string} property - The property to retrieve the value from.
*/
function getValueFromProperty(sourceElement, property) {
let source =
sourceElement instanceof HTMLSelectElement
? sourceElement.selectedOptions[0]
: sourceElement;
if (property.startsWith("dataset.")) {
let datasetKey = property.slice(8); // Remove 'dataset.' part
return source.dataset[datasetKey];
} else if (property in source) {
return source[property];
} else {
console.error(`Property ${property} is not valid for the option element.`);
return null;
}
}
/**
* @description Returns a single element by name.
* @param {string} selector The selector to look for.
*/
function getEl(selector) {
if (selector.startsWith("#")) {
return document.getElementById(selector.slice(1));
} else if (selector.startsWith(".")) {
return document.getElementsByClassName(selector);
} else {
return document.getElementsByTagName(selector);
}
}
/**
* @description Applies different behaviors to elements based on multiple conditional configurations.
* Each configuration is an array containing a condition function, an array of target element selectors,
* and two callback functions for handling matched and unmatched conditions.
* @param {...Array} configs Each configuration is an array of the form:
* - 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(...configs) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => {
if (condition()) {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn1(el);
}
});
} else {
targetElements.forEach((elementName) => {
let el = getEl(elementName);
if (el === null) {
console.error(`Element ${elementName} doesn't exist.`);
} else {
callbackfn2(el);
}
});
}
});
}
function disableElementsWhenValueNotEqual(
targetSelect,
targetValue,
elementList
) {
return conditionalElementHandler([
() => {
let target = getEl(targetSelect);
console.debug(
`${disableElementsWhenTrue.name}: triggered on ${target.id}`
);
console.debug(`
${disableElementsWhenTrue.name}: matching against value(s): ${targetValue}`);
if (targetValue instanceof Array) {
if (targetValue.every((value) => target.value != value)) {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return true;
}
} else {
console.debug(
`${disableElementsWhenTrue.name}: none of the values is equal to ${target.value}, returning true.`
);
return target.value != targetValue;
}
},
elementList,
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated true, disabling ${el.id}.`
);
el.disabled = "disabled";
},
(el) => {
console.debug(
`${disableElementsWhenTrue.name}: evaluated false, NOT disabling ${el.id}.`
);
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
console.log(`${disableElementsWhenTrue.name}: triggered on ${targetSelect}`)
console.log(`Value of ${targetSelect} is ${targetValue}: ${getEl(targetSelect).value == targetValue}`)
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
console.log(`${disableElementsWhenTrue.name}: disabling ${el.id}`)
el.disabled = "disabled";
},
(el) => {
console.log(`${disableElementsWhenTrue.name}: enabling ${el.id}`)
el.disabled = "";
},
]);
}
export {
toISOUTCString,
syncSelectInputUntilChanged,
getEl,
conditionalElementHandler,
disableElementsWhenValueNotEqual,
disableElementsWhenTrue,
getValueFromProperty,
};
-43
View File
@@ -1,43 +0,0 @@
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
/**
* @param {Node} targetNode
*/
function addToggleButton(targetNode) {
let manualToggleButton = elt(
"td",
{},
elt(
"div",
{ className: "basic-button" },
elt(
"button",
{
onclick: (event) => {
let textInputField = elt("input", { type: "text", id: targetNode.id });
targetNode.replaceWith(textInputField);
event.target.addEventListener("click", (event) => {
textInputField.replaceWith(targetNode);
});
},
},
"Toggle manual"
)
)
);
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_games", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
});
-2
View File
@@ -1,2 +0,0 @@
User-agent: *
Disallow: /
-100
View File
@@ -1,100 +0,0 @@
import logging
import requests
from django.db import models
from django.template.defaultfilters import floatformat
logger = logging.getLogger("games")
from games.models import ExchangeRate, Purchase
# fixme: save preferred currency in user model
currency_to = "CZK"
currency_to = currency_to.upper()
def _get_exchange_rate(currency_from, currency_to, year):
logger.debug(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
).first()
if not rate:
logger.debug(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
try:
response = requests.get(
f"https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@{year}-01-01/v1/currencies/{currency_from.lower()}.json"
)
response.raise_for_status()
data = response.json()
currency_from_data = data.get(currency_from.lower())
rate = currency_from_data.get(currency_to.lower())
if rate:
logger.info(f"[convert_prices]: Got {rate}, saving...")
exchange_rate = ExchangeRate.objects.create(
currency_from=currency_from,
currency_to=currency_to,
year=year,
rate=floatformat(rate, 2),
)
rate = exchange_rate.rate
else:
logger.info("[convert_prices]: Could not get an exchange rate.")
except requests.RequestException as e:
logger.info(
f"[convert_prices]: Failed to fetch exchange rate for {currency_from}->{currency_to} in {year}: {e}"
)
elif rate:
rate = rate.rate
return rate
def _save_converted_price(purchase, converted_price, needs_update):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {currency_to} (originally {purchase.price} {purchase.price_currency})"
)
purchase.converted_price = converted_price
purchase.converted_currency = currency_to
if needs_update:
purchase.needs_price_update = False
purchase.save(update_fields=["converted_price", "converted_currency", "needs_price_update"])
def convert_prices():
purchases = Purchase.objects.filter(
models.Q(needs_price_update=True) | models.Q(converted_price__isnull=True)
).distinct()
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
return
for purchase in purchases:
needs_update = purchase.needs_price_update
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
_save_converted_price(purchase, purchase.price, needs_update)
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
rate = _get_exchange_rate(currency_from, currency_to, year)
if rate:
_save_converted_price(
purchase,
floatformat(purchase.price * rate, 0),
needs_update,
)
def calculate_price_per_game():
"""
This task is deprecated because price_per_game is now a GeneratedField.
It is kept here to prevent errors from lingering scheduled tasks.
"""
try:
from django_q.models import Schedule
Schedule.objects.filter(func="games.tasks.calculate_price_per_game").delete()
except Exception:
pass
-2
View File
@@ -1,2 +0,0 @@
<c-layouts.add>
</c-layouts.add>
-9
View File
@@ -1,9 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<c-button type="submit" color="gray"
name="submit_and_redirect"
>
Submit & Create Purchase
</c-button>
</c-slot>
</c-layouts.add>
-15
View File
@@ -1,15 +0,0 @@
<c-layouts.add>
<c-slot name="additional_row">
<tr>
<td></td>
<td>
<c-button type="submit"
color="gray"
name="submit_and_redirect"
>
Submit & Create Session
</c-button>
</td>
</tr>
</c-slot>
</c-layouts.add>
-38
View File
@@ -1,38 +0,0 @@
<c-layouts.add>
<c-slot name="form_content">
<div class="max-width-container">
<div id="add-form" class="form-container max-w-xl mx-auto">
<form method="post" enctype="multipart/form-data" class="">
{% csrf_token %}
{% for field in form %}
<div>
{{ field.label_tag }}
{% if field.name == "note" %}
{{ field }}
{% else %}
{{ field }}
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
<c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
<c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
<c-button data-target="{{ field.name }}" data-type="copy" size="xs">
Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
</c-button>
</span>
{% endif %}
</div>
{% endfor %}
<div>
<c-button type="submit">
Submit
</c-button>
</div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form>
</div>
</div>
</c-slot>
</c-layouts.add>
-14
View File
@@ -1,14 +0,0 @@
<c-vars color="blue" size="base" type="button" />
<button
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if hx_swap %}hx-swap="{{ hx_swap }}"{% endif %}
{% if type %}type="{{ type }}"{% endif %}
{% if title %}title="{{ title }}"{% endif %}
{% if onclick %}onclick="{{ onclick }}"{% endif %}
{% if data_target %}data-target="{{ data_target }}"{% endif %}
{% if data_type %}data-type="{{ data_type }}"{% endif %}
{% if name %}name="{{ name }}"{% endif %}
class="{% if class %}{{ class }} {%else%}{%endif%}{% if color == "blue" %}text-white bg-brand box-border border border-transparent hover:bg-brand-strong focus:ring-4 focus:ring-brand-medium {% elif color == "red" %} bg-red-700 dark:bg-red-600 dark:focus:ring-red-900 dark:hover:bg-red-700 focus:ring-red-300 hover:bg-red-800 text-white {% elif color == "gray" %} bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-600 dark:focus:ring-gray-700 dark:hover:bg-gray-700 dark:hover:text-white dark:text-gray-400 focus:ring-gray-100 hover:bg-gray-100 hover:text-blue-700 text-gray-900 border {% elif color == "green" %} bg-green-700 dark:bg-green-600 dark:focus:ring-green-800 dark:hover:bg-green-700 focus:ring-green-300 hover:bg-green-800 text-white {% endif %} leading-5 focus:outline-hidden focus:ring-4 font-medium mb-2 me-2 rounded-base {% if size == "xs" %} px-3 py-2 text-xs shadow-xs {% elif size == "sm" %} px-3 py-2 text-sm {% elif size == "base" %} px-5 py-2.5 text-sm {% elif size == "lg" %} px-5 py-3 text-base {% elif size == "xl" %} px-6 py-3.5 text-base {% endif %} {% if icon %} inline-flex text-center items-center gap-2 {% else %} {% endif %} ">
{{ slot }}
</button>
-8
View File
@@ -1,8 +0,0 @@
<div class="inline-flex rounded-md shadow-xs" role="group">
{% if slot %}{{ slot }}{% endif %}
{% for button in buttons %}
{% if button.slot %}
<c-button-group-button-sm :href=button.href :slot=button.slot :color=button.color :hover=button.hover :title=button.title :hx_get=button.hx_get :hx_target=button.hx_target :hx_swap=button.hx_swap />
{% endif %}
{% endfor %}
</div>
@@ -1,26 +0,0 @@
<c-vars color="gray" />
<a href="{{ href }}"
{% if hx_get %}hx-get="{{ hx_get }}"{% endif %}
{% if hx_target %}hx-target="{{ hx_target }}"{% endif %}
{% if click %}@click="{{ click }}"{% endif %}
class="[&:first-of-type_button]:rounded-s-lg [&:last-of-type_button]:rounded-e-lg">
{% if color == "gray" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }}
</button>
{% elif color == "red" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-red-500 hover:text-white focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-red-700 dark:hover:bg-red-700 dark:focus:ring-blue-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }}
</button>
{% elif color == "green" %}
<button type="button"
title="{{ title }}"
class="px-2 py-1 text-xs font-medium text-gray-900 bg-white border border-gray-200 hover:bg-green-500 hover:border-green-600 hover:text-white focus:z-10 focus:ring-2 focus:ring-green-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:border-green-700 dark:hover:bg-green-600 dark:focus:ring-green-500 dark:focus:text-white hover:cursor-pointer">
{{ slot }}
</button>
{% endif %}
</a>
-13
View File
@@ -1,13 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<a href="{{ link }}"
title="{{ title }}"
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-hidden focus:ring-2 focus:ring-offset-2 rounded-xs">
{% comment %} <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{% endcomment %}
{{ text }}
</a>
-18
View File
@@ -1,18 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<button type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-hidden focus:ring-2 focus:ring-offset-2 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline">
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg>
{{ text }}
</button>
-10
View File
@@ -1,10 +0,0 @@
<span class="truncate-container">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'games:view_game' game_id %}">
{% if slot %}
{{ slot }}
{% else %}
{{ name }}
{% endif %}
</a>
</span>
-16
View File
@@ -1,16 +0,0 @@
<span class="{% if display == 'flex' %}flex{% else %}inline-flex{% endif %} gap-2 items-center align-middle {{class}}">
<span class="rounded-xl w-3 h-3
{% if status == "u" %}
bg-gray-500
{% elif status == "p" %}
bg-orange-400
{% elif status == "f" %}
bg-green-500
{% elif status == "a" %}
bg-red-500
{% elif status == "r" %}
bg-purple-500
{% endif %}
">&nbsp;</span>
{{ slot }}
</span>
-8
View File
@@ -1,8 +0,0 @@
<h1 class="{% if badge %}flex items-center {% endif %}mb-4 text-3xl font-extrabold leading-none tracking-tight text-gray-900 dark:text-white">
{{ slot }}
{% if badge %}
<span class="bg-blue-100 text-blue-800 text-2xl font-semibold me-2 px-2.5 py-0.5 rounded-sm dark:bg-blue-200 dark:text-blue-800 ms-2">
{{ badge }}
</span>
{% endif %}
</h1>
@@ -1,3 +0,0 @@
<svg class="dark:text-white w-3" viewBox="5 8 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 284 B

@@ -1,5 +0,0 @@
<c-svg title="Battle.net">
<c-slot name="path">
M 43.113281 22.152344 C 43.113281 22.152344 47.058594 22.351563 47.058594 20.03125 C 47.058594 16.996094 41.804688 14.261719 41.804688 14.261719 C 41.804688 14.261719 42.628906 12.515625 43.140625 11.539063 C 43.65625 10.5625 45.101563 6.753906 45.230469 5.886719 C 45.394531 4.792969 45.144531 4.449219 45.144531 4.449219 C 44.789063 6.792969 40.972656 13.539063 40.671875 13.769531 C 36.949219 12.023438 31.835938 11.539063 31.835938 11.539063 C 31.835938 11.539063 26.832031 1 22.125 1 C 17.457031 1 17.480469 10.023438 17.480469 10.023438 C 17.480469 10.023438 16.160156 7.464844 14.507813 7.464844 C 12.085938 7.464844 11.292969 11.128906 11.292969 15.097656 C 6.511719 15.097656 2.492188 16.164063 2.132813 16.265625 C 1.773438 16.371094 0.644531 17.191406 1.15625 17.089844 C 2.203125 16.753906 7.113281 15.992188 11.410156 16.367188 C 11.648438 20.140625 13.851563 25.054688 13.851563 25.054688 C 13.851563 25.054688 9.128906 31.894531 9.128906 36.78125 C 9.128906 38.066406 9.6875 40.417969 13.078125 40.417969 C 15.917969 40.417969 19.105469 38.710938 19.707031 38.363281 C 19.183594 39.113281 18.796875 40.535156 18.796875 41.191406 C 18.796875 41.726563 19.113281 43.246094 21.304688 43.246094 C 24.117188 43.246094 27.257813 41.089844 27.257813 41.089844 C 27.257813 41.089844 30.222656 46.019531 32.761719 48.28125 C 33.445313 48.890625 34.097656 49 34.097656 49 C 34.097656 49 31.578125 46.574219 28.257813 40.324219 C 31.34375 38.417969 34.554688 33.921875 34.554688 33.921875 C 34.554688 33.921875 34.933594 33.933594 37.863281 33.933594 C 42.453125 33.933594 48.972656 32.96875 48.972656 29.320313 C 48.972656 25.554688 43.113281 22.152344 43.113281 22.152344 Z M 43.625 19.886719 C 43.625 21.21875 42.359375 21.199219 42.359375 21.199219 L 41.394531 21.265625 C 41.394531 21.265625 39.566406 20.304688 38.460938 19.855469 C 38.460938 19.855469 40.175781 17.207031 40.578125 16.46875 C 40.882813 16.644531 43.625 18.363281 43.625 19.886719 Z M 24.421875 6.308594 C 26.578125 6.308594 29.65625 11.402344 29.65625 11.402344 C 29.65625 11.402344 24.851563 10.972656 20.898438 13.296875 C 21.003906 9.628906 22.238281 6.308594 24.421875 6.308594 Z M 15.871094 10.4375 C 16.558594 10.4375 17.230469 11.269531 17.507813 11.976563 C 17.507813 12.445313 17.75 15.171875 17.75 15.171875 L 13.789063 15.023438 C 13.789063 11.449219 15.1875 10.4375 15.871094 10.4375 Z M 15.464844 35.246094 C 13.300781 35.246094 12.851563 34.039063 12.851563 32.953125 C 12.851563 30.496094 14.8125 27.058594 14.8125 27.058594 C 14.8125 27.058594 17.011719 31.683594 20.851563 33.636719 C 18.945313 34.753906 17.375 35.246094 15.464844 35.246094 Z M 22.492188 40.089844 C 20.972656 40.089844 20.789063 39.105469 20.789063 38.878906 C 20.789063 38.171875 21.339844 37.335938 21.339844 37.335938 C 21.339844 37.335938 23.890625 35.613281 24.054688 35.429688 L 25.9375 38.945313 C 25.9375 38.945313 24.007813 40.089844 22.492188 40.089844 Z M 27.226563 38.171875 C 26.300781 36.554688 25.621094 34.867188 25.621094 34.867188 C 25.621094 34.867188 29.414063 35.113281 31.453125 33.007813 C 30.183594 33.578125 28.15625 34.300781 25.800781 34.082031 C 30.726563 29.742188 33.601563 26.597656 36.03125 23.34375 C 35.824219 23.09375 34.710938 22.316406 34.4375 22.1875 C 32.972656 23.953125 27.265625 30.054688 21.984375 33.074219 C 15.292969 29.425781 13.890625 18.691406 13.746094 16.460938 L 17.402344 16.8125 C 17.402344 16.8125 16.027344 19.246094 16.027344 21.039063 C 16.027344 22.828125 16.242188 22.925781 16.242188 22.925781 C 16.242188 22.925781 16.195313 19.800781 18.125 17.390625 C 19.59375 25.210938 21.125 29.21875 22.320313 31.605469 C 22.925781 31.355469 24.058594 30.851563 24.058594 30.851563 C 24.058594 30.851563 20.683594 21.121094 20.871094 14.535156 C 22.402344 13.71875 24.667969 12.875 27.226563 12.875 C 33.957031 12.875 39.367188 15.773438 39.367188 15.773438 L 37.25 18.730469 C 37.25 18.730469 35.363281 15.3125 32.699219 14.703125 C 34.105469 15.753906 35.679688 17.136719 36.496094 19.128906 C 30.917969 16.949219 24.1875 15.796875 22.027344 15.542969 C 21.839844 16.339844 21.863281 17.480469 21.863281 17.480469 C 21.863281 17.480469 30.890625 19.144531 37.460938 22.90625 C 37.414063 31.125 28.460938 37.4375 27.226563 38.171875 Z M 35.777344 32.027344 C 35.777344 32.027344 38.578125 28.347656 38.535156 23.476563 C 38.535156 23.476563 43.0625 26.28125 43.0625 29.015625 C 43.0625 32.074219 35.777344 32.027344 35.777344 32.027344 Z
</c-slot>
</c-svg>
@@ -1,5 +0,0 @@
<c-svg viewBox="0 0 20 20">
<c-slot name="path">
M2.069,11 L5,11 L5,9 L2.069,9 C2.252,7.542 2.828,6.208 3.688,5.102 L5.757,7.172 L7.171,5.757 L5.102,3.688 C6.208,2.828 8,2.252 9,2.069 L9,5 L11,5 L11,2.069 C12,2.252 13.791,2.828 14.897,3.688 L12.828,5.757 L14.242,7.172 L16.311,5.102 C17.171,6.208 17.747,7.542 17.93,9 L15,9 L15,11 L17.93,11 C17.747,12.458 17.171,13.792 16.311,14.898 L14.242,12.828 L12.828,14.243 L14.897,16.312 C13.791,17.172 12,17.748 11,17.931 L11,15 L9,15 L9,17.931 C8,17.748 6.208,17.172 5.102,16.312 L7.171,14.243 L5.757,12.828 L3.688,14.898 C2.828,13.792 2.252,12.458 2.069,11 M10,0 C4.477,0 0,4.477 0,10 C0,15.523 4.477,20 10,20 C15.522,20 20,15.523 20,10 C20,4.477 15.522,0 10,0
</c-slot>
</c-svg>
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 43.470703 8.9863281 A 1.50015 1.50015 0 0 0 42.439453 9.4394531 L 16.5 35.378906 L 5.5605469 24.439453 A 1.50015 1.50015 0 1 0 3.4394531 26.560547 L 15.439453 38.560547 A 1.50015 1.50015 0 0 0 17.560547 38.560547 L 44.560547 11.560547 A 1.50015 1.50015 0 0 0 43.470703 8.9863281 z">
</path>
</svg>

Before

Width:  |  Height:  |  Size: 477 B

-8
View File
@@ -1,8 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg"
x="0px"
y="0px"
viewBox="0 0 48 48"
class="text-black dark:text-white w-4 h-4">
<path fill="currentColor" d="M 24 4 C 20.491685 4 17.570396 6.6214322 17.080078 10 L 10.238281 10 A 1.50015 1.50015 0 0 0 9.9804688 9.9785156 A 1.50015 1.50015 0 0 0 9.7578125 10 L 6.5 10 A 1.50015 1.50015 0 1 0 6.5 13 L 8.6386719 13 L 11.15625 39.029297 C 11.427329 41.835926 13.811782 44 16.630859 44 L 31.367188 44 C 34.186411 44 36.570826 41.836168 36.841797 39.029297 L 39.361328 13 L 41.5 13 A 1.50015 1.50015 0 1 0 41.5 10 L 38.244141 10 A 1.50015 1.50015 0 0 0 37.763672 10 L 30.919922 10 C 30.429604 6.6214322 27.508315 4 24 4 z M 24 7 C 25.879156 7 27.420767 8.2681608 27.861328 10 L 20.138672 10 C 20.579233 8.2681608 22.120844 7 24 7 z M 11.650391 13 L 36.347656 13 L 33.855469 38.740234 C 33.730439 40.035363 32.667963 41 31.367188 41 L 16.630859 41 C 15.331937 41 14.267499 40.033606 14.142578 38.740234 L 11.650391 13 z M 20.476562 17.978516 A 1.50015 1.50015 0 0 0 19 19.5 L 19 34.5 A 1.50015 1.50015 0 1 0 22 34.5 L 22 19.5 A 1.50015 1.50015 0 0 0 20.476562 17.978516 z M 27.476562 17.978516 A 1.50015 1.50015 0 0 0 26 19.5 L 26 34.5 A 1.50015 1.50015 0 1 0 29 34.5 L 29 19.5 A 1.50015 1.50015 0 0 0 27.476562 17.978516 z">
</path>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,9 +0,0 @@
<c-svg viewbox="0 0 50 50">
<g transform="scale(0.09765625)">
<title>EA/Origin</title>
<g>
<path fill="currentColor" d="M299.125,126.274H126.628L97.876,183.93h172.499L299.125,126.274z" />
<path fill="currentColor" d="M342.248,126.274L224.462,328.066h-105.8l32.862-57.653h61.347l28.758-57.658H69.125l-28.746,57.658H85.31L26.001,385.727h232.784l83.463-153.654l18.169,38.342h-18.169l-28.75,57.654h75.67l28.75,57.658h68.081L342.248,126.274z" />
</g>
</g>
</c-svg>

Some files were not shown because too many files have changed in this diff Show More