Compare commits

..

176 Commits

Author SHA1 Message Date
Lukáš Kucharczyk d6f77c0c19 Add django-cors-headers
Django CI/CD / build-and-push (pull_request) Has been cancelled Details
2023-11-18 09:23:54 +01:00
Lukáš Kucharczyk 52881a88c6 Remove top-level package.json 2023-11-18 09:23:54 +01:00
Lukáš Kucharczyk 76b09fea39 Integrate TailwindCSS 2023-11-18 09:23:54 +01:00
Lukáš Kucharczyk 89f6959fc1 Change API url 2023-11-18 09:23:10 +01:00
Lukáš Kucharczyk 77d6ad2618 Add django-rest-framework 2023-11-18 09:23:09 +01:00
Lukáš Kucharczyk 125d17da8a Merge sessions and notes 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 38d416c4f3 Don't display prices if zero 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 63bb8e5fa6 Apply djlint 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 71ede4e4dd Add pre-commit 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 12159e00c1 Use the black profile for isort 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk f4bdb0a3e2 Use isort on migrations 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 583efe1754 Handle empty edition_id 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 0f5067d66d Prevent HTMX from messing up the initial state 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk a32d6c38ab Account for no sessions 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 4f67bd85c7 Enable hx-boost everywhere 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk cabcaebead Fix form not syncing due to HTMX 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 047f67c4cb Fix error 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 34118e1319 Re-instance gitea actions 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk bd07f19939 Update .drone.yml testing 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 78a50739a8 Formatting 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk a579ef7d7d Move static files in prod 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 8ab2dcc950 Fix docker-compose.yml 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk ee4efae594 Improve Dockerfile
Major inspiration (aka direct theft) from https://github.com/wemake-services/wemake-django-template
2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk b3eed84e1b Fix .dockerignore 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk eb53a5983c Remove Django admin 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 023736d544 Update dependencies 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 4a990bb6c6 Fix naive date 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk af85607ada isort 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 978714125b 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-18 09:22:26 +01:00
Lukáš Kucharczyk 51ba5cfa20 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-18 09:22:26 +01:00
Lukáš Kucharczyk a7dd0c5556 Only allow choosing purchases of selected edition 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 7b2dba7483 Name and related_purchase validation for non-games 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 250fa0ee9d Sort imports, remove cruft 2023-11-18 09:22:26 +01:00
Lukáš Kucharczyk 952961a3ad Fix wrong playrange ordering 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 1f9b3d5682 Improve how editions and purchases are displayed 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk c8e8ba8c8e Game View: order editions by year 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk ab9c1d61dd Version 1.5.1 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk d64ad125ff Improve and cleanup ConditionalElementHandler 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 410f20acea Improve purchase __str__ 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk d280fa3798 Disallow choosing non-game purchase as related purchase 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 29d6cb8a4a Version 1.5.0 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk c4fdefadb0 Order purchases by date on game view 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 7ba212e718 Add purchase types 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 7b459a2735 CI: run migrations before tests 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk f0822ff221 Make sure empty stats are 0 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 200819ceb0 Change stats years to 2000 up to current year 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 8a433616c4 Add stat for finished this year's games 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 809e8e2d7c Fix detecting manual durations 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 528b7d78a1 Remove deprecated USE_L10N 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 5270525fc2 Add more tests 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 09faa8e6fd Fix edge case in format_duration
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-18 09:22:25 +01:00
Lukáš Kucharczyk 87f50cedd2 Fix ordering 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk cbf872d21f Also prefill year between game and edition 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 881e5df3d1 Version 1.4.0 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk fa6ac21b42 Adding new games is easier 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 01ce582b74 Refactor, remove cruft 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 827279fb3d Add backlog decrease count 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk fa2b9b5c88 Fix sort names getting mangled 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 55ff3cd1a9 Pre-fill year when adding edition 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 68d1bfc2b9 UX improvements
* 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-18 09:22:25 +01:00
Lukáš Kucharczyk 4112d593f6 Fix hardcoded year 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 553030c08e Order devices alphabetically on new session form 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 06a4319623 Use safe_division in more places 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk cb99d8b656 Fix potential division by zero 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk b8df870cca Add more stats
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 7781efd849 Add more stats
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 9fa95758bf stats: change overall stats table layout 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk b81989a352 Model changes
* 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-18 09:22:25 +01:00
Lukáš Kucharczyk 5e6a5a4024 simplify playtime range display 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 288bf65afb Version 1.3.0 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 1d0bafd72d Make some pages redirect back instead to session list 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk db9ac2edeb Add more stats 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 2076fe20d1 Extend stats range to 2018 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk ff30962ce9 Remove cruft 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk d06e284426 Group by game instead of purchase 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 1f36a32b3d Reorder imports in views.py 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk e87f985cb3 Remove hardcoded year 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 8ca62f32e3 Make navigation more compact 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 5bc04c463a Add stats link, year selector 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 0130ab0059 Limit stats of single year correctly 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk df5e605582 Version 1.2.0 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk ade8a47c86 Add yearly stats page
Fixes #15
2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 4dd6f13160 Add a button to start session from game overview
Fix #62
2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 50f0571144 Add more time tests for fractional numbers 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 3fae309ff2 Do not format as float if no precision specified 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk e0a4aae6f6 Version 1.1.2 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk e1848caee9 Display durations in a consistent manner
Fixes #61
2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 4767c17004 Version 1.1.1 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 2cbe10a983 Remove debugging cruft from container 2023-11-18 09:22:25 +01:00
Lukáš Kucharczyk 693e1b1cd3 Add edit buttons to game overview, notes 2023-11-18 09:22:24 +01:00
Lukáš Kucharczyk 8eeeb9d3c9 Version 1.1.0 2023-11-18 09:22:24 +01:00
Lukáš Kucharczyk caa2ae06e1 Improve game overview
- add counts for each section
- add average hours per session
2023-11-18 09:22:24 +01:00
Lukáš Kucharczyk b9869cf232 Fix playtime range not working with manual session
Fixes #55
2023-11-18 09:22:24 +01:00
Lukáš Kucharczyk 36f80b7f01 Remove unused chart functionality 2023-11-18 09:22:24 +01:00
Lukáš Kucharczyk 9df959d989 ci: automatically redeploy container 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk db7b60bdf2 Organize changelog better 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 470426004e Fix session being wrongly considered in progress
Fixes #58
2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 3e04ef2b83 Fix date range on game overview 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 760f12e6b1 Adjust game edit URL 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk ac1f86cac3 Add game overview page
Fixes #8
2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 775ec6424b Try to shutdown container gracefully and faster 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 6000384c72 Switch fonts to WOFF2 2023-11-18 09:21:40 +01:00
Lukáš Kucharczyk 8786a4518e Further improve session list 2023-11-18 09:21:34 +01:00
Lukáš Kucharczyk a2ababaebc Update base.css 2023-11-18 09:21:24 +01:00
Lukáš Kucharczyk aae09a913c Update .vscode 2023-11-18 09:21:24 +01:00
Lukáš Kucharczyk c07f966cf1 Update CSS 2023-11-18 09:21:24 +01:00
Lukáš Kucharczyk d653f83a33 Improve session list
Fixes #53
2023-11-18 09:21:24 +01:00
Lukáš Kucharczyk 552d4bee9e Focus important fields on forms 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 8293737058 Update generated CSS 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 5234e19705 Add hacky way to not reload page when starting/ending session
Partially fixes #52
2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 077d8d1e3c Add time copy button, improve session editing 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 8ba5344a4d Fix bug when filtering only manual sessions (#51) 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 9d2a0ace33 add robots.txt 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 9b1f4e95e0 disable deleting sessions in code 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk d1f843a267 allow admin in prod 2/2 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 65a110b37c allow admin in prod 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk bbb5d0bfd5 disable deleting sessions 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 61c3b02472 install dev dependecies 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 4355953e42 do not filter out refunded games when adding session 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 1610b7d8a4 allow django admit 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk a9c3e93954 Change recent session view to current year 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk fb8fea4724 input datetime-local needs day to also be two digits 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 11b19cbace Exclude refunded purchases from start session form 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk fd9d531baf Update styles 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 3c02e3beaa Add .editorconfig 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 2b9f321edf Improve forms, add helper buttons on add session form 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk ee44c52324 Register Edition, Device in the admin UI 2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk b30679f6e7 Use date and datetime inputs
Properly implements 4d91a76513
2023-11-18 09:20:54 +01:00
Lukáš Kucharczyk 42d002a9d9 Sort games by name on edition form 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 56ffde8402 Sync game name and edition name fields for QOL 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 94706595c5 Fix gitignoring root static folder 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 352f6133d8 Sort games alphabetically on edition form 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 8f1bbab895 Version 1.0.3 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 38b25ff27e Allow editing filtered entities from session list 2023-11-18 09:20:24 +01:00
Lukáš Kucharczyk 4086c078bd Improve display of editions on purchase form 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk c1023bdcbc Sort platforms alphabetically on edition form 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 115a2a0844 Add wikidata ID and year for editions 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 326e4aac60 Add icons 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk fa4f256cb8 Change timetracker icon 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 739a41c4aa Add icons for session filters 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 376beea02f Fix error when generating charts with less than 2 entries 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 5e54871374 Allow filtering by game, edition, purchase from the session list 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 42e9ba15c5 Fix form styling 2023-11-18 09:20:23 +01:00
Lukáš Kucharczyk 2680476fe8 Version 1.0.2 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk a05e0e0fa3 Allow editing editions 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk d2455ec82a Allow editing purchases 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk f0e307dcfb Fix session starting 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk 1a6e5dc8fc Fix ownership display 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk f8844ce091 Add support for device info
Closes #49
2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk 5efb054c30 Add support for purchase ownership information
Closes #48
2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk 622c52bb20 Add support for prices on purchases 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk d447302b46 Re-introduce index view 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk b0b3567bf3 Fix displaying of "filterying by..." text 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk 812f74d79f Add support for game editions (#28) 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk 7974b74cd4 Fix make css 2023-11-18 09:20:11 +01:00
Lukáš Kucharczyk c61891cff4 Fix redirect to index 2023-11-18 09:19:59 +01:00
Lukáš Kucharczyk 6790c434f7 Order homepage sessions from newest 2023-11-18 09:19:59 +01:00
Lukáš Kucharczyk b45889fd85 Version 1.0.1 2023-11-18 09:19:59 +01:00
Lukáš Kucharczyk 791203c2dc Show only last 30 days on homepage
Fixes #47
2023-11-18 09:19:59 +01:00
Lukáš Kucharczyk 8918df1dfd Show markers on smaller graphs 2023-11-18 09:19:42 +01:00
Lukáš Kucharczyk c1b1cda38e Make it possible to edit sessions
Fixes #46
2023-11-18 09:19:42 +01:00
Lukáš Kucharczyk 345315db38
Start converting to react-router
continuous-integration/drone/push Build is passing Details
2023-01-25 17:50:15 +01:00
Lukáš Kucharczyk d9076e4367
Add apiService 2023-01-25 17:49:02 +01:00
Lukáš Kucharczyk 65e556656f
Fix Vite proxy config 2023-01-25 17:48:51 +01:00
Lukáš Kucharczyk 22fd1c356a
Add tailwind typography and forms 2023-01-25 17:48:33 +01:00
Lukáš Kucharczyk 9080425e1e
Add django-cors-headers 2023-01-25 17:48:17 +01:00
Lukáš Kucharczyk c64c68791f
Add eslint and prettier 2023-01-25 13:31:05 +01:00
Lukáš Kucharczyk f6edc8688a
Remove cruft 2023-01-25 13:19:50 +01:00
Lukáš Kucharczyk 1c73bba301
Remove top-level package.json 2023-01-25 13:07:45 +01:00
Lukáš Kucharczyk da5783c08f
Add Nav component 2023-01-25 12:56:32 +01:00
Lukáš Kucharczyk 04e6b01c2a
Integrate TailwindCSS 2023-01-25 12:47:39 +01:00
Lukáš Kucharczyk 3a14ea98a6
Change API url 2023-01-25 12:17:56 +01:00
Lukáš Kucharczyk ba27511b97
Add django-rest-framework 2023-01-25 12:05:37 +01:00
Lukáš Kucharczyk ae95015f55
CI: autotag
continuous-integration/drone/push Build is passing Details
2023-01-25 11:49:22 +01:00
Lukáš Kucharczyk c217fd30ef
Add vite proxy config 2023-01-25 11:48:17 +01:00
Lukáš Kucharczyk ec16d40361
Add vite react
continuous-integration/drone/push Build is failing Details
2023-01-25 11:44:04 +01:00
22 changed files with 664 additions and 377 deletions

View File

@ -3,10 +3,12 @@ name: Django CI/CD
on:
push:
branches: [ main ]
paths-ignore: [ 'README.md' ]
pull_request:
branches: [ main ]
jobs:
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout

View File

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

View File

@ -9,11 +9,6 @@ custom_datetime_widget = forms.DateTimeInput(
)
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
class SessionForm(forms.ModelForm):
# purchase = forms.ModelChoiceField(
@ -87,6 +82,7 @@ class PurchaseForm(forms.ModelForm):
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase
fields = [
@ -151,7 +147,7 @@ class EditionForm(forms.ModelForm):
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm):

View File

@ -1,10 +1,11 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41
from django.db import migrations, models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0026_purchase_type"),
]

View File

@ -1,6 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models
from games.models import Purchase

View File

@ -2,7 +2,6 @@ from datetime import timedelta
from django.core.exceptions import ValidationError
from django.db import models
from django.core.exceptions import ValidationError
from django.db.models import F, Manager, Sum
from django.utils import timezone
@ -39,9 +38,13 @@ class Edition(models.Model):
game = models.ForeignKey("Game", on_delete=models.CASCADE)
name = models.CharField(max_length=255)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
year_released = models.IntegerField(default=datetime.today().year)
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return self.sort_name
@ -121,14 +124,25 @@ class Purchase(models.Model):
type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True)
related_purchase = models.ForeignKey(
"Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
"Purchase",
on_delete=models.SET_NULL,
default=None,
null=True,
blank=True,
related_name="related_purchases",
)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
platform_info = self.platform
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()})"
additional_info = [
self.get_type_display() if self.type != Purchase.GAME else "",
f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self):
return self.type == self.GAME

View File

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

View File

@ -1,29 +1,24 @@
/**
* @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);
}
import { syncSelectInputUntilChanged } from './utils.js';
sourceElement.addEventListener("change", sourceElementHandler);
targetElement.addEventListener("focus", targetElementHandler);
}
let syncData = [
{
"source": "#id_game",
"source_value": "dataset.name",
"target": "#id_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "textContent",
"target": "#id_sort_name",
"target_value": "value"
},
{
"source": "#id_game",
"source_value": "dataset.year",
"target": "#id_year_released",
"target_value": "value"
},
]
window.addEventListener("load", () => {
syncSelectInputUntilChanged("#id_game", "#id_name");
});
syncSelectInputUntilChanged(syncData, "form");

View File

@ -1,4 +1,9 @@
import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
import {
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [
{
@ -11,21 +16,28 @@ let syncData = [
syncSelectInputUntilChanged(syncData, "form");
let myConfig = [
() => {
return getEl("#id_type").value == "game";
},
["#id_name", "#id_related_purchase"],
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
}
]
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers);
document.addEventListener("htmx:afterSwap", setupElementHandlers);
getEl("#id_type").onchange = () => {
setupElementHandlers();
};
document.body.addEventListener('htmx:beforeRequest', function(event) {
// Assuming 'Purchase1' is the element that triggers the HTMX request
if (event.target.id === 'id_edition') {
var idEditionValue = document.getElementById('id_edition').value;
// Condition to check - replace this with your actual logic
if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request
}
}
});

View File

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

View File

@ -3,9 +3,16 @@
* @param {Date} date
* @returns {string}
*/
export function toISOUTCString(date) {
let month = (date.getMonth() + 1).toString().padStart(2, 0);
return `${date.getFullYear()}-${month}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`;
function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
@ -92,37 +99,72 @@ function getEl(selector) {
return document.getElementsByClassName(selector)
}
else {
return document.getElementsByName(selector)
return document.getElementsByTagName(selector)
}
}
/**
* @description Does something to elements when something happens.
* @param {() => boolean} condition The condition that is being tested.
* @param {string[]} targetElements
* @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
* @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
* @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(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 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);
}
});
}
});
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };
function disableElementsWhenFalse(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -6,13 +6,19 @@
{% 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>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -6,17 +6,27 @@
{% 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>
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td>
<input type="submit" name="submit" value="Submit" />
</td>
</tr>
<tr>
<td></td>
<td>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %}
{% endblock scripts %}

View File

@ -1,34 +1,38 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button>
<button class="basic-button"
data-target="{{ field.name }}"
data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td>
<input type="submit" value="Submit" />
</td>
</tr>
</table>
</form>
{% load static %}

View File

@ -2,22 +2,29 @@
<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>
<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" />
<body class="dark" hx-indicator="#indicator" hx-boost="true">
<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"></span>
<span class="text-4xl">
<img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" />
</span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<div class="w-full md:block md:w-auto">
@ -91,5 +98,4 @@
{% block scripts %}
{% endblock scripts %}
</body>
</html>

View File

@ -1,22 +1,13 @@
<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>
{% 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>

View File

@ -1,73 +1,68 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block title %}
{{ title }}
{% endblock title %}
{% block content %}
{% if dataset.count >= 1 %}
<div class="mx-auto text-center my-4">
<a
id="last-session-start"
href="{% url 'start_session' last.id %}"
hx-get="{% url 'start_session' last.id %}"
hx-indicator="#indicator"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}"
>
{% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
</tr>
</thead>
<tbody>
{% for data in dataset %}
<tr>
<td
class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
>
{{ data.purchase.edition }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ data.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if data.unfinished %}
<a
href="{% url 'update_session' data.id %}"
hx-get="{% url 'update_session' data.id %}"
hx-swap="outerHTML"
hx-target=".responsive-table tbody tr:first-child"
hx-select=".responsive-table tbody tr:first-child"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
>
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif data.duration_manual %}
--
{% else %}
{{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ data.duration_formatted }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if dataset.count >= 1 %}
<div class="mx-auto text-center my-4">
<a id="last-session-start"
href="{% url 'start_session_same_as_last' last.id %}"
hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-swap="afterbegin"
hx-target=".responsive-table tbody"
hx-select=".responsive-table tbody tr:first-child"
onClick="document.querySelector('#last-session-start').classList.add('invisible')"
class="{% if last.timestamp_end == null %}invisible{% endif %}">
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
</a>
</div>
{% endif %}
{% if dataset.count != 0 %}
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
</tr>
</thead>
<tbody>
{% for data in dataset %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'view_game' data.purchase.edition.game.id %}">
{{ data.purchase.edition }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{{ data.timestamp_start | date:"d/m/Y H:i" }}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
{% if data.unfinished %}
<a href="{% url 'update_session' data.id %}"
hx-get="{% url 'update_session' data.id %}"
hx-swap="outerHTML"
hx-target=".responsive-table tbody tr:first-child"
hx-select=".responsive-table tbody tr:first-child"
hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.remove('invisible')">
<span class="text-yellow-300">Finish now?</span>
</a>
{% elif data.duration_manual %}
--
{% else %}
{{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ data.duration_formatted }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div>
{% endif %}
{% endblock content %}

View File

@ -194,18 +194,17 @@
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}
({{ purchase.name }}, {{ purchase.get_type_display }})
{% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2"
href="{% url 'edit_purchase' purchase.id %}">
{{ purchase.edition.name }}
{% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@ -22,42 +22,37 @@
</h1>
<ul>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
{% if purchase.related_purchases %}
<li>
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" />
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
<ul>
{% for purchase in edition.game_purchases %}
<li class="sm:pl-6 flex items-center">
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %}
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
<ul>
{% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-6 flex items-center">
{{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% for related_purchase in purchase.nongame_related_purchases %}
<li class="sm:pl-12 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }})
{% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
</li>
{% endif %}
</li>
{% endfor %}
</ul>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">

View File

@ -93,4 +93,10 @@ urlpatterns = [
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
]

View File

@ -1,8 +1,15 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from typing import Any, Callable
from common.time import now as now_with_tz
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate
from django.http import (
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils import timezone
@ -133,23 +140,10 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None):
game = Game.objects.get(id=game_id)
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
game_purchases = Purchase.objects.filter(edition__game_id=game_id).filter(
type=Purchase.GAME
)
for purchase in game_purchases:
purchase.related_purchases = Purchase.objects.exclude(
type=Purchase.GAME
).filter(related_purchase=purchase.id)
context["purchases"] = game_purchases
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
nongame_related_purchases_prefetch = Prefetch(
"related_purchases",
queryset=Purchase.objects.exclude(type=Purchase.GAME),
to_attr="nongame_related_purchases",
)
game_purchases_prefetch = Prefetch(
"purchase_set",
@ -259,6 +253,12 @@ def start_session_same_as_last(request, last_session_id: int):
return redirect("list_sessions")
# def delete_session(request, session_id=None):
# session = Session.objects.get(id=session_id)
# session.delete()
# return redirect("list_sessions")
def list_sessions(
request,
filter="",
@ -287,10 +287,12 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = timezone.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1))
dataset = Session.objects.filter(
timestamp_start__gte=datetime.now() - timedelta(days=30)
)
context["title"] = "Last 30 days"
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else:
# by default, sort from newest to oldest
dataset = Session.objects.order_by("-timestamp_start")
@ -304,8 +306,10 @@ def list_sessions(
context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last()
try:
context["last"] = Session.objects.latest()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context)
@ -543,3 +547,19 @@ def add_platform(request):
context["form"] = form
context["title"] = "Add New Platform"
return render(request, "add.html", context)
def add_device(request):
context = {}
form = DeviceForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")

73
poetry.lock generated
View File

@ -98,6 +98,17 @@ editorconfig = ">=0.12.2"
jsbeautifier = "*"
six = ">=1.13.0"
[[package]]
name = "distlib"
version = "0.3.7"
description = "Distribution utilities"
optional = false
python-versions = "*"
files = [
{file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"},
{file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"},
]
[[package]]
name = "django"
version = "4.2.7"
@ -237,7 +248,9 @@ files = [
]
[package.extras]
test = ["pytest (>=6)"]
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"]
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "gunicorn"
@ -348,6 +361,20 @@ files = [
editorconfig = ">=0.12.2"
six = ">=1.13.0"
[[package]]
name = "json5"
version = "0.9.14"
description = "A Python implementation of the JSON5 data format."
optional = false
python-versions = "*"
files = [
{file = "json5-0.9.14-py2.py3-none-any.whl", hash = "sha256:740c7f1b9e584a468dbb2939d8d458db3427f2c93ae2139d05f47e453eae964f"},
{file = "json5-0.9.14.tar.gz", hash = "sha256:9ed66c3a6ca3510a976a9ef9b8c0787de24802724ab1860bc0153c7fdd589b02"},
]
[package.extras]
dev = ["hypothesis"]
[[package]]
name = "markupsafe"
version = "2.1.3"
@ -478,22 +505,23 @@ files = [
]
[[package]]
name = "packaging"
version = "22.0"
description = "Core utilities for Python packages"
category = "dev"
name = "nodeenv"
version = "1.8.0"
description = "Node.js virtual environment builder"
optional = false
python-versions = ">=3.7"
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
files = [
{file = "packaging-22.0-py3-none-any.whl", hash = "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3"},
{file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
{file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
{file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
]
[package.dependencies]
setuptools = "*"
[[package]]
name = "pathspec"
version = "0.10.3"
description = "Utility library for gitignore style pattern matching of file paths."
category = "dev"
name = "packaging"
version = "23.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.7"
files = [
@ -629,6 +657,24 @@ files = [
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pre-commit"
version = "3.5.0"
description = "A framework for managing and maintaining multi-language pre-commit hooks."
optional = false
python-versions = ">=3.8"
files = [
{file = "pre_commit-3.5.0-py2.py3-none-any.whl", hash = "sha256:841dc9aef25daba9a0238cd27984041fa0467b4199fc4852e27950664919f660"},
{file = "pre_commit-3.5.0.tar.gz", hash = "sha256:5804465c675b659b0862f07907f96295d490822a450c4c40e747d0b1c6ebcb32"},
]
[package.dependencies]
cfgv = ">=2.0.0"
identify = ">=1.0.0"
nodeenv = ">=0.11.1"
pyyaml = ">=5.1"
virtualenv = ">=20.10.0"
[[package]]
name = "pytest"
version = "7.4.3"
@ -647,7 +693,7 @@ packaging = "*"
pluggy = ">=0.12,<2.0"
[package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pyyaml"
@ -825,7 +871,6 @@ testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jar
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [