Add purchase types
This commit is contained in:
parent
2eaccc57b0
commit
e4c6e9e414
94
CHANGELOG.md
94
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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
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 = () => {
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
conditionalElementHandler(...myConfig)
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -196,10 +196,11 @@
|
|||
{% 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 %}">
|
||||
<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 %}
|
||||
{% 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>
|
||||
|
|
|
@ -27,32 +27,37 @@
|
|||
{% 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' %}" />
|
||||
<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 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 %}
|
||||
{% 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 %}
|
||||
</li>
|
||||
{% if purchase.related_purchases %}
|
||||
<li>
|
||||
<ul>
|
||||
{% 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 }})
|
||||
{% 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}})
|
||||
{% url 'edit_purchase' related_purchase.id as edit_url %}
|
||||
{% include 'components/edit_button.html' with edit_url=edit_url %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue