Compare commits

..

383 Commits

Author SHA1 Message Date
lukas a4e697a274 Add confirmation before deleting game
Django CI/CD / test (push) Successful in 28s
Django CI/CD / build-and-push (push) Successful in 1m1s
2026-05-12 13:37:55 +02:00
lukas b8187c32b1 Always abandon refunded games
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 54s
2026-05-12 12:49:07 +02:00
lukas bf2b86ba1f Streamline evaluating game status 2026-05-12 12:48:14 +02:00
lukas 913c7d3a98 Scope URLs to the games namespace 2026-05-12 12:43:08 +02:00
lukas 37e3c69abc Make tests more robust, use django-pytest 2026-05-12 11:56:28 +02:00
lukas 0866eb25e9 update django-ninja to 1.6.2 2026-05-12 11:15:07 +02:00
lukas 39f21bc7db Remove GraphQL API 2026-05-12 11:15:07 +02:00
lukas 1416d00a37 Fix additional tests 2026-05-12 11:15:07 +02:00
lukas d9fe99963a Fix htmx_middleware tests 2026-05-12 11:01:48 +02:00
lukas 393476be85 Fix test_duration_format 2026-05-12 10:48:30 +02:00
lukas e32af2f576 Fix test_paths_return_200 2026-05-12 10:43:38 +02:00
lukas e565002244 Add simple table rendering tests
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m6s
2026-05-12 10:21:33 +02:00
lukas 1a4e51c95a Update NameWithIcon
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.
2026-05-12 10:05:15 +02:00
lukas eae020fd34 Add component tests 2026-05-12 09:43:45 +02:00
lukas 1f4dd60c54 Fix default mutable arguments
`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.
2026-05-12 09:39:43 +02:00
lukas 656a96f55c Fix A() component
Replaced single `url` parameter with explicit `url_name` (URL pattern name resolved via `reverse()`) and `href` (literal path). Fixes:
- Silent fallback (typos like `"ad_puchase"` silently became broken links) → now raises `NoReverseMatch` at render time
- `type(url) is str` gate → removed (implicit dual-mode eliminated entirely)
- Callable parameter (`url: Callable`) dead code → removed
- Implicit dual-mode (`url="name"` vs `url=reverse("name")`) → `url_name` vs `href` are now mutually exclusive params
- Inconsistent type annotation mixing `Callable` with string default → cleaned up
- Added `ValueError` when both `url_name` and `href` are provided
- Updated all 10 call sites across 6 view files and internal callers (`LinkedPurchase()`, `NameWithIcon()`)
2026-05-12 09:01:05 +02:00
lukas 8c3e819a5f Consistent component return type 2026-05-12 08:43:39 +02:00
lukas ff11e35115 Add component tests 2026-05-12 08:31:17 +02:00
lukas ebef0bba87 Make randomid deterministic to improve caching 2026-05-12 08:27:11 +02:00
lukas 140f3d2bd6 Add caching tests 2026-05-12 08:21:48 +02:00
lukas 245a4f5b3e Add component improvement doc 2026-05-12 08:10:46 +02:00
lukas cd9f0b4111 Caching 1/? 2026-05-12 08:10:33 +02:00
lukas f82c61ef1e Add toast notification system
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 54s
Add more toast types
2026-05-11 20:22:23 +02:00
lukas 4e3b0ddb08 Allow directly updating device in session list
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m12s
2026-05-11 12:54:42 +02:00
lukas a549050860 Make edit_session use the same template as add_session
Django CI/CD / test (push) Successful in 34s
Django CI/CD / build-and-push (push) Successful in 1m36s
2026-05-06 10:43:57 +02:00
lukas 596d1ccfe1 Fix refund confirmation not working
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m34s
2026-03-05 20:34:58 +01:00
lukas bb26fec5e3 Fix extra submit button when adding purchase
Django CI/CD / test (push) Successful in 35s
Django CI/CD / build-and-push (push) Successful in 1m13s
2026-02-25 08:04:48 +01:00
lukas 1ba7de0bb7 Use pointer cursor for search field button
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m2s
2026-02-21 21:50:46 +01:00
lukas 3391fb72f2 Fix secondary submit buttons not working 2026-02-21 21:48:31 +01:00
lukas 0986e59fe7 Improve styles
Django CI/CD / test (push) Successful in 30s
Django CI/CD / build-and-push (push) Successful in 1m4s
2026-02-18 23:30:30 +01:00
lukas 46b1199863 Fix button not passing attributes 2026-02-18 23:30:12 +01:00
lukas bc1092b0b3 Add prompt to set game to Abandoned upon refund
Django CI/CD / test (push) Successful in 36s
Django CI/CD / build-and-push (push) Successful in 1m55s
2026-02-17 22:14:36 +01:00
lukas 996c0107c9 Housekeeping
* Updated flowbite to 4.x
* Start revamping styles
* Remove unused GraphQL code
* Make some templates more robuts
2026-02-17 22:14:16 +01:00
lukas 277ecd1b55 Update to 1.6.1
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-30 11:49:39 +01:00
lukas 4e3a5ef682 Make buttons use pointer cursor 2026-01-30 11:45:42 +01:00
lukas 233f63f18e Update Django et al
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 1m25s
2026-01-29 16:53:45 +01:00
lukas 016f307240 Upgrade to Tailwind v4 2026-01-29 13:17:04 +01:00
lukas 715acd6244 Finish poetry migration 2026-01-29 12:56:45 +01:00
lukas 0bc48d01a7 Fix search field icon misalignment
Django CI/CD / test (push) Successful in 16s
Django CI/CD / build-and-push (push) Successful in 1m0s
2026-01-29 12:17:40 +01:00
lukas c5646d0451 Make sure Dockerfile is consistent with entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 48s
2026-01-27 21:39:30 +01:00
lukas 710a0fc5bc Update entrypoint.sh
Django CI/CD / test (push) Successful in 23s
Django CI/CD / build-and-push (push) Successful in 57s
2026-01-27 21:30:04 +01:00
lukas 1d0d16b4d4 Disable cache
Django CI/CD / test (push) Successful in 21s
Django CI/CD / build-and-push (push) Successful in 49s
2026-01-27 21:15:39 +01:00
lukas 6b89bab0a6 Switch from poetry to uv
Django CI/CD / test (push) Successful in 9m34s
Django CI/CD / build-and-push (push) Failing after 1m55s
2026-01-27 20:03:39 +01:00
lukas 2bc2d98f88 Fix purchase form logic 2026-01-27 19:30:07 +01:00
lukas 06096d471e Improve dark/light mode 2026-01-27 19:28:05 +01:00
lukas 40869e25f3 Pre-calculate playevent time from last playevent 2026-01-27 18:39:09 +01:00
lukas 4f0ac21ba3 Fill up 2026-01-27 18:39:09 +01:00
lukas 3801949fdb Keep calculate_price_per_game stub
Django CI/CD / test (push) Successful in 24s
Django CI/CD / build-and-push (push) Successful in 2m1s
2026-01-16 12:32:32 +01:00
lukas f895dc1265 Prepare 1.6.0
Django CI/CD / test (push) Successful in 29s
Django CI/CD / build-and-push (push) Has been skipped
2026-01-15 23:15:03 +01:00
lukas 04601ca13d Replace game selector on game view with component 2026-01-15 23:12:54 +01:00
lukas d53575ab48 Create game status selector template 2026-01-15 23:11:13 +01:00
lukas 4e1f55855d Update history when status changes 2026-01-15 22:43:17 +01:00
lukas 95af4ceed6 Use c-gamestatus everywhere 2026-01-15 22:40:29 +01:00
lukas 6bb89438df CI: switch to checkout@v6
Django CI/CD / test (push) Successful in 27s
Django CI/CD / build-and-push (push) Successful in 2m32s
2026-01-15 21:19:18 +01:00
lukas bd5525e57e Add verbose names to selected fields 2026-01-15 21:18:58 +01:00
lukas 5cac19be7b Fix dropdown and formatting 2026-01-15 21:18:40 +01:00
lukas a6577a9e53 Remove redundant task
price_per_game is a generated column, which means it's update
automatically
2026-01-15 17:49:30 +01:00
lukas 243830a84a Make removing games and associated purchases more robust 2026-01-15 17:49:03 +01:00
lukas 7032b8c7c7 Fix signals interfering with deleting a game with sessions
Django CI/CD / test (push) Successful in 2m29s
Django CI/CD / build-and-push (push) Failing after 1m31s
2025-12-30 13:24:09 +01:00
lukas 5cc1652002 Always set game status change timestamp to now instead of game's last updated_at
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m15s
2025-06-07 20:28:14 +02:00
lukas 7cf2180192 Allow setting game to Finished when creating PlayEvent
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-06-07 20:14:14 +02:00
lukas ad0641f95b Fix playtime stats per year
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m39s
2025-04-17 16:15:38 +02:00
lukas abdcfdfe64 Redirect to previous page after editing a session
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-04-01 15:36:40 +02:00
lukas 31daf2efe0 Make game overview playthrough dropdown close when clicked outside
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 2m17s
2025-03-28 13:43:46 +01:00
lukas 6d53fca910 Always return timedelta in update_game_playtime
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m19s
2025-03-26 12:05:10 +01:00
lukas f7e426e030 Make it easier to create a play event 2025-03-26 12:04:46 +01:00
lukas b29e4edd72 Continue making use of improved duration handling
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-03-25 23:38:04 +01:00
lukas 3c58851b88 Improve form fields for duration and currency
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-03-25 22:46:17 +01:00
lukas 99f3540825 Improve duration handling for sessions and games 2025-03-25 22:46:01 +01:00
lukas 5e778bec30 Fix stats having hardcoded year
Django CI/CD / test (push) Successful in 1m20s
Django CI/CD / build-and-push (push) Successful in 2m35s
2025-03-25 15:56:20 +01:00
lukas fea9d9784d Fix purchase-name partial
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-03-24 21:02:13 +01:00
lukas 23b4a7a069 Make it possible to edit and delete status changes
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m18s
2025-03-22 23:45:02 +01:00
lukas 89de85c00d Introduce game status, playevents
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m21s
2025-03-22 20:59:23 +01:00
lukas d892659132 Add debugging config 2025-03-22 20:58:30 +01:00
lukas 341e62283b Change gamestatus to non-block element 2025-03-22 20:54:20 +01:00
lukas 61b6c1c55f Set default logger to WARNING, add games handler at INFO 2025-03-22 20:52:39 +01:00
lukas eeaa02bada Add helper scripts for getting exchange rates 2025-03-22 20:52:02 +01:00
lukas 9d16bc2546 Update exchangerates.yaml 2025-03-22 20:49:21 +01:00
lukas 7a52b59b3d Improve logging in tasks.py
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-03-22 16:57:27 +01:00
lukas 0ce59a8cc6 Fix a bug in convert_prices
Prevents actually finding any new prices
2025-03-22 16:57:27 +01:00
lukas e0dfc0fc3e update dependencies 2025-03-22 09:14:46 +01:00
lukas 8cb67ca002 Add updated_at to Game 2025-03-17 08:36:41 +01:00
lukas be2a01840c Fix != None 2025-03-17 08:35:48 +01:00
lukas 612c42ebb7 Standardize blank and null fields in models 2025-03-17 08:35:07 +01:00
lukas e2255a1c85 Update django-cotton to 1.6.0 2025-03-17 08:33:43 +01:00
lukas 0b274b4403 Calculate stats for last 7/14 days from manual as well 2025-03-17 08:30:57 +01:00
lukas ddd75f22b0 Allow games to be set to Mastered 2025-03-17 08:26:56 +01:00
lukas 843eed64d6 Add search field to game list
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m1s
2025-02-10 18:20:46 +01:00
lukas 50e7efcfae Fix today's playtime stats
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 2m0s
2025-02-09 09:00:28 +01:00
lukas 3e713a7637 Switch PRAGMA synchronous back to FULL
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 2m8s
I had some data loss when restarting a container shortly after a database change, let's see if this prevents it.
2025-02-09 08:56:24 +01:00
lukas 2d7342c0d5 Fix add/edit session screen
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m14s
2025-02-08 17:54:03 +01:00
lukas aba9bc994d Add playtime stats to navbar
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-02-08 13:46:56 +01:00
lukas 967ff7df07 Fix wrong purchase form field
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m22s
2025-02-07 20:21:14 +01:00
lukas 2ab497fd54 Also theme password fields 2025-02-07 20:20:33 +01:00
lukas 34148466c7 Improve forms
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
2025-02-04 20:09:25 +01:00
lukas b22e185d47 Add status, mastered to Game 2025-02-04 20:09:05 +01:00
lukas b2b69339b3 Improve game choices on the session form 2025-02-04 18:34:59 +01:00
lukas 89d1bbdd9e Fix games stats, show all played games instead of top 10
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m29s
2025-02-01 10:02:23 +01:00
lukas 637e3e6493 Specifying name on NameWithIcon now works 2025-02-01 10:01:56 +01:00
lukas d213a3d35d Improve purchase view
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 2m16s
2025-01-30 17:54:42 +01:00
lukas 2f4e16dd54 Improve database concurrency
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m58s
2025-01-30 16:53:30 +01:00
lukas 6f62889e92 Improve price information
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m9s
2025-01-30 16:38:13 +01:00
lukas 4ec808eeec Improve display when no purchases or sessions exist 2025-01-30 11:56:59 +01:00
lukas 69d27958f3 Fix possible server error 2025-01-30 11:56:47 +01:00
lukas 4ec1cf5f28 Improve purchase __str__
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-30 11:41:01 +01:00
lukas d936fdc60d Fix currency API endpoint accepting only lowercase currency strings
Signed-off-by: Lukáš Kucharczyk <lukas@kucharczyk.xyz>
2025-01-30 11:40:22 +01:00
lukas 2116cfc219 Remove migrations
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m12s
2025-01-29 22:28:00 +01:00
lukas 6bd8271291 Remove Edition
Django CI/CD / test (push) Failing after 54s
Django CI/CD / build-and-push (push) Has been skipped
2025-01-29 22:05:06 +01:00
lukas e571feadef Add platform to Game 2025-01-29 18:42:13 +01:00
lukas 23c1ce1f96 Set Edition related_name to editions 2025-01-29 18:02:17 +01:00
lukas 33103daebc Update Django to 5.1.5 and virtualenv to 20.29.1
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m13s
2025-01-29 13:45:58 +01:00
lukas ba6028e43d Add emulated property to sessions
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Has been cancelled
2025-01-29 13:43:35 +01:00
lukas c2853a3ecc purchases can now refer to multiple editions
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m36s
allows purchases to be for more than one game
2025-01-08 21:00:19 +01:00
lukas cd90d60475 fix monthly playtime not displaying
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 2m12s
this bug was introduced in d622ddfbf3
2024-12-01 11:00:15 +01:00
lukas 11cea2142a dont display year if none
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m15s
2024-11-27 18:47:25 +01:00
lukas 24578b64fe disable djlint precommit hook 2024-11-27 18:47:16 +01:00
lukas 13e607f9a7 Add error handling if no Sessions exist
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 2m30s
2024-11-27 18:35:44 +01:00
lukas fc0d8db8e8 consistently format prices everywhere
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-11-15 22:53:07 +01:00
lukas 8acc4f9c5b make table work better on small screens
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-11-13 21:28:44 +01:00
lukas 6b7a96dc06 make PopoverTruncated customizable 2024-11-13 21:28:17 +01:00
lukas 5c5fd5f26a truncate: strip trailing whitespace 2024-11-13 21:07:26 +01:00
lukas 7181b6472c fix mistakenly hardcoded value in truncate() 2024-11-13 21:06:52 +01:00
lukas af06d07ee3 pre-commit hook: disable isort 2024-11-13 21:06:38 +01:00
lukas 315e22a8ac Add yaml to dependencies
Django CI/CD / test (push) Successful in 1m28s
Django CI/CD / build-and-push (push) Successful in 2m33s
2024-11-11 18:14:48 +01:00
lukas 19676f8441 Implement converting prices (#79)
Django CI/CD / test (push) Successful in 1m17s
Django CI/CD / build-and-push (push) Successful in 2m10s
Reviewed-on: #79
2024-11-11 16:36:57 +00:00
lukas f61cde180f Pass search_string to search_field.html
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-11-10 00:05:33 +01:00
lukas a53818257c Fix being unable to override c-vars from render_from_string 2024-11-10 00:05:11 +01:00
lukas 2d3ea714c4 Extend session search 2024-11-09 23:52:09 +01:00
lukas 832bb48983 Device: safe long type names directly in database 2024-11-09 23:51:28 +01:00
lukas c6b1badf39 add session search
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 2m16s
2024-11-09 21:34:01 +00:00
lukas a3ed93c154 handle non-existent icons
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been cancelled
2024-11-09 21:30:13 +00:00
lukas cf503a7b7d improve devcontainer
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:56:20 +01:00
lukas d81df6452a add dev container
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 2m26s
2024-11-09 11:22:21 +01:00
lukas d9290373b0 also sort purchases by created_at
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m24s
2024-10-18 09:50:10 +02:00
lukas f8d621e710 fix stat dropdown
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 2m7s
2024-10-16 18:31:12 +02:00
lukas 9992d9c9bd set edition platform to unspecified if none 2024-10-16 18:06:40 +02:00
lukas 2ae81bb00f update django-cotton to 1.2.1 2024-10-16 17:49:55 +02:00
lukas 993abb4710 editorconfig: do not add newline to HTML 2024-10-16 17:45:23 +02:00
lukas 23502eab85 do not throw error when no stats to calculate 2024-10-16 17:45:23 +02:00
lukas c517d735c7 use unified dateformat more 2024-10-16 17:45:23 +02:00
lukas 19056f846e view_game: display timezone-aware time for end timestamp 2024-10-16 17:45:23 +02:00
lukas 0759ad0804 make purchase price a float 2024-10-16 17:45:23 +02:00
lukas 228fc2bf5f avoid exception on game overview when sessions are 0 2024-10-16 17:45:23 +02:00
lukas a5a7041920 rename icon 2024-10-16 17:45:23 +02:00
lukas fbd829f70e order platforms by name 2024-10-16 17:45:23 +02:00
lukas 4873f25248 remove css cruft 2024-10-16 17:45:23 +02:00
lukas 3578f1707f add more icons 2024-10-16 17:45:23 +02:00
lukas b74ccb6eaa Remove extraneous statement 2024-10-16 17:45:23 +02:00
lukas b0b1bb2d42 add icon field to platform, use everywhere 2024-10-16 17:45:23 +02:00
lukas c40764a02f fix bug in Component
A leftover from when there was only the A component function,
this bug was not found earlier because we used
templates instead of tags most of the time.
2024-09-14 10:40:03 +02:00
lukas 649351efde implement platform icons
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-09-14 06:42:34 +02:00
lukas 698c8966c0 add purchase date to game view
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 2m8s
2024-09-11 11:40:17 +02:00
lukas 7f6584ecf7 finish purchase from list 2024-09-11 11:39:54 +02:00
lukas 540f5ee42c align last column to the right 2024-09-11 11:39:48 +02:00
lukas 1c73268258 redirect to purchase list after modifying purchase 2024-09-10 14:50:49 +02:00
lukas 3063a3d143 refund purchase from list 2024-09-10 14:50:02 +02:00
lukas b589199ca6 drop purchase from list 2024-09-10 14:46:50 +02:00
lukas 2fc661dade re-add button titles 2024-09-10 14:46:10 +02:00
lukas 1f535a6e84 formatting 2024-09-10 14:26:06 +02:00
lukas a9c1135639 improve layout
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m12s
2024-09-09 11:25:29 +02:00
lukas 58cfaca1a9 add table header actions
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m23s
2024-09-08 21:03:37 +02:00
lukas c1b3493c80 Merge calculated and manual duration
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-09-07 23:35:59 +02:00
lukas a1df8720f5 Fix missing variable reference
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 2m3s
2024-09-07 23:20:17 +02:00
lukas 5a852bc2b9 tailwind: define accent and background colors
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 2m6s
2024-09-04 21:59:29 +02:00
lukas 8ab9bfeeeb update deps 2024-09-04 21:59:06 +02:00
lukas 5eee7176d4 add streak-releted basic functionality 2024-09-04 21:58:56 +02:00
lukas 98c9c1faee move time-related functionality out of views.general
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-04 21:55:22 +02:00
lukas 645ffa0dad update styles
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 2m4s
2024-09-03 22:39:25 +02:00
lukas 4358708262 add links to add a new X to: game, edition, purchase, session, device, platform
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-09-03 15:48:58 +02:00
lukas c738245783 Properly display non-game type names
Django CI/CD / test (push) Successful in 1m8s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-09-02 23:52:28 +02:00
lukas 57184ceea0 add one more breakpoint to better utilize smaller screens
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-09-02 23:44:18 +02:00
lukas c2b9409562 update styles
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-09-02 20:14:52 +02:00
lukas e067e65bce linkify game, edition, purchase, session references
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Has been cancelled
also add link styles for links in a table row
2024-09-02 20:04:21 +02:00
lukas b8258e2937 replace slippers with django-cotton
Django CI/CD / test (push) Successful in 59s
Django CI/CD / build-and-push (push) Successful in 2m4s
main reason: slippers cannot pass request via context inside its
components, making it annoying to use template takes that take request.
more reasons: not actively worked on, no named slots, having to define
components in components.yaml + new components do not get registered
without restarting server
2024-09-02 17:43:41 +02:00
lukas 9af4c79947 improve game view
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-19 21:58:43 +02:00
lukas d8b8182b91 fix table top rounding 2024-08-13 08:36:40 +02:00
lukas 2fd44c1f53 separate views out 2/2
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-12 21:52:26 +02:00
lukas c3f99d124c update base.css 2024-08-12 21:42:56 +02:00
lukas 51f5b9fceb update ruff path 2024-08-12 21:42:47 +02:00
lukas 973f4416de separate views out 1/2 2024-08-12 21:42:34 +02:00
lukas a84209eb81 sort by timestamp
Django CI/CD / test (push) Successful in 51s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 21:39:14 +02:00
lukas 498cd69328 improve display of game names, durations 2024-08-11 20:29:47 +02:00
lukas b28c42d945 delete platform
Django CI/CD / test (push) Successful in 51s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 20:21:44 +02:00
lukas 3099f02145 list editions 2024-08-11 20:21:27 +02:00
lukas 74b9d0421c list platforms, fix editing platform 2024-08-11 18:34:50 +02:00
lukas c61adad180 list games 2024-08-11 18:21:11 +02:00
lukas 298ecb4092 formatting 2024-08-11 17:58:35 +02:00
lukas 020e12e20b remove session recent filter 2024-08-11 17:58:08 +02:00
lukas 6ef56bfed5 list, edit, and delete devices 2024-08-11 17:53:36 +02:00
lukas fda4913c97 add ruff to shell.nix
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-11 17:24:50 +02:00
lukas e85b32e22f update styles 2024-08-11 17:24:33 +02:00
lukas 2d6d6d24a4 formatting 2024-08-11 17:24:26 +02:00
lukas 00993a85db remove black 2024-08-11 17:24:19 +02:00
lukas 4f7e708255 vscode: replace black with ruff 2024-08-11 17:23:59 +02:00
lukas 238e4839e0 formatting 2024-08-11 17:23:28 +02:00
lukas b0ad806a93 fix version_date 2024-08-11 17:23:18 +02:00
lukas 453b4fd922 add manage -> sessions 2024-08-11 17:22:58 +02:00
lukas bb0d24809e make sure titles are truncated 2024-08-11 17:13:31 +02:00
lukas 3abd4c4af9 reuse existing variable 2024-08-09 13:59:14 +02:00
lukas 2e5e77b4e5 replace navbar
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-09 13:14:18 +02:00
lukas e79cf5de7a fix non-working views 2024-08-09 13:12:47 +02:00
lukas c15eaca205 only overflow table, not paginator, improve styling
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-09 12:42:54 +02:00
lukas 496c99ccf1 formatting 2024-08-09 12:23:49 +02:00
lukas 992622e8d1 make it possible to not use paginator when limit = 0 2024-08-09 12:23:40 +02:00
lukas cabe36c822 add dark/light mode toggle 2024-08-09 12:22:26 +02:00
lukas d84b67c460 improve pagination 2024-08-09 11:47:10 +02:00
lukas 1c28950b53 add pagination
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-08-08 22:54:15 +02:00
lukas b54bcdd9e9 remove cruft 2024-08-08 21:20:17 +02:00
lukas 9ec6c958c8 remove unnecessary styles 2024-08-08 21:20:08 +02:00
lukas 25deac6ea9 add more types 2024-08-08 21:19:43 +02:00
lukas a5ac10b20d use model variables for foreign keys where possible 2024-08-08 20:22:25 +02:00
lukas 3de40ccad3 create purchase list without paging 2024-08-08 20:17:43 +02:00
lukas 6a5dc9b62c even more formatting 2024-08-08 15:08:50 +02:00
lukas b6014a72e0 .gitignore: add .direnv 2024-08-08 14:49:09 +02:00
lukas 245b47b8b3 improve shell.nix
do not let poetry manage venvs
no need to override python3
2024-08-08 14:48:58 +02:00
lukas e33f23c18f add .envrc 2024-08-08 14:48:20 +02:00
lukas 33012bc328 vscode: add extensions and settings 2024-08-08 14:48:10 +02:00
lukas 447bd4820c reformat with djlint --reformat 2024-08-08 14:47:51 +02:00
lukas 72e89dae77 remove cruft
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 2m0s
2024-08-08 09:47:06 +02:00
lukas 1cd0a8c0fb add shell.nix 2024-08-08 09:27:51 +02:00
lukas a9a430f856 change vscode settings 2024-08-08 09:27:36 +02:00
lukas 0ee4c50a24 update dependencies
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-08-08 09:17:09 +02:00
lukas 714f0d97a9 Reformat
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-08-04 22:40:43 +02:00
lukas d622ddfbf3 Add all-time stats 2024-08-04 22:40:37 +02:00
lukas 86fd40cc4a Do not save non-durations as manual
Django CI/CD / test (push) Successful in 1m56s
Django CI/CD / build-and-push (push) Successful in 2m23s
2024-07-23 09:51:15 +02:00
lukas e174850262 Update deps
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-07-11 13:28:09 +02:00
lukas 6328d835ee Fix formatting 2024-07-09 23:04:14 +02:00
lukas 34d42e2af5 Fix list session links
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 2m1s
2024-07-09 23:03:52 +02:00
lukas e19caf47bf Make game overview more appealing
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2024-07-09 23:03:03 +02:00
lukas 72998ffc02 Fix incorrect font name 2024-07-09 20:38:03 +02:00
lukas ba44814474 Improve game links
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m56s
2024-07-09 19:40:47 +02:00
lukas 86f8fde8fa Avoid errors when displaying game overview with zero sessions
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 2m10s
2024-07-09 07:32:49 +02:00
lukas 811fec4b11 Ignore manual sessions when calculating session average
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-07-02 17:27:44 +02:00
lukas fe6cf2758c make dev does not ignore warnings
Django CI/CD / test (push) Successful in 1m9s
Django CI/CD / build-and-push (push) Successful in 1m55s
2024-06-26 18:35:05 +02:00
lukas 1e1372ca56 Update Python deps 2024-06-26 18:34:38 +02:00
lukas d91c0bc255 Update npm deps
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-06-26 17:39:53 +02:00
lukas a14f5d3ae5 Add npm-check-updates 2024-06-26 17:39:39 +02:00
lukas 4ac13053d5 Use new Poetry section for main deps 2024-06-26 17:31:43 +02:00
lukas e9311225e7 Make setting up and developing easier 2024-06-26 17:18:58 +02:00
lukas 44c70a5ee7 Formatting
Django CI/CD / test (push) Successful in 1m21s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-06-03 18:19:11 +02:00
lukas cd804f2c77 Sort url paths 2024-06-03 18:18:58 +02:00
lukas 15997bd5af Re-enable delete session delete view 2024-06-03 18:07:10 +02:00
lukas 880ea93424 Unify url path names 2024-06-03 18:05:34 +02:00
lukas dc1a9d5c4f Make sure attribute chains are evaluated safely 2024-05-30 14:26:38 +02:00
lukas 51c25659a9 djhtml formatting
Django CI/CD / test (push) Successful in 1m5s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-04-30 12:04:16 +02:00
lukas 973dda59d2 Improve game overview header 2024-04-30 12:03:52 +02:00
lukas 64edca9ffa Use display name in session list
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m52s
2024-04-29 19:21:05 +02:00
lukas 86e25b84ab Allow deleting purchases
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-29 16:35:54 +02:00
lukas edc1d062bc Update gunicorn to version 22.0.0
Django CI/CD / test (push) Successful in 1m46s
Django CI/CD / build-and-push (push) Successful in 2m28s
2024-04-17 12:28:10 +02:00
lukas 12a517c9fa Update sqlparse to version 0.5
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m58s
2024-04-16 11:44:24 +02:00
lukas c1882f66e3 Improve purchase name consistency on stats page
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
2024-04-15 13:55:17 +02:00
lukas 1e87e67eb1 Reformat HTML with djhtml
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-04-04 11:27:33 +02:00
lukas 84552e088b Update more dependencies 2024-04-04 11:27:14 +02:00
lukas 79dc8ae25c Update black
Django CI/CD / test (push) Failing after 44s
Django CI/CD / build-and-push (push) Has been skipped
2024-04-04 11:09:09 +02:00
lukas cee06e4f64 Update dependencies
Django CI/CD / test (push) Successful in 47s
Django CI/CD / build-and-push (push) Successful in 1m47s
2024-04-04 10:46:59 +02:00
lukas d9b5f0eab2 stats: add monthly playtimes
Django CI/CD / test (push) Successful in 58s
Django CI/CD / build-and-push (push) Successful in 1m54s
2024-04-02 08:18:58 +02:00
lukas ff28600710 Fix timestamp minutes on game page
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 2m2s
Fixed #72
2024-03-27 14:38:00 +01:00
lukas 7517bf5f37 Add stats for dropped purchases
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m57s
2024-03-10 22:48:46 +01:00
lukas 780a04d13f Do not edit sort_name invisibly
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 2m0s
Fixes #64
2024-03-04 16:50:37 +01:00
lukas fd04e9fa77 Sort prefetch instead of the result
Django CI/CD / test (push) Successful in 57s
Django CI/CD / build-and-push (push) Successful in 1m52s
order_by on the final queryset results in duplicating editions, 1 for each purchase
to fix it we sort the thing we actually want to sort - non-game purchases - in a prefetch earlier in the code
2024-02-18 12:31:03 +01:00
lukas 18902aedac Reformat
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m53s
2024-02-18 09:03:35 +01:00
lukas f9e37e9b1e Sort purchases also by date purchased
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Has been cancelled
2024-02-18 09:02:08 +01:00
lukas c747cd1fd8 Reformat
Django CI/CD / test (push) Successful in 55s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-02-10 09:50:53 +01:00
lukas 6a5457191a Add logout button 2024-02-10 09:48:09 +01:00
lukas 76f6d0c377 Fix CSS bug 2024-02-10 09:03:16 +01:00
lukas ae93703c08 Remove login_required from clone_session_by_id
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m35s
2024-02-09 22:27:28 +01:00
lukas c55176090c Temporarily disable tests
Django CI/CD / test (push) Successful in 52s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-02-09 22:08:49 +01:00
lukas 081b8a92de Require login by default
Django CI/CD / test (push) Failing after 1m1s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 22:03:24 +01:00
lukas d02a60675f Render notes as Markdown
Django CI/CD / test (push) Failing after 1m5s
Django CI/CD / build-and-push (push) Has been skipped
2024-02-09 21:37:39 +01:00
lukas 4670568acb Add .DS_Store to .gitignore
Django CI/CD / test (push) Successful in 1m4s
Django CI/CD / build-and-push (push) Successful in 1m34s
2024-01-15 22:09:29 +01:00
lukas 4b75a1dea9 Increase session count on game overview when starting a new session
Django CI/CD / test (push) Successful in 50s
Django CI/CD / build-and-push (push) Successful in 1m32s
2024-01-15 21:41:25 +01:00
lukas e2b7ff2e15 Remove cruft
Django CI/CD / test (push) Successful in 53s
Django CI/CD / build-and-push (push) Successful in 1m30s
2024-01-15 19:17:27 +01:00
lukas b94aa49fc3 Fix title not being displayed on the Recent sessions page 2024-01-15 19:17:24 +01:00
lukas 73a92e5636 Mark refunded purchases red
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-15 11:19:18 +01:00
lukas 42b28665e1 Version 1.5.2
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-14 21:28:38 +01:00
lukas 6ba187f8e4 Make it possible to end session from game overview 2024-01-14 21:27:18 +01:00
lukas a765fd8d00 More game overview optimizations
Django CI/CD / test (push) Successful in 1m15s
Django CI/CD / build-and-push (push) Successful in 1m37s
2024-01-14 17:04:06 +01:00
lukas 854e3cc54a Do not copy notes when cloning session
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m45s
2024-01-14 13:05:45 +01:00
lukas 2d8eb32e90 Remove cruft
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m29s
2024-01-10 17:13:59 +01:00
lukas 1f1ed79ee5 Optimize session listing 2024-01-10 16:57:01 +01:00
lukas 01fd7bad69 Remove cruft 2024-01-10 15:55:08 +01:00
lukas 44f49e5974 Session list: speed up starting new sessions
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-10 15:54:09 +01:00
lukas 0cf3411f63 Make ending session from session list faster
Django CI/CD / test (push) Successful in 1m16s
Django CI/CD / build-and-push (push) Successful in 1m40s
2024-01-10 15:12:45 +01:00
lukas aa669710e1 Change update_session to template partial
Django CI/CD / test (push) Failing after 1m3s
Django CI/CD / build-and-push (push) Has been skipped
2024-01-10 14:10:13 +01:00
lukas 242833f886 Make it possible to drop purchases, or consider them infinite
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2024-01-03 22:35:39 +01:00
lukas 0cdfd3c298 Stats: optimize
Django CI/CD / test (push) Successful in 56s
Django CI/CD / build-and-push (push) Successful in 1m33s
2024-01-03 21:35:47 +01:00
lukas a98b4839dd Fix wrong unfinished purchases calculation
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m42s
2024-01-02 20:03:59 +01:00
lukas 1999f13cf2 stats: add first and last play
Django CI/CD / test (push) Successful in 1m12s
Django CI/CD / build-and-push (push) Successful in 1m36s
2024-01-01 18:42:14 +01:00
lukas 8466f67c86 Fix errors caused by empty values
Django CI/CD / test (push) Successful in 1m10s
Django CI/CD / build-and-push (push) Successful in 1m41s
2024-01-01 18:21:50 +01:00
lukas d9fbb4b896 Add title to stats page
Django CI/CD / build-and-push (push) Blocked by required conditions
Django CI/CD / test (push) Has been cancelled
2023-12-15 10:58:15 +01:00
lukas 4ff3692606 Remove duplicate block
Django CI/CD / test (push) Successful in 1m34s
Django CI/CD / build-and-push (push) Successful in 1m29s
2023-11-30 18:05:52 +01:00
lukas 8289c48896 Fix CI error
Django CI/CD / test (push) Successful in 1m14s
Django CI/CD / build-and-push (push) Successful in 1m24s
2023-11-30 17:44:31 +01:00
lukas d1b9202337 Update poetry.lock
Django CI/CD / test (push) Failing after 1m15s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-30 17:35:44 +01:00
lukas fde93cb875 Organize better 2023-11-30 17:35:44 +01:00
lukas d1c3ac6079 Revert "Move GraphQL to separata app"
This reverts commit 6ac4209492.
2023-11-30 17:35:44 +01:00
lukas d921c2d8a6 Revert "Add UpdateGameMutation"
This reverts commit e9e61403a9.
2023-11-30 17:35:44 +01:00
lukas 52513e1ed8 Add UpdateGameMutation 2023-11-30 17:35:44 +01:00
lukas cb380814a7 Move GraphQL to separata app 2023-11-30 17:35:44 +01:00
lukas 5ef8c07f30 Initial working API 2023-11-30 17:35:44 +01:00
lukas 9573c3b8ff Better formatting
Django CI/CD / test (push) Successful in 1m11s
Django CI/CD / build-and-push (push) Successful in 1m22s
2023-11-29 22:26:43 +01:00
lukas c4354a1380 Update poetry.lock
Django CI/CD / test (push) Successful in 1m3s
Django CI/CD / build-and-push (push) Successful in 1m14s
2023-11-29 22:22:23 +01:00
lukas a245b6ff0f Fix longest session formatting
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m24s
Put space between hours and minutes
2023-11-29 09:08:10 +01:00
lukas 6329d380b7 Editions are unique if name, platform OR year is different
Django CI/CD / test (push) Successful in 1m25s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-28 14:44:11 +01:00
lukas 76fbc39fed Disable hx-boost
Django CI/CD / test (push) Successful in 1m1s
Django CI/CD / build-and-push (push) Successful in 1m17s
2023-11-28 14:29:56 +01:00
lukas 4b6734c173 Add width, height, alt to images 2023-11-28 14:29:11 +01:00
lukas b505b5b430 Stats: add highest session average
Django CI/CD / test (push) Successful in 1m2s
Django CI/CD / build-and-push (push) Successful in 1m23s
2023-11-21 21:57:17 +01:00
lukas 87553ebdc5 Add djlint pre-commit hook
Django CI/CD / test (push) Successful in 1m13s
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-21 18:19:25 +01:00
lukas ba4fc0cac5 Do not trigger hx-boost for non-submit buttons
Django CI/CD / test (push) Successful in 1m7s
Django CI/CD / build-and-push (push) Successful in 1m20s
2023-11-21 18:12:58 +01:00
lukas 8cb0276215 Use better way to find out if model record exists 2023-11-21 18:03:01 +01:00
lukas f9a51ee83d Remove experimental layout 2023-11-21 18:03:01 +01:00
lukas c9deba7d65 Add stats for most sessions, longest session 2023-11-21 17:02:44 +01:00
lukas c55fbe86b5 Support HTMX with Django Debug Toolbar 2023-11-21 16:59:21 +01:00
lukas 0e93993498 Add django-debug-toolbar 2023-11-21 14:42:37 +01:00
lukas 9fccdfbff0 Make links colorful 2023-11-20 23:07:11 +01:00
lukas d78139a5b3 Display finished DLCs in stats better
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m26s
2023-11-20 21:56:16 +01:00
lukas 7dc43fbf77 Fix wrong export name 2023-11-20 21:54:51 +01:00
lukas 5442926457 Allow DLC to have date_finished set
Django CI/CD / test (push) Successful in 1m6s
Django CI/CD / build-and-push (push) Successful in 1m35s
2023-11-20 21:42:23 +01:00
lukas db4c635260 Remote JavaScript files 2023-11-20 21:25:21 +01:00
lukas 4a1d08d4df Fix CI, re-add test step
Django CI/CD / test (push) Successful in 1m0s
Django CI/CD / build-and-push (push) Successful in 1m45s
2023-11-18 11:10:33 +01:00
lukas c35b539c42 Merge sessions and notes
Django CI/CD / build-and-push (push) Successful in 1m9s
2023-11-17 21:20:33 +01:00
lukas bbe5e072b2 Don't display prices if zero 2023-11-17 21:10:56 +01:00
lukas 6fc2f623dc Apply djlint 2023-11-17 21:06:57 +01:00
lukas 9481bd5fef Add pre-commit
Django CI/CD / build-and-push (push) Successful in 1m33s
2023-11-17 09:34:51 +01:00
lukas 4083165123 Use the black profile for isort 2023-11-17 09:15:18 +01:00
lukas 45bb2681c7 Use isort on migrations 2023-11-17 09:15:06 +01:00
lukas dbb8ec3f9a Handle empty edition_id 2023-11-17 09:14:25 +01:00
lukas 206b5f6d46 Prevent HTMX from messing up the initial state
Django CI/CD / build-and-push (push) Successful in 1m15s
2023-11-16 20:33:56 +01:00
lukas b7e14ecc83 Account for no sessions
Django CI/CD / build-and-push (push) Successful in 1m21s
2023-11-16 20:29:08 +01:00
lukas 912e010729 Enable hx-boost everywhere
Django CI/CD / build-and-push (push) Successful in 1m18s
2023-11-16 19:56:08 +01:00
lukas a485237456 Fix form not syncing due to HTMX
Django CI/CD / build-and-push (push) Successful in 2m38s
2023-11-16 19:03:16 +01:00
lukas f5faf92ee0 Fix error
Django CI/CD / build-and-push (push) Successful in 1m57s
2023-11-16 16:53:59 +01:00
lukas 07452d8c43 Re-instance gitea actions
Django CI/CD / test (push) Failing after 34s
Django CI/CD / build-and-push (push) Has been skipped
2023-11-16 16:51:52 +01:00
lukas 229a79d266 Update .drone.yml testing
continuous-integration/drone/push Build is failing
2023-11-16 16:30:17 +01:00
lukas c6ed577fe3 Formatting
continuous-integration/drone/push Build is failing
2023-11-16 16:27:41 +01:00
lukas 171e4779a3 Move static files in prod 2023-11-16 16:27:41 +01:00
lukas 79f94e5984 Fix docker-compose.yml 2023-11-16 16:27:41 +01:00
lukas ccebcb89c6 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-16 16:27:41 +01:00
lukas fe0a6b39e3 Fix .dockerignore 2023-11-16 16:27:41 +01:00
lukas 6a495f951f Remove Django admin 2023-11-16 16:27:41 +01:00
lukas c8646d0a0c Update dependencies 2023-11-16 16:27:41 +01:00
lukas f2bb15e669 Fix naive date 2023-11-16 16:27:41 +01:00
lukas c49177d63c isort 2023-11-16 16:27:41 +01:00
lukas bd8d30eac1 Improve time-related stuff
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-16 16:27:41 +01:00
lukas c44d8bf427 Improve time-related stuff
continuous-integration/drone/push Build is passing
Add created_at to all models
Add modified_at to Session
Get rid of custom now() function
Make sure aware datetime is used everywhere
2023-11-15 19:14:09 +01:00
lukas 3f037b4c7c Only allow choosing purchases of selected edition
continuous-integration/drone/push Build is passing
2023-11-15 14:25:42 +01:00
lukas 8783d1fc8e Name and related_purchase validation for non-games 2023-11-15 13:04:47 +01:00
lukas 9a1d24dbfd Sort imports, remove cruft 2023-11-15 12:19:31 +01:00
lukas 4720660cff Fix wrong playrange ordering 2023-11-15 10:40:52 +01:00
lukas e158bc0623 Improve how editions and purchases are displayed
continuous-integration/drone/push Build is passing
2023-11-15 10:37:24 +01:00
lukas 8982fc5086 Game View: order editions by year 2023-11-14 21:19:36 +01:00
lukas 729e1d939b Version 1.5.1
continuous-integration/drone/push Build encountered an error
2023-11-14 21:10:42 +01:00
lukas 2b4683e489 Improve and cleanup ConditionalElementHandler 2023-11-14 21:09:43 +01:00
lukas cce810e8cf Improve purchase __str__ 2023-11-14 19:55:56 +01:00
lukas 62cd17f702 Disallow choosing non-game purchase as related purchase 2023-11-14 19:55:19 +01:00
lukas f31280c682 Version 1.5.0
continuous-integration/drone/push Build is passing
2023-11-14 19:31:17 +01:00
lukas a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
lukas ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
lukas c8a3212b77 CI: run migrations before tests
continuous-integration/drone/push Build was killed
2023-11-12 08:11:22 +01:00
lukas d211326c3f Make sure empty stats are 0
continuous-integration/drone/push Build is failing
2023-11-12 08:01:12 +01:00
lukas 270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
lukas 13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
lukas 015b6db2f7 Fix detecting manual durations
continuous-integration/drone/push Build is failing
2023-11-11 15:02:28 +01:00
lukas 667b161fff Remove deprecated USE_L10N
continuous-integration/drone/push Build is failing
2023-11-10 21:37:13 +01:00
lukas 5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
lukas 3b37f2c3f0 Fix edge case in format_duration
continuous-integration/drone/push Build is passing
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
lukas 4517ff2b5a Fix ordering
continuous-integration/drone/push Build is passing
2023-11-09 21:43:17 +01:00
lukas 884ce13e26 Also prefill year between game and edition
continuous-integration/drone/push Build is passing
2023-11-09 21:20:12 +01:00
lukas dd219bae9d Version 1.4.0
continuous-integration/drone/push Build is passing
2023-11-09 21:11:43 +01:00
lukas 60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
lukas 1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
lukas c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
lukas b0be7b5887 Fix sort names getting mangled
continuous-integration/drone/push Build is passing
2023-11-09 15:41:46 +01:00
lukas 099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
lukas a879360ebd UX improvements
continuous-integration/drone/push Build is passing
* 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 and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
lukas 866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
lukas ce3c4b55f0 Order devices alphabetically on new session form
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
lukas c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
lukas cdc6ca1324 Fix potential division by zero
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
lukas e7ed349356 Add more stats
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
lukas 5052ca7dbf Add more stats
continuous-integration/drone/push Build is passing
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
lukas f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
lukas 666dee33ba Model changes
continuous-integration/drone/push Build is passing
* 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
2023-11-06 19:48:12 +01:00
lukas e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
188 changed files with 15836 additions and 3971 deletions
+25
View File
@@ -0,0 +1,25 @@
{
"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",
}
+10 -1
View File
@@ -5,4 +5,13 @@
.venv .venv
.vscode .vscode
node_modules node_modules
src/timetracker/static/* static
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile
+2 -1
View File
@@ -5,11 +5,12 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.10 image: python:3.12
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run python manage.py migrate
- poetry run pytest - poetry run pytest
- name: build-prod - name: build-prod
+3
View File
@@ -15,3 +15,6 @@ indent_size = 4
[**/*.js] [**/*.js]
indent_style = space indent_style = space
indent_size = 2 indent_size = 2
[*.html]
insert_final_newline = false
+1
View File
@@ -0,0 +1 @@
use nix
+50
View File
@@ -0,0 +1,50 @@
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
+4 -1
View File
@@ -1,9 +1,12 @@
__pycache__ __pycache__
.mypy_cache .mypy_cache
.pytest_cache .pytest_cache
.venv .venv/
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
/static/ /static/
dist/ dist/
.DS_Store
.python-version
.direnv
+11
View File
@@ -0,0 +1,11 @@
{
"recommendations": [
"charliermarsh.ruff",
"ms-python.python",
"ms-python.vscode-pylance",
"ms-python.debugpy",
"batisteo.vscode-django",
"bradlc.vscode-tailwindcss",
"EditorConfig.EditorConfig"
]
}
+26
View File
@@ -0,0 +1,26 @@
{
// 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"
}
]
}
+24 -2
View File
@@ -4,8 +4,30 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic", "python.analysis.typeCheckingMode": "strict",
"[python]": { "[python]": {
"editor.defaultFormatter": "ms-python.black-formatter" "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"
]
} }
+110
View File
@@ -1,3 +1,113 @@
## 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 ## 1.3.0 / 2023-11-05 15:09+01:00
### New ### New
+32 -18
View File
@@ -1,27 +1,41 @@
FROM node as css FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim AS builder
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
FROM python:3.10.9-slim-bullseye ENV UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PYTHONUNBUFFERED=1
ENV VERSION_NUMBER 1.3.0
ENV PROD 1
ENV PYTHONUNBUFFERED=1
RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN --mount=type=cache,target=/root/.cache/uv \
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css --mount=type=bind,source=uv.lock,target=uv.lock \
COPY entrypoint.sh / --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 /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry ENV VERSION_NUMBER=1.6.1
RUN poetry install
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]
+35 -21
View File
@@ -3,60 +3,74 @@ all: css migrate
initialize: npm css migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
PYTHON_VERSION = 3.12
npm: npm:
npm install npm install
css: common/input.css css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css npx @tailwindcss/cli -i ./common/input.css -o ./games/static/base.css
css-dev: css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations uv run python manage.py makemigrations
migrate: makemigrations migrate: makemigrations
poetry run python manage.py migrate 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"
dev: migrate
poetry run python manage.py runserver
caddy: caddy:
caddy run --watch caddy run --watch
dev-prod: migrate collectstatic dev-prod: migrate collectstatic
PROD=1 poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker PROD=1 uv run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker
dumpgames: dumpgames:
poetry run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml uv run python manage.py dumpdata --format yaml games --output tracker_fixture.yaml
loadplatforms: loadplatforms:
poetry run python manage.py loaddata platforms.yaml uv run python manage.py loaddata platforms.yaml
loadall: loadall:
poetry run python manage.py loaddata data.yaml uv run python manage.py loaddata data.yaml
loadsample: loadsample:
poetry run python manage.py loaddata sample.yaml uv run python manage.py loaddata sample.yaml
createsuperuser: createsuperuser:
poetry run python manage.py createsuperuser uv run python manage.py createsuperuser
shell: shell:
poetry run python manage.py shell uv run python manage.py shell
collectstatic: collectstatic:
poetry run python manage.py collectstatic --clear --no-input uv run python manage.py collectstatic --clear --no-input
poetry.lock: pyproject.toml uv.lock: pyproject.toml
poetry install uv sync
test: poetry.lock test: uv.lock
poetry run pytest uv run --with pytest-django pytest
date: date:
poetry run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))' uv run python -c 'import datetime; from zoneinfo import ZoneInfo; print(datetime.datetime.isoformat(datetime.datetime.now(ZoneInfo("Europe/Prague")), timespec="minutes", sep=" "))'
cleanstatic: cleanstatic:
rm -r static/* rm -r static/*
+12
View File
@@ -1,3 +1,15 @@
# Timetracker # Timetracker
A simple game catalogue and play session tracker. 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`.
+46
View File
@@ -0,0 +1,46 @@
# 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
@@ -0,0 +1,344 @@
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",
)
+196 -66
View File
@@ -1,103 +1,233 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities;
@font-face { @plugin '@tailwindcss/typography';
font-family: "IBM Plex Mono"; @plugin '@tailwindcss/forms';
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2"); @plugin 'flowbite/plugin';
font-weight: 400;
font-style: normal; @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;
} }
@font-face {
font-family: "IBM Plex Sans"; /*
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2"); The default border color has changed to `currentcolor` in Tailwind CSS v4,
font-weight: 400; so we've added these compatibility styles to make sure everything still
font-style: normal; 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);
}
} }
@font-face { @utility min-w-20char {
font-family: "IBM Plex Serif"; min-width: 20ch;
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
} }
form label { @utility max-w-20char {
@apply dark:text-slate-400; max-width: 20ch;
} }
.responsive-table { @utility min-w-30char {
@apply dark:text-white mx-auto; min-width: 30ch;
} }
.responsive-table tr:nth-child(even) { @utility max-w-30char {
@apply bg-slate-800 max-width: 30ch;
} }
.responsive-table tbody tr:nth-child(odd) { @utility max-w-35char {
@apply bg-slate-900 max-width: 35ch;
} }
.responsive-table thead th { @utility max-w-40char {
@apply text-left border-b-2 border-b-slate-500 text-xl; max-width: 40ch;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
} }
@layer utilities { @layer utilities {
.max-w-20char { @font-face {
max-width: 20ch; font-family: 'IBM Plex Mono';
src: url('fonts/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
} }
.max-w-35char {
max-width: 40ch; @font-face {
font-family: 'IBM Plex Sans';
src: url('fonts/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
} }
.max-w-40char {
max-width: 40ch; @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, form input:disabled,
select, select:disabled,
textarea { textarea:disabled {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @apply cursor-not-allowed bg-neutral-secondary-strong text-fg-disabled;
} }
@media screen and (min-width: 768px) { .errorlist {
form input, @apply mt-4 mb-1 pl-3 py-2 bg-red-600 text-slate-200 w-[300px];
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} }
#button-container button { #button-container button {
@apply mx-1; @apply mx-1;
} }
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container { .basic-button-container {
@apply flex space-x-2 justify-center; @apply flex space-x-2 justify-center;
} }
.basic-button { .basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out; @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;
}
} }
+99 -9
View File
@@ -1,16 +1,19 @@
import re import re
from datetime import datetime, timedelta from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings from django.utils import timezone
from common.utils import generate_split_ranges
def now() -> datetime: dateformat: str = "%d/%m/%Y"
return datetime.now(ZoneInfo(settings.TIME_ZONE)) 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): def _safe_timedelta(duration: timedelta | int | None):
if duration == None: if duration is None:
return timedelta(0) return timedelta(0)
elif isinstance(duration, int): elif isinstance(duration, int):
return timedelta(seconds=duration) return timedelta(seconds=duration)
@@ -19,7 +22,7 @@ def _safe_timedelta(duration: timedelta | int | None):
def format_duration( def format_duration(
duration: timedelta | int | None, format_string: str = "%H hours" duration: timedelta | int | float | None, format_string: str = "%H hours"
) -> str: ) -> str:
""" """
Format timedelta into the specified format_string. Format timedelta into the specified format_string.
@@ -44,7 +47,7 @@ def format_duration(
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
days = hours = minutes = seconds = 0 days = hours = hours_float = minutes = seconds = 0
remainder = seconds = seconds_total remainder = seconds = seconds_total
if "%d" in format_string: if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds) days, remainder = divmod(seconds_total, day_seconds)
@@ -55,7 +58,7 @@ def format_duration(
minutes, seconds = divmod(remainder, minute_seconds) minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"d": str(days), "d": str(days),
"H": str(hours), "H": str(hours) if "m" not in format_string else str(hours_float),
"m": str(minutes), "m": str(minutes),
"s": str(seconds), "s": str(seconds),
"r": str(seconds_total), "r": str(seconds_total),
@@ -77,3 +80,90 @@ def format_duration(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string rf"%\d*\.?\d*{pattern}", replacement, formatted_string
) )
return 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
@@ -0,0 +1,167 @@
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})}"
@@ -0,0 +1,33 @@
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
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,24 @@
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
+4 -2
View File
@@ -10,13 +10,14 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: volumes:
- "static-files:/home/timetracker/app/static" - "static-files:/var/www/django/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: caddy image: caddy
volumes: volumes:
- "static-files:/usr/share/caddy" - "static-files:/usr/share/caddy:ro"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile" - "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
@@ -26,3 +27,4 @@ services:
volumes: volumes:
static-files: static-files:
+9 -5
View File
@@ -2,18 +2,22 @@
# Apply database migrations # Apply database migrations
set -euo pipefail set -euo pipefail
echo "Apply database migrations" echo "Apply database migrations"
poetry run python manage.py migrate python manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input python manage.py collectstatic --clear --no-input
_term() { _term() {
echo "Caught SIGTERM signal!" echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid" kill -SIGTERM "$gunicorn_pid"
kill -SIGTERM "$django_q_pid"
} }
trap _term SIGTERM trap _term SIGTERM
echo "Starting app" echo "Starting Django-Q cluster"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$! python manage.py qcluster & django_q_pid=$!
wait "$gunicorn_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"
+9 -2
View File
@@ -1,11 +1,18 @@
from django.contrib import admin from django.contrib import admin
from games.models import Game, Platform, Purchase, Session, Edition, Device from games.models import (
Device,
ExchangeRate,
Game,
Platform,
Purchase,
Session,
)
# Register your models here. # Register your models here.
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Purchase) admin.site.register(Purchase)
admin.site.register(Platform) admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device) admin.site.register(Device)
admin.site.register(ExchangeRate)
+116
View File
@@ -0,0 +1,116 @@
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)
+40
View File
@@ -1,6 +1,46 @@
# from datetime import timedelta
from django.apps import AppConfig 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): class GamesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField" default_auto_field = "django.db.models.BigAutoField"
name = "games" 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")
+504
View File
@@ -0,0 +1,504 @@
- 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
+196 -24
View File
@@ -1,22 +1,54 @@
from django import forms from django import forms
from django.db import transaction
from django.urls import reverse
from games.models import Game, Platform, Purchase, Session, Edition, Device 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_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M" attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
) )
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"}) 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): class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField( game = SingleGameChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name") queryset=Game.objects.order_by("sort_name"),
# ) widget=forms.Select(attrs={"autofocus": "autofocus"}),
purchase = forms.ModelChoiceField( )
queryset=Purchase.objects.order_by("edition__name"),
widget=autofocus_select_widget, 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: class Meta:
@@ -26,25 +58,74 @@ class SessionForm(forms.ModelForm):
} }
model = Session model = Session
fields = [ fields = [
"purchase", "game",
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"emulated",
"device", "device",
"note", "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 EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str: class IncludePlatformSelect(forms.SelectMultiple):
return f"{obj.name} ({obj.platform}, {obj.year_released})" 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): class PurchaseForm(forms.ModelForm):
edition = EditionChoiceField( def __init__(self, *args, **kwargs):
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget 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")) 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: class Meta:
widgets = { widgets = {
@@ -53,38 +134,84 @@ class PurchaseForm(forms.ModelForm):
} }
model = Purchase model = Purchase
fields = [ fields = [
"edition", "games",
"platform", "platform",
"date_purchased", "date_purchased",
"date_refunded", "date_refunded",
"infinite",
"price", "price",
"price_currency", "price_currency",
"ownership_type", "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")
class EditionForm(forms.ModelForm): # Set the type on the instance to use get_type_display()
game = forms.ModelChoiceField( # This is safe because we're not saving the instance.
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget 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.",
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class Meta:
model = Edition class IncludeNameSelect(forms.Select):
fields = ["game", "name", "platform", "year_released", "wikidata"] 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): class GameForm(forms.ModelForm):
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta: class Meta:
model = Game model = Game
fields = ["name", "wikidata"] fields = [
"name",
"sort_name",
"platform",
"original_year_released",
"year_released",
"status",
"mastered",
"wikidata",
]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm): class PlatformForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ["name", "group"] fields = [
"name",
"icon",
"group",
]
widgets = {"name": autofocus_input_widget} widgets = {"name": autofocus_input_widget}
@@ -93,3 +220,48 @@ class DeviceForm(forms.ModelForm):
model = Device model = Device
fields = ["name", "type"] fields = ["name", "type"]
widgets = {"name": autofocus_input_widget} 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
@@ -0,0 +1,64 @@
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
@@ -0,0 +1,24 @@
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."))
+74 -71
View File
@@ -1,5 +1,6 @@
# Generated by Django 4.1.4 on 2023-01-02 18:27 # Generated by Django 5.1.5 on 2025-01-29 21:26
import datetime
import django.db.models.deletion import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
@@ -8,94 +9,96 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [] dependencies = [
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name="Game", name='Device',
fields=[ fields=[
( ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
"id", ('name', models.CharField(max_length=255)),
models.BigAutoField( ('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)),
auto_created=True, ('created_at', models.DateTimeField(auto_now_add=True)),
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("wikidata", models.CharField(max_length=50)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Platform", name='Platform',
fields=[ fields=[
( ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
"id", ('name', models.CharField(max_length=255)),
models.BigAutoField( ('group', models.CharField(blank=True, default=None, max_length=255, null=True)),
auto_created=True, ('icon', models.SlugField(blank=True)),
primary_key=True, ('created_at', models.DateTimeField(auto_now_add=True)),
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("group", models.CharField(max_length=255)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name="Purchase", name='ExchangeRate',
fields=[ fields=[
( ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
"id", ('currency_from', models.CharField(max_length=255)),
models.BigAutoField( ('currency_to', models.CharField(max_length=255)),
auto_created=True, ('year', models.PositiveIntegerField()),
primary_key=True, ('rate', models.FloatField()),
serialize=False, ],
verbose_name="ID", options={
), 'unique_together': {('currency_from', 'currency_to', 'year')},
), },
("date_purchased", models.DateField()),
("date_refunded", models.DateField(blank=True, null=True)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
), ),
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( migrations.CreateModel(
name="Session", name='Session',
fields=[ fields=[
( ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
"id", ('timestamp_start', models.DateTimeField()),
models.BigAutoField( ('timestamp_end', models.DateTimeField(blank=True, null=True)),
auto_created=True, ('duration_manual', models.DurationField(blank=True, default=datetime.timedelta(0), null=True)),
primary_key=True, ('duration_calculated', models.DurationField(blank=True, null=True)),
serialize=False, ('note', models.TextField(blank=True, null=True)),
verbose_name="ID", ('emulated', models.BooleanField(default=False)),
), ('created_at', models.DateTimeField(auto_now_add=True)),
), ('modified_at', models.DateTimeField(auto_now=True)),
("timestamp_start", models.DateTimeField()), ('device', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_DEFAULT, to='games.device')),
("timestamp_end", models.DateTimeField()), ('game', models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='sessions', to='games.game')),
("duration_manual", models.DurationField(blank=True, null=True)),
("duration_calculated", models.DurationField(blank=True, null=True)),
("note", models.TextField(blank=True, null=True)),
(
"purchase",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="games.purchase",
),
),
], ],
options={
'get_latest_by': 'timestamp_start',
},
), ),
] ]
@@ -1,22 +0,0 @@
# Generated by Django 4.1.4 on 2023-01-02 18:55
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -0,0 +1,18 @@
# 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,23 +0,0 @@
# Generated by Django 4.1.4 on 2023-01-02 23:11
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0002_alter_session_duration_manual"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(blank=True, null=True),
),
migrations.AlterField(
model_name="session",
name="timestamp_end",
field=models.DateTimeField(blank=True, null=True),
),
]
@@ -0,0 +1,18 @@
# 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,22 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 14:49
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0003_alter_session_duration_manual_and_more"),
]
operations = [
migrations.AlterField(
model_name="session",
name="duration_manual",
field=models.DurationField(
blank=True, default=datetime.timedelta(0), null=True
),
),
]
@@ -0,0 +1,28 @@
# 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,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 17:43
from datetime import timedelta
from django.db import migrations
def set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == None:
session.duration_calculated = timedelta(0)
session.save()
def revert_set_duration_calculated_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_calculated == timedelta(0):
session.duration_calculated = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0004_alter_session_duration_manual"),
]
operations = [
migrations.RunPython(
set_duration_calculated_none_to_zero,
revert_set_duration_calculated_none_to_zero,
)
]
@@ -0,0 +1,38 @@
# 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),
]
@@ -0,0 +1,59 @@
# 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=''),
),
]
@@ -1,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-09 18:04
from datetime import timedelta
from django.db import migrations
def set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == None:
session.duration_manual = timedelta(0)
session.save()
def revert_set_duration_manual_none_to_zero(apps, schema_editor):
Session = apps.get_model("games", "Session")
for session in Session.objects.all():
if session.duration_manual == timedelta(0):
session.duration_manual = None
session.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0005_auto_20230109_1843"),
]
operations = [
migrations.RunPython(
set_duration_manual_none_to_zero,
revert_set_duration_manual_none_to_zero,
)
]
@@ -1,35 +0,0 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0006_auto_20230109_1904"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
migrations.AlterField(
model_name="session",
name="purchase",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.purchase"
),
),
]
+18
View File
@@ -0,0 +1,18 @@
# 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),
),
]
-41
View File
@@ -1,41 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]
@@ -0,0 +1,190 @@
# 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),
]
-34
View File
@@ -1,34 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]
@@ -0,0 +1,21 @@
# 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,21 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]
@@ -0,0 +1,17 @@
# 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',
),
]
@@ -0,0 +1,20 @@
# 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,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]
@@ -0,0 +1,32 @@
# 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(),
),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]
+35
View File
@@ -0,0 +1,35 @@
# 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,31 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]
@@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -0,0 +1,19 @@
# 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()),
),
]
@@ -0,0 +1,39 @@
# 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,23 +0,0 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]
+414 -62
View File
@@ -1,31 +1,139 @@
from datetime import datetime, timedelta import logging
from typing import Any from datetime import timedelta
from zoneinfo import ZoneInfo
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 from common.time import format_duration
from django.conf import settings
from django.db import models logger = logging.getLogger("games")
from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
class Meta:
unique_together = [["name", "platform", "year_released"]]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50) 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): def __str__(self):
return self.name return self.name
def finished(self):
return (self.status == self.Status.FINISHED or
self.playevents.filter(ended__isnull=False).exists())
class Edition(models.Model): def abandoned(self):
game = models.ForeignKey("Game", on_delete=models.CASCADE) 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) name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) group = models.CharField(max_length=255, blank=True, default="")
year_released = models.IntegerField(default=datetime.today().year) icon = models.SlugField(blank=True)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
def save(self, *args, **kwargs):
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): class Purchase(models.Model):
PHYSICAL = "ph" PHYSICAL = "ph"
@@ -46,30 +154,121 @@ class Purchase(models.Model):
(DEMO, "Demo"), (DEMO, "Demo"),
(PIRATED, "Pirated"), (PIRATED, "Pirated"),
] ]
GAME = "game"
DLC = "dlc"
SEASONPASS = "season_pass"
BATTLEPASS = "battle_pass"
TYPES = [
(GAME, "Game"),
(DLC, "DLC"),
(SEASONPASS, "Season Pass"),
(BATTLEPASS, "Battle Pass"),
]
edition = models.ForeignKey("Edition", on_delete=models.CASCADE) objects = PurchaseQueryset().as_manager()
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
date_purchased = models.DateField() games = models.ManyToManyField(Game, related_name="purchases")
date_refunded = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0) 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") 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="")
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( ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
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): def __str__(self):
platform_info = self.platform return self.standardized_name
if self.platform != self.edition.platform:
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
@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)})"
class Platform(models.Model): def is_game(self):
name = models.CharField(max_length=255) return self.type == self.GAME
group = models.CharField(max_length=255)
def __str__(self): def price_or_currency_differ_from(self, purchase_to_compare):
return self.name return (
self.price != purchase_to_compare.price
or self.price_currency != purchase_to_compare.price_currency
)
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."
)
if self.pk is not None:
# Retrieve the existing instance from the database
existing_purchase = Purchase.objects.get(pk=self.pk)
# If price has changed, reset converted fields
if existing_purchase.price_or_currency_differ_from(self):
from games.tasks import currency_to
exchange_rate = get_or_create_rate(
self.price_currency, currency_to, self.date_purchased.year
)
if exchange_rate:
self.converted_price = floatformat(self.price * exchange_rate, 0)
self.converted_currency = currency_to
super().save(*args, **kwargs)
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
@@ -82,68 +281,221 @@ class SessionQuerySet(models.QuerySet):
) )
return result["duration"] 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 Session(models.Model):
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) class Meta:
timestamp_start = models.DateTimeField() get_latest_by = "timestamp_start"
timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) game = models.ForeignKey(
duration_calculated = models.DurationField(blank=True, null=True) Game,
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True) on_delete=models.CASCADE,
note = models.TextField(blank=True, null=True) 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() objects = SessionQuerySet.as_manager()
def __str__(self): def __str__(self):
mark = ", manual" if self.duration_manual != None else "" mark = "*" if self.is_manual() else ""
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.game)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_end = timezone.now()
def start_now(): def start_now():
self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE)) self.timestamp_start = timezone.now()
def duration_seconds(self) -> timedelta:
manual = timedelta(0)
calculated = timedelta(0)
if not self.duration_manual in (None, 0, timedelta(0)):
manual = self.duration_manual
if self.timestamp_end != None and self.timestamp_start != None:
calculated = self.timestamp_end - self.timestamp_start
return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%02.0H:%02.0m") result = format_duration(self.duration_total, "%02.1H")
return result return result
@property def duration_formatted_with_mark(self) -> str:
def duration_sum(self) -> str: mark = "*" if self.is_manual() else ""
return Session.objects.all().total_duration_formatted() return f"{self.duration_formatted()}{mark}"
def save(self, *args, **kwargs): def is_manual(self) -> bool:
if self.timestamp_start != None and self.timestamp_end != None: return not self.duration_manual == timedelta(0)
self.duration_calculated = self.timestamp_end - self.timestamp_start
else: def save(self, *args, **kwargs) -> None:
self.duration_calculated = timedelta(0) 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) super(Session, self).save(*args, **kwargs)
class Device(models.Model): class Device(models.Model):
PC = "pc" PC = "PC"
CONSOLE = "co" CONSOLE = "Console"
HANDHELD = "ha" HANDHELD = "Handheld"
MOBILE = "mo" MOBILE = "Mobile"
SBC = "sbc" SBC = "Single-board computer"
UNKNOWN = "Unknown"
DEVICE_TYPES = [ DEVICE_TYPES = [
(PC, "PC"), (PC, "PC"),
(CONSOLE, "Console"), (CONSOLE, "Console"),
(HANDHELD, "Handheld"), (HANDHELD, "Handheld"),
(MOBILE, "Mobile"), (MOBILE, "Mobile"),
(SBC, "Single-board computer"), (SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC) type = models.CharField(max_length=255, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.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"]
+89
View File
@@ -0,0 +1,89 @@
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(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")
+5043 -1329
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+22 -27
View File
@@ -1,29 +1,24 @@
/** import { syncSelectInputUntilChanged } from "./utils.js";
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
sourceElement.addEventListener("change", sourceElementHandler); let syncData = [
targetElement.addEventListener("focus", targetElementHandler); {
} source: "#id_game",
source_value: "dataset.name",
target: "#id_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "textContent",
target: "#id_sort_name",
target_value: "value",
},
{
source: "#id_game",
source_value: "dataset.year",
target: "#id_year_released",
target_value: "value",
},
];
window.addEventListener("load", () => { syncSelectInputUntilChanged(syncData, "form");
syncSelectInputUntilChanged("#id_game", "#id_name");
});
+12
View File
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,31 @@
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();
}
);
+7 -3
View File
@@ -7,10 +7,14 @@ for (let button of document.querySelectorAll("[data-target]")) {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
if (type == "now") { if (type == "now") {
targetElement.value = toISOUTCString(new Date); targetElement.value = toISOUTCString(new Date());
} else if (type == "copy") { } else if (type == "copy") {
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start"; const oppositeName =
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value; targetElement.name == "timestamp_start"
? "timestamp_end"
: "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value =
targetElement.value;
} else if (type == "toggle") { } else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text"; if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local"; else targetElement.type = "datetime-local";
+37
View File
@@ -0,0 +1,37 @@
(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 -1
View File
File diff suppressed because one or more lines are too long
+173
View File
@@ -0,0 +1,173 @@
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;
});
};
+197 -1
View File
@@ -3,7 +3,7 @@
* @param {Date} date * @param {Date} date
* @returns {string} * @returns {string}
*/ */
export function toISOUTCString(date) { function toISOUTCString(date) {
function stringAndPad(number) { function stringAndPad(number) {
return number.toString().padStart(2, 0); return number.toString().padStart(2, 0);
} }
@@ -14,3 +14,199 @@ export function toISOUTCString(date) {
const minutes = stringAndPad(date.getMinutes()); const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`; 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,
};
+1 -1
View File
@@ -36,7 +36,7 @@ function addToggleButton(targetNode) {
targetNode.parentElement.appendChild(manualToggleButton); targetNode.parentElement.appendChild(manualToggleButton);
} }
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"]; const toggleableFields = ["#id_games", "#id_platform"];
toggleableFields.map((selector) => { toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector)); addToggleButton(document.querySelector(selector));
+90
View File
@@ -0,0 +1,90 @@
import logging
import requests
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 save_converted_info(purchase, converted_price, converted_currency):
logger.info(
f"Setting converted price of {purchase} to {converted_price} {converted_currency} (originally {purchase.price} {purchase.price_currency})"
)
purchase.converted_price = converted_price
purchase.converted_currency = converted_currency
purchase.save()
def convert_prices():
purchases = Purchase.objects.filter(
converted_price__isnull=True, converted_currency=""
)
if purchases.count() == 0:
logger.info("[convert_prices]: No prices to convert.")
for purchase in purchases:
if purchase.price_currency.upper() == currency_to or purchase.price == 0:
save_converted_info(purchase, purchase.price, currency_to)
continue
year = purchase.date_purchased.year
currency_from = purchase.price_currency.upper()
exchange_rate = ExchangeRate.objects.filter(
currency_from=currency_from, currency_to=currency_to, year=year
).first()
logger.info(
f"[convert_prices]: Looking for exchange rate in database: {currency_from}->{currency_to}"
)
if not exchange_rate:
logger.info(
f"[convert_prices]: Getting exchange rate from {currency_from} to {currency_to} for {year}..."
)
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),
)
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}"
)
if exchange_rate:
save_converted_info(
purchase,
floatformat(purchase.price * exchange_rate.rate, 0),
currency_to,
)
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 -17
View File
@@ -1,17 +1,2 @@
{% extends "base.html" %} <c-layouts.add>
</c-layouts.add>
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
-22
View File
@@ -1,22 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% endblock scripts %}
+9
View File
@@ -0,0 +1,9 @@
<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
@@ -0,0 +1,15 @@
<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>
+29 -28
View File
@@ -1,37 +1,38 @@
{% extends "base.html" %} <c-layouts.add>
<c-slot name="form_content">
{% block title %}{{ title }}{% endblock title %} <div class="max-width-container">
<div id="add-form" class="form-container max-w-xl mx-auto">
{% block content %} <form method="post" enctype="multipart/form-data" class="">
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{% for field in form %} {% for field in form %}
<tr> <div>
<th>{{ field.label_tag }}</th> {{ field.label_tag }}
{% if field.name == "note" %} {% if field.name == "note" %}
<td>{{ field }}</td> {{ field }}
{% else %} {% else %}
<td>{{ field }}</td> {{ field }}
{% endif %} {% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %} {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td> <span class="form-row-button-group flex-row gap-3 justify-start mt-3" hx-boost="false">
<div class="basic-button-container"> <c-button data-target="{{ field.name }}" data-type="now" size="xs">Set to now</c-button>
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button> <c-button data-target="{{ field.name }}" data-type="toggle" size="xs">Toggle text</c-button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button> <c-button data-target="{{ field.name }}" data-type="copy" size="xs">
<button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button> Copy {%if field.name == "timestamp_start" %}start{% else %}end{% endif %} value to {%if field.name == "timestamp_start" %}end{% else %}start{% endif %}
</div> </c-button>
</td> </span>
{% endif %} {% endif %}
</tr> </div>
{% endfor %} {% endfor %}
<tr> <div>
<td></td> <c-button type="submit">
<td><input type="submit" value="Submit"/></td> Submit
</tr> </c-button>
</table> </div>
<div class="submit-button-container">
{{ additional_row }}
</div>
</form> </form>
{% load static %} </div>
<script type="module" src="{% static 'js/add_session.js' %}"></script> </div>
{% endblock content %} </c-slot>
</c-layouts.add>
-72
View File
@@ -1,72 +0,0 @@
<!doctype html>
<html lang="en">
{% load static %}
<head>
<meta charset="utf-8"/>
<meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<script src="{% static 'js/htmx.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'base.css' %}" />
</head>
<body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<div class="w-full md:block md:w-auto">
<ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
{% if game_available and platform_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
{% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
{% block content %}No content here.{% endblock content %}
</div>
{% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %}{% endblock scripts %}
</body>
</html>
-26
View File
@@ -1,26 +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-none focus:ring-2 focus:ring-offset-2 rounded-sm"
>
{% 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>
@@ -1,26 +0,0 @@
{% comment %}
title
text
{% endcomment %}
<button
type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{{ text }}
</button>
@@ -1,21 +0,0 @@
<a href="{{ edit_url }}">
<button
type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
</svg>
</button>
</a>
+14
View File
@@ -0,0 +1,14 @@
<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
@@ -0,0 +1,8 @@
<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>
@@ -0,0 +1,26 @@
<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
@@ -0,0 +1,13 @@
{% 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
@@ -0,0 +1,18 @@
{% 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
@@ -0,0 +1,10 @@
<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
@@ -0,0 +1,16 @@
<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
@@ -0,0 +1,8 @@
<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>
@@ -0,0 +1,3 @@
<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>

After

Width:  |  Height:  |  Size: 284 B

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

After

Width:  |  Height:  |  Size: 477 B

+8
View File
@@ -0,0 +1,8 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,9 @@
<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>
+8
View File
@@ -0,0 +1,8 @@
<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 40.5 6 C 40.11625 6 39.732453 6.1464531 39.439453 6.4394531 L 21.462891 24.417969 L 20 28 L 23.582031 26.537109 L 41.560547 8.5605469 C 42.145547 7.9745469 42.145547 7.0254531 41.560547 6.4394531 C 41.267547 6.1464531 40.88375 6 40.5 6 z M 12.5 7 C 9.4802259 7 7 9.4802259 7 12.5 L 7 35.5 C 7 38.519774 9.4802259 41 12.5 41 L 35.5 41 C 38.519774 41 41 38.519774 41 35.5 L 41 18.5 A 1.50015 1.50015 0 1 0 38 18.5 L 38 35.5 C 38 36.898226 36.898226 38 35.5 38 L 12.5 38 C 11.101774 38 10 36.898226 10 35.5 L 10 12.5 C 10 11.101774 11.101774 10 12.5 10 L 29.5 10 A 1.50015 1.50015 0 1 0 29.5 7 L 12.5 7 z">
</path>
</svg>

After

Width:  |  Height:  |  Size: 798 B

+6
View File
@@ -0,0 +1,6 @@
<c-vars title="Epic Games Store" />
<c-svg :title=title viewbox="0 0 50 50">
<c-slot name="path">
M 10 3 C 6.69 3 4 5.69 4 9 L 4 41.240234 L 25 47.539062 L 46 41.240234 L 46 9 C 46 5.69 43.31 3 40 3 L 10 3 z M 11 8 L 15 8 L 15 11 L 11 11 L 11 18 L 14 18 L 14 21 L 11 21 L 11 28 L 15 28 L 15 31 L 11 31 C 9.34 31 8 29.66 8 28 L 8 11 C 8 9.34 9.34 8 11 8 z M 17 8 L 23 8 C 24.66 8 26 9.34 26 11 L 26 18 C 26 19.66 24.66 21 23 21 L 20 21 L 20 31 L 17 31 L 17 8 z M 28 8 L 31 8 L 31 31 L 28 31 L 28 8 z M 36 8 L 39 8 C 40.66 8 42 9.34 42 11 L 42 15 L 39 15 L 39 11 L 36 11 L 36 28 L 39 28 L 39 24 L 42 24 L 42 28 C 42 29.66 40.66 31 39 31 L 36 31 C 34.34 31 33 29.66 33 28 L 33 11 C 33 9.34 34.34 8 36 8 z M 20 11 L 20 18 L 23 18 L 23 11 L 20 11 z M 9 34 L 13 34 C 13.55 34 14 34.45 14 35 L 14 36 L 13 36 L 13 35.25 C 13 35.11 12.89 35 12.75 35 L 9.25 35 C 9.11 35 9 35.11 9 35.25 L 9 38.75 C 9 38.89 9.11 39 9.25 39 L 12.75 39 C 12.89 39 13 38.89 13 38.75 L 13 38 L 12 38 L 12 37 L 14 37 L 14 39 C 14 39.55 13.55 40 13 40 L 9 40 C 8.45 40 8 39.55 8 39 L 8 35 C 8 34.45 8.45 34 9 34 z M 18 34 L 19 34 L 22 40 L 21 40 L 20.5 39 L 16.5 39 L 16 40 L 15 40 L 18 34 z M 23 34 L 24 34 L 26 38 L 28 34 L 29 34 L 29 40 L 28 40 L 28 36 L 26.5 39 L 25.5 39 L 24 36 L 24 40 L 23 40 L 23 34 z M 30 34 L 35 34 L 35 35 L 31 35 L 31 36.5 L 33 36.5 L 33 37.5 L 31 37.5 L 31 39 L 35 39 L 35 40 L 30 40 L 30 34 z M 37 34 L 41 34 C 41.55 34 42 34.45 42 35 L 42 35.5 L 41 35.5 L 41 35.25 C 41 35.11 40.89 35 40.75 35 L 37.25 35 C 37.11 35 37 35.11 37 35.25 L 37 36.25 C 37 36.39 37.11 36.5 37.25 36.5 L 41 36.5 C 41.55 36.5 42 36.95 42 37.5 L 42 39 C 42 39.55 41.55 40 41 40 L 37 40 C 36.45 40 36 39.55 36 39 L 36 38.5 L 37 38.5 L 37 38.75 C 37 38.89 37.11 39 37.25 39 L 40.75 39 C 40.89 39 41 38.89 41 38.75 L 41 37.75 C 41 37.61 40.89 37.5 40.75 37.5 L 37 37.5 C 36.45 37.5 36 37.05 36 36.5 L 36 35 C 36 34.45 36.45 34 37 34 z M 18.5 35 L 17 38 L 20 38 L 18.5 35 z
</c-slot>
</c-svg>

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