From e4c6e9e4146b4d339807d8513c5be963952d5f52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Tue, 14 Nov 2023 19:27:00 +0100 Subject: [PATCH] Add purchase types --- CHANGELOG.md | 94 ++++++++++++++ frontend/src/index.css | 6 + games/forms.py | 5 +- .../0027_purchase_related_purchase.py | 3 +- games/migrations/0028_purchase_name.py | 1 - games/models.py | 22 ++-- games/static/base.css | 25 ++++ games/static/js/add_purchase.js | 46 +++---- games/static/js/utils.js | 119 ++++++++++++++++++ games/templates/stats.html | 23 ++-- games/templates/view_game.html | 55 ++++---- games/views.py | 21 +++- 12 files changed, 335 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7ab5a0..0d4fecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,99 @@ ## Unreleased +## 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 + +## 1.4.0 / 2023-11-09 21:01+01:00 + +### New +* More fields are now optional. This is to make it easier to add new items in bulk. + * Game: Wikidata ID + * Edition: Platform, Year + * Purchase: Platform + * Platform: Group + * Session: Device +* New fields: + * Game: Year Released + * To record original year of release + * Upon migration, this will be set to a year of any of the game's edition that has it set + * Purchase: Date Finished +* Editions are now unique combination of name and platform +* Add more stats: + * All finished games + * All finished 2023 games + * All finished games that were purchased this year + * Sessions (count) + * Days played + * Finished (count) + * Unfinished (count) + * Refunded (count) + * Backlog Decrease (count) +* New workflow: + * Adding Game, Edition, Purchase, and Session in a row is now much faster + +### Improved +* game overview: simplify playtime range display +* new session: order devices alphabetically +* ignore English articles when sorting names + * added a new sort_name field that gets automatically created +* automatically fill certain values in forms: + * new game: name and sort name after typing + * new edition: name, sort name, and year when selecting game + * new purchase: platform when selecting edition + +## 1.3.0 / 2023-11-05 15:09+01:00 + +### New +* Add Stats to the main navigation +* Allow selecting year on the Stats page + +### Improved +* Make some pages redirect back instead to session list + +### Improved +* Make navigation more compact + +### Fixed +* Correctly limit sessions to a single year for stats + +## 1.2.0 / 2023-11-01 20:18+01:00 + +### New +* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15) + +### Enhancements +* Add a button to start session from game overview + +## 1.1.2 / 2023-10-13 16:30+02:00 + +### Enhancements +* Durations are formatted in a consisent manner across all pages + +### Fixes +* Game Overview: display duration when >1 hour instead of displaying 0 + +## 1.1.1 / 2023-10-09 20:52+02:00 + +### New +* Add notes section to game overview + +### Enhancements +* Make it possible to add any data on the game overview page + +## 1.1.0 / 2023-10-09 00:01+02:00 + +### New +* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8) +* Add helper buttons next to datime fields +* Add copy button on Add session page to copy times between fields +* Change fonts to IBM Plex + +### Enhancements * Improve form appearance * Add helper buttons next to datime fields * Change recent session view to current year instead of last 30 days diff --git a/frontend/src/index.css b/frontend/src/index.css index 3be0629..49b6857 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -66,6 +66,12 @@ textarea { @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; } +form input:disabled, +select:disabled, +textarea:disabled { + @apply dark:bg-slate-700 dark:text-slate-400; +} + @media screen and (min-width: 768px) { form input, select, diff --git a/games/forms.py b/games/forms.py index 4a23482..d05ad2f 100644 --- a/games/forms.py +++ b/games/forms.py @@ -77,10 +77,7 @@ class PurchaseForm(forms.ModelForm): ) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) related_purchase = forms.ModelChoiceField( - queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( - "edition__sort_name" - ), - required=False, + queryset=Purchase.objects.order_by("edition__sort_name") ) class Meta: diff --git a/games/migrations/0027_purchase_related_purchase.py b/games/migrations/0027_purchase_related_purchase.py index 06e7072..a55f044 100644 --- a/games/migrations/0027_purchase_related_purchase.py +++ b/games/migrations/0027_purchase_related_purchase.py @@ -1,11 +1,10 @@ # Generated by Django 4.1.5 on 2023-11-14 08:41 -import django.db.models.deletion from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ ("games", "0026_purchase_type"), ] diff --git a/games/migrations/0028_purchase_name.py b/games/migrations/0028_purchase_name.py index e2c390c..7c08cfc 100644 --- a/games/migrations/0028_purchase_name.py +++ b/games/migrations/0028_purchase_name.py @@ -1,7 +1,6 @@ # Generated by Django 4.1.5 on 2023-11-14 11:05 from django.db import migrations, models - from games.models import Purchase diff --git a/games/models.py b/games/models.py index 5799390..1c9b3fc 100644 --- a/games/models.py +++ b/games/models.py @@ -118,16 +118,12 @@ class Purchase(models.Model): max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL ) 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, - related_name="related_purchases", + name = models.CharField( + max_length=255, default="Unknown Name", null=True, blank=True + ) + related_purchase = models.ForeignKey( + "Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True ) - created_at = models.DateTimeField(auto_now_add=True) def __str__(self): platform_info = self.platform @@ -135,6 +131,14 @@ class Purchase(models.Model): 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()})" + def is_game(self): + return self.type == self.GAME + + def save(self, *args, **kwargs): + if self.type == Purchase.GAME: + self.name = "" + super().save(*args, **kwargs) + class Platform(models.Model): name = models.CharField(max_length=255) diff --git a/games/static/base.css b/games/static/base.css index 1438484..f66098f 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -1148,6 +1148,15 @@ textarea) { color: rgb(241 245 249 / var(--tw-text-opacity)); } +:is(.dark form input:disabled),:is(.dark +select:disabled),:is(.dark +textarea:disabled) { + --tw-bg-opacity: 1; + background-color: rgb(51 65 85 / var(--tw-bg-opacity)); + --tw-text-opacity: 1; + color: rgb(148 163 184 / var(--tw-text-opacity)); +} + @media screen and (min-width: 768px) { form input, select, @@ -1309,6 +1318,22 @@ th label { padding-left: 1rem; padding-right: 1rem; } + + .sm\:pl-2 { + padding-left: 0.5rem; + } + + .sm\:pl-4 { + padding-left: 1rem; + } + + .sm\:pl-6 { + padding-left: 1.5rem; + } + + .sm\:decoration-2 { + text-decoration-thickness: 2px; + } } @media (min-width: 768px) { diff --git a/games/static/js/add_purchase.js b/games/static/js/add_purchase.js index cfacecc..c427652 100644 --- a/games/static/js/add_purchase.js +++ b/games/static/js/add_purchase.js @@ -1,9 +1,4 @@ -import { - syncSelectInputUntilChanged, - getEl, - disableElementsWhenTrue, - disableElementsWhenFalse, -} from "./utils.js"; +import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js"; let syncData = [ { @@ -16,28 +11,21 @@ let syncData = [ syncSelectInputUntilChanged(syncData, "form"); -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 - } +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) +} diff --git a/games/static/js/utils.js b/games/static/js/utils.js index 988e0a7..669735e 100644 --- a/games/static/js/utils.js +++ b/games/static/js/utils.js @@ -7,3 +7,122 @@ export function toISOUTCString(date) { let month = (date.getMonth() + 1).toString().padStart(2, 0); return `${date.getFullYear()}-${month}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`; } + +/** + * @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) { + 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.getElementsByName(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. + */ +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); + } + }); + } +} + +export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler }; diff --git a/games/templates/stats.html b/games/templates/stats.html index d2dd1c4..7675bb5 100644 --- a/games/templates/stats.html +++ b/games/templates/stats.html @@ -194,17 +194,18 @@ {% for purchase in all_purchased_this_year %} - - - - {{ purchase.edition.name }} - {% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %} - - - {{ purchase.price }} - {{ purchase.date_purchased | date:"d/m/Y" }} - + + + + {{ purchase.edition.name }} + {% if purchase.type != "game" %} + ({{ purchase.name }}, {{ purchase.get_type_display }}) + {% endif %} + + + {{ purchase.price }} + {{ purchase.date_purchased | date:"d/m/Y" }} + {% endfor %} diff --git a/games/templates/view_game.html b/games/templates/view_game.html index b28cba5..e73ece2 100644 --- a/games/templates/view_game.html +++ b/games/templates/view_game.html @@ -22,37 +22,42 @@

diff --git a/games/views.py b/games/views.py index 5a992c2..fcc1201 100644 --- a/games/views.py +++ b/games/views.py @@ -133,10 +133,23 @@ def edit_game(request, game_id=None): def view_game(request, game_id=None): game = Game.objects.get(id=game_id) - nongame_related_purchases_prefetch = Prefetch( - "related_purchases", - queryset=Purchase.objects.exclude(type=Purchase.GAME), - to_attr="nongame_related_purchases", + 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") ) game_purchases_prefetch = Prefetch( "purchase_set",