Compare commits
8 Commits
ce3c4b55f0
...
1.4.0
Author | SHA1 | Date | |
---|---|---|---|
394dd4f9f8 | |||
c358b1aaa0 | |||
1bc3ca057b | |||
c2c0886451 | |||
b0be7b5887 | |||
099d989f16 | |||
a879360ebd | |||
866f2526e6 |
11
CHANGELOG.md
11
CHANGELOG.md
@ -1,4 +1,4 @@
|
|||||||
## Unreleased
|
## 1.4.0 / 2023-11-09 21:01+01:00
|
||||||
|
|
||||||
### New
|
### New
|
||||||
* More fields are now optional. This is to make it easier to add new items in bulk.
|
* More fields are now optional. This is to make it easier to add new items in bulk.
|
||||||
@ -22,10 +22,19 @@
|
|||||||
* Finished (count)
|
* Finished (count)
|
||||||
* Unfinished (count)
|
* Unfinished (count)
|
||||||
* Refunded (count)
|
* Refunded (count)
|
||||||
|
* Backlog Decrease (count)
|
||||||
|
* New workflow:
|
||||||
|
* Adding Game, Edition, Purchase, and Session in a row is now much faster
|
||||||
|
|
||||||
### Improved
|
### Improved
|
||||||
* game overview: simplify playtime range display
|
* game overview: simplify playtime range display
|
||||||
* new session: order devices alphabetically
|
* new session: order devices alphabetically
|
||||||
|
* ignore English articles when sorting names
|
||||||
|
* added a new sort_name field that gets automatically created
|
||||||
|
* automatically fill certain values in forms:
|
||||||
|
* new game: name and sort name after typing
|
||||||
|
* new edition: name, sort name, and year when selecting game
|
||||||
|
* new purchase: platform when selecting edition
|
||||||
|
|
||||||
## 1.3.0 / 2023-11-05 15:09+01:00
|
## 1.3.0 / 2023-11-05 15:09+01:00
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ RUN npm install && \
|
|||||||
|
|
||||||
FROM python:3.10.9-slim-bullseye
|
FROM python:3.10.9-slim-bullseye
|
||||||
|
|
||||||
ENV VERSION_NUMBER 1.3.0
|
ENV VERSION_NUMBER 1.4.0
|
||||||
ENV PROD 1
|
ENV PROD 1
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
@ -6,7 +6,6 @@ custom_date_widget = forms.DateInput(attrs={"type": "date"})
|
|||||||
custom_datetime_widget = forms.DateTimeInput(
|
custom_datetime_widget = forms.DateTimeInput(
|
||||||
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
|
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
|
||||||
)
|
)
|
||||||
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
|
|
||||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
|
||||||
|
|
||||||
|
|
||||||
@ -15,8 +14,8 @@ class SessionForm(forms.ModelForm):
|
|||||||
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||||
# )
|
# )
|
||||||
purchase = forms.ModelChoiceField(
|
purchase = forms.ModelChoiceField(
|
||||||
queryset=Purchase.objects.order_by("edition__name"),
|
queryset=Purchase.objects.order_by("edition__sort_name"),
|
||||||
widget=autofocus_select_widget,
|
widget=forms.Select(attrs={"autofocus": "autofocus"}),
|
||||||
)
|
)
|
||||||
|
|
||||||
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
|
||||||
@ -39,12 +38,21 @@ class SessionForm(forms.ModelForm):
|
|||||||
|
|
||||||
class EditionChoiceField(forms.ModelChoiceField):
|
class EditionChoiceField(forms.ModelChoiceField):
|
||||||
def label_from_instance(self, obj) -> str:
|
def label_from_instance(self, obj) -> str:
|
||||||
return f"{obj.name} ({obj.platform}, {obj.year_released})"
|
return f"{obj.sort_name} ({obj.platform}, {obj.year_released})"
|
||||||
|
|
||||||
|
|
||||||
|
class IncludePlatformSelect(forms.Select):
|
||||||
|
def create_option(self, name, value, *args, **kwargs):
|
||||||
|
option = super().create_option(name, value, *args, **kwargs)
|
||||||
|
if value:
|
||||||
|
option["attrs"]["data-platform"] = value.instance.platform.id
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
class PurchaseForm(forms.ModelForm):
|
class PurchaseForm(forms.ModelForm):
|
||||||
edition = EditionChoiceField(
|
edition = EditionChoiceField(
|
||||||
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
|
queryset=Edition.objects.order_by("sort_name"),
|
||||||
|
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
|
||||||
)
|
)
|
||||||
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
|
||||||
|
|
||||||
@ -67,9 +75,25 @@ class PurchaseForm(forms.ModelForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class IncludeNameSelect(forms.Select):
|
||||||
|
def create_option(self, name, value, *args, **kwargs):
|
||||||
|
option = super().create_option(name, value, *args, **kwargs)
|
||||||
|
if value:
|
||||||
|
option["attrs"]["data-name"] = value.instance.name
|
||||||
|
option["attrs"]["data-year"] = value.instance.year_released
|
||||||
|
return option
|
||||||
|
|
||||||
|
|
||||||
|
class GameModelChoiceField(forms.ModelChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
# Use sort_name as the label for the option
|
||||||
|
return obj.sort_name
|
||||||
|
|
||||||
|
|
||||||
class EditionForm(forms.ModelForm):
|
class EditionForm(forms.ModelForm):
|
||||||
game = forms.ModelChoiceField(
|
game = GameModelChoiceField(
|
||||||
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
|
queryset=Game.objects.order_by("sort_name"),
|
||||||
|
widget=IncludeNameSelect(attrs={"autofocus": "autofocus"}),
|
||||||
)
|
)
|
||||||
platform = forms.ModelChoiceField(
|
platform = forms.ModelChoiceField(
|
||||||
queryset=Platform.objects.order_by("name"), required=False
|
queryset=Platform.objects.order_by("name"), required=False
|
||||||
@ -77,13 +101,13 @@ class EditionForm(forms.ModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Edition
|
model = Edition
|
||||||
fields = ["game", "name", "platform", "year_released", "wikidata"]
|
fields = ["game", "name", "sort_name", "platform", "year_released", "wikidata"]
|
||||||
|
|
||||||
|
|
||||||
class GameForm(forms.ModelForm):
|
class GameForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Game
|
model = Game
|
||||||
fields = ["name", "year_released", "wikidata"]
|
fields = ["name", "sort_name", "year_released", "wikidata"]
|
||||||
widgets = {"name": autofocus_input_widget}
|
widgets = {"name": autofocus_input_widget}
|
||||||
|
|
||||||
|
|
||||||
|
39
games/migrations/0024_edition_sort_name.py
Normal file
39
games/migrations/0024_edition_sort_name.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-09 09:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_sort_name(apps, schema_editor):
|
||||||
|
Edition = apps.get_model(
|
||||||
|
"games", "Edition"
|
||||||
|
) # Replace 'your_app_name' with the actual name of your app
|
||||||
|
|
||||||
|
for edition in Edition.objects.all():
|
||||||
|
name = edition.name
|
||||||
|
# Check for articles at the beginning of the name and move them to the end
|
||||||
|
if name.lower().startswith("the "):
|
||||||
|
sort_name = f"{name[4:]}, The"
|
||||||
|
elif name.lower().startswith("a "):
|
||||||
|
sort_name = f"{name[2:]}, A"
|
||||||
|
elif name.lower().startswith("an "):
|
||||||
|
sort_name = f"{name[3:]}, An"
|
||||||
|
else:
|
||||||
|
sort_name = name
|
||||||
|
# Save the sort_name back to the database
|
||||||
|
edition.sort_name = sort_name
|
||||||
|
edition.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0023_purchase_date_finished"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="edition",
|
||||||
|
name="sort_name",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_sort_name),
|
||||||
|
]
|
39
games/migrations/0025_game_sort_name.py
Normal file
39
games/migrations/0025_game_sort_name.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-11-09 09:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def create_sort_name(apps, schema_editor):
|
||||||
|
Game = apps.get_model(
|
||||||
|
"games", "Game"
|
||||||
|
) # Replace 'your_app_name' with the actual name of your app
|
||||||
|
|
||||||
|
for game in Game.objects.all():
|
||||||
|
name = game.name
|
||||||
|
# Check for articles at the beginning of the name and move them to the end
|
||||||
|
if name.lower().startswith("the "):
|
||||||
|
sort_name = f"{name[4:]}, The"
|
||||||
|
elif name.lower().startswith("a "):
|
||||||
|
sort_name = f"{name[2:]}, A"
|
||||||
|
elif name.lower().startswith("an "):
|
||||||
|
sort_name = f"{name[3:]}, An"
|
||||||
|
else:
|
||||||
|
sort_name = name
|
||||||
|
# Save the sort_name back to the database
|
||||||
|
game.sort_name = sort_name
|
||||||
|
game.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("games", "0024_edition_sort_name"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="game",
|
||||||
|
name="sort_name",
|
||||||
|
field=models.CharField(blank=True, default=None, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
migrations.RunPython(create_sort_name),
|
||||||
|
]
|
@ -10,12 +10,26 @@ from django.db.models import F, Manager, Sum
|
|||||||
|
|
||||||
class Game(models.Model):
|
class Game(models.Model):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
year_released = models.IntegerField(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)
|
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
def get_sort_name(name):
|
||||||
|
articles = ["a", "an", "the"]
|
||||||
|
name_parts = name.split()
|
||||||
|
first_word = name_parts[0].lower()
|
||||||
|
if first_word in articles:
|
||||||
|
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
|
||||||
|
else:
|
||||||
|
return name
|
||||||
|
|
||||||
|
self.sort_name = get_sort_name(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class Edition(models.Model):
|
class Edition(models.Model):
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -23,6 +37,7 @@ class Edition(models.Model):
|
|||||||
|
|
||||||
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
game = models.ForeignKey("Game", on_delete=models.CASCADE)
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
|
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
|
||||||
platform = models.ForeignKey(
|
platform = models.ForeignKey(
|
||||||
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
|
"Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
|
||||||
)
|
)
|
||||||
@ -30,7 +45,20 @@ class Edition(models.Model):
|
|||||||
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.sort_name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
def get_sort_name(name):
|
||||||
|
articles = ["a", "an", "the"]
|
||||||
|
name_parts = name.split()
|
||||||
|
first_word = name_parts[0].lower()
|
||||||
|
if first_word in articles:
|
||||||
|
return f"{' '.join(name_parts[1:])}, {name_parts[0]}"
|
||||||
|
else:
|
||||||
|
return name
|
||||||
|
|
||||||
|
self.sort_name = get_sort_name(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class PurchaseQueryset(models.QuerySet):
|
class PurchaseQueryset(models.QuerySet):
|
||||||
|
@ -1,29 +1,24 @@
|
|||||||
/**
|
import { syncSelectInputUntilChanged } from './utils.js';
|
||||||
* @description Sync select field with input field until user focuses it.
|
|
||||||
* @param {HTMLSelectElement} sourceElementSelector
|
|
||||||
* @param {HTMLInputElement} targetElementSelector
|
|
||||||
*/
|
|
||||||
function syncSelectInputUntilChanged(
|
|
||||||
sourceElementSelector,
|
|
||||||
targetElementSelector
|
|
||||||
) {
|
|
||||||
const sourceElement = document.querySelector(sourceElementSelector);
|
|
||||||
const targetElement = document.querySelector(targetElementSelector);
|
|
||||||
function sourceElementHandler(event) {
|
|
||||||
let selected = event.target.value;
|
|
||||||
let selectedValue = document.querySelector(
|
|
||||||
`#id_game option[value='${selected}']`
|
|
||||||
).textContent;
|
|
||||||
targetElement.value = selectedValue;
|
|
||||||
}
|
|
||||||
function targetElementHandler(event) {
|
|
||||||
sourceElement.removeEventListener("change", sourceElementHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceElement.addEventListener("change", sourceElementHandler);
|
let syncData = [
|
||||||
targetElement.addEventListener("focus", targetElementHandler);
|
{
|
||||||
}
|
"source": "#id_game",
|
||||||
|
"source_value": "dataset.name",
|
||||||
|
"target": "#id_name",
|
||||||
|
"target_value": "value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "#id_game",
|
||||||
|
"source_value": "textContent",
|
||||||
|
"target": "#id_sort_name",
|
||||||
|
"target_value": "value"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "#id_game",
|
||||||
|
"source_value": "dataset.year",
|
||||||
|
"target": "#id_year_released",
|
||||||
|
"target_value": "value"
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
window.addEventListener("load", () => {
|
syncSelectInputUntilChanged(syncData, "form");
|
||||||
syncSelectInputUntilChanged("#id_game", "#id_name");
|
|
||||||
});
|
|
||||||
|
12
games/static/js/add_game.js
Normal file
12
games/static/js/add_game.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { syncSelectInputUntilChanged } from './utils.js'
|
||||||
|
|
||||||
|
let syncData = [
|
||||||
|
{
|
||||||
|
"source": "#id_name",
|
||||||
|
"source_value": "value",
|
||||||
|
"target": "#id_sort_name",
|
||||||
|
"target_value": "value"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
syncSelectInputUntilChanged(syncData, "form")
|
12
games/static/js/add_purchase.js
Normal file
12
games/static/js/add_purchase.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { syncSelectInputUntilChanged } from './utils.js'
|
||||||
|
|
||||||
|
let syncData = [
|
||||||
|
{
|
||||||
|
"source": "#id_edition",
|
||||||
|
"source_value": "dataset.platform",
|
||||||
|
"target": "#id_platform",
|
||||||
|
"target_value": "value"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
syncSelectInputUntilChanged(syncData, "form")
|
@ -3,7 +3,7 @@
|
|||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function toISOUTCString(date) {
|
function toISOUTCString(date) {
|
||||||
function stringAndPad(number) {
|
function stringAndPad(number) {
|
||||||
return number.toString().padStart(2, 0);
|
return number.toString().padStart(2, 0);
|
||||||
}
|
}
|
||||||
@ -14,3 +14,77 @@ export function toISOUTCString(date) {
|
|||||||
const minutes = stringAndPad(date.getMinutes());
|
const minutes = stringAndPad(date.getMinutes());
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Sync values between source and target elements based on syncData configuration.
|
||||||
|
* @param {Array} syncData - Array of objects to define source and target elements with their respective value types.
|
||||||
|
*/
|
||||||
|
function syncSelectInputUntilChanged(syncData, parentSelector = document) {
|
||||||
|
const parentElement =
|
||||||
|
parentSelector === document
|
||||||
|
? document
|
||||||
|
: document.querySelector(parentSelector);
|
||||||
|
|
||||||
|
if (!parentElement) {
|
||||||
|
console.error(`The parent selector "${parentSelector}" is not valid.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Set up a single change event listener on the document for handling all source changes
|
||||||
|
parentElement.addEventListener("change", function (event) {
|
||||||
|
// Loop through each sync configuration item
|
||||||
|
syncData.forEach((syncItem) => {
|
||||||
|
// Check if the change event target matches the source selector
|
||||||
|
if (event.target.matches(syncItem.source)) {
|
||||||
|
const sourceElement = event.target;
|
||||||
|
const valueToSync = getValueFromProperty(
|
||||||
|
sourceElement,
|
||||||
|
syncItem.source_value
|
||||||
|
);
|
||||||
|
const targetElement = document.querySelector(syncItem.target);
|
||||||
|
|
||||||
|
if (targetElement && valueToSync !== null) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toISOUTCString, syncSelectInputUntilChanged };
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
|
|
||||||
@ -15,3 +16,10 @@
|
|||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|
@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block title %}{{ title }}{% endblock title %}
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
|
|
||||||
@ -10,13 +11,19 @@
|
|||||||
{{ form.as_table }}
|
{{ form.as_table }}
|
||||||
<tr>
|
<tr>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td><input type="submit" value="Submit"/></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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% load static %}
|
{% if script_name %}
|
||||||
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock scripts %}
|
{% endblock scripts %}
|
||||||
|
|
29
games/templates/add_game.html
Normal file
29
games/templates/add_game.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" name="submit" value="Submit"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|
29
games/templates/add_purchase.html
Normal file
29
games/templates/add_purchase.html
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{{ title }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<form method="post" enctype="multipart/form-data">
|
||||||
|
<table class="mx-auto">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
{{ form.as_table }}
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" name="submit" value="Submit"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% if script_name %}
|
||||||
|
<script type="module" src="{% static 'js/'|add:script_name %}"></script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|
@ -64,6 +64,10 @@
|
|||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
|
||||||
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ backlog_decrease_count }}</td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
|
||||||
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
|
||||||
@ -127,7 +131,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<h1 class="text-5xl text-center my-6">Finished (2023 games)</h1>
|
<h1 class="text-5xl text-center my-6">Finished ({{ year }} games)</h1>
|
||||||
<table class="responsive-table">
|
<table class="responsive-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -11,8 +11,14 @@ urlpatterns = [
|
|||||||
name="list_sessions_recent",
|
name="list_sessions_recent",
|
||||||
),
|
),
|
||||||
path("add-game/", views.add_game, name="add_game"),
|
path("add-game/", views.add_game, name="add_game"),
|
||||||
|
path("add-game-unified/", views.add_game_unified, name="add_game_unified"),
|
||||||
path("add-platform/", views.add_platform, name="add_platform"),
|
path("add-platform/", views.add_platform, name="add_platform"),
|
||||||
path("add-session/", views.add_session, name="add_session"),
|
path("add-session/", views.add_session, name="add_session"),
|
||||||
|
path(
|
||||||
|
"add-session-for-purchase/<int:purchase_id>",
|
||||||
|
views.add_session,
|
||||||
|
name="add_session_for_purchase",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"update-session/by-session/<int:session_id>",
|
"update-session/by-session/<int:session_id>",
|
||||||
views.update_session,
|
views.update_session,
|
||||||
@ -34,7 +40,17 @@ urlpatterns = [
|
|||||||
# name="delete_session",
|
# name="delete_session",
|
||||||
# ),
|
# ),
|
||||||
path("add-purchase/", views.add_purchase, name="add_purchase"),
|
path("add-purchase/", views.add_purchase, name="add_purchase"),
|
||||||
|
path(
|
||||||
|
"add-purchase-for-edition/<int:edition_id>",
|
||||||
|
views.add_purchase,
|
||||||
|
name="add_purchase_for_edition",
|
||||||
|
),
|
||||||
path("add-edition/", views.add_edition, name="add_edition"),
|
path("add-edition/", views.add_edition, name="add_edition"),
|
||||||
|
path(
|
||||||
|
"add-edition-for-game/<int:game_id>",
|
||||||
|
views.add_edition,
|
||||||
|
name="add_edition_for_game",
|
||||||
|
),
|
||||||
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
|
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
|
||||||
path("game/<int:game_id>/view", views.view_game, name="view_game"),
|
path("game/<int:game_id>/view", views.view_game, name="view_game"),
|
||||||
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
|
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
|
||||||
|
196
games/views.py
196
games/views.py
@ -35,21 +35,30 @@ def stats_dropdown_year_range(request):
|
|||||||
return {"stats_dropdown_year_range": range(2018, 2024)}
|
return {"stats_dropdown_year_range": range(2018, 2024)}
|
||||||
|
|
||||||
|
|
||||||
def add_session(request):
|
def add_session(request, purchase_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
initial = {}
|
initial = {"timestamp_start": now_with_tz()}
|
||||||
|
|
||||||
now = now_with_tz()
|
|
||||||
initial["timestamp_start"] = now
|
|
||||||
|
|
||||||
last = Session.objects.all().last()
|
last = Session.objects.all().last()
|
||||||
if last != None:
|
if last != None:
|
||||||
initial["purchase"] = last.purchase
|
initial["purchase"] = last.purchase
|
||||||
|
|
||||||
form = SessionForm(request.POST or None, initial=initial)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = SessionForm(request.POST or None, initial=initial)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("list_sessions")
|
form.save()
|
||||||
|
return redirect("list_sessions")
|
||||||
|
else:
|
||||||
|
if purchase_id:
|
||||||
|
purchase = Purchase.objects.get(id=purchase_id)
|
||||||
|
form = SessionForm(
|
||||||
|
initial={
|
||||||
|
**initial,
|
||||||
|
"purchase": purchase,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = SessionForm(initial=initial)
|
||||||
|
|
||||||
context["title"] = "Add New Session"
|
context["title"] = "Add New Session"
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
@ -275,65 +284,57 @@ def stats(request, year: int = 0):
|
|||||||
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
|
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
|
||||||
if year == 0:
|
if year == 0:
|
||||||
year = now_with_tz().year
|
year = now_with_tz().year
|
||||||
first_day_of_year = datetime(year, 1, 1)
|
this_year_sessions = Session.objects.filter(timestamp_start__year=year)
|
||||||
last_day_of_year = datetime(year + 1, 1, 1)
|
selected_currency = "CZK"
|
||||||
year_sessions = Session.objects.filter(timestamp_start__year=year)
|
|
||||||
unique_days = (
|
unique_days = (
|
||||||
year_sessions.annotate(date=TruncDate("timestamp_start"))
|
this_year_sessions.annotate(date=TruncDate("timestamp_start"))
|
||||||
.values("date")
|
.values("date")
|
||||||
.distinct()
|
.distinct()
|
||||||
.aggregate(dates=Count("date"))
|
.aggregate(dates=Count("date"))
|
||||||
)
|
)
|
||||||
year_played_purchases = Purchase.objects.filter(
|
this_year_played_purchases = Purchase.objects.filter(
|
||||||
session__in=year_sessions
|
session__in=this_year_sessions
|
||||||
).distinct()
|
).distinct()
|
||||||
|
|
||||||
selected_currency = "CZK"
|
this_year_purchases = Purchase.objects.filter(date_purchased__year=year)
|
||||||
all_purchased_this_year = (
|
this_year_purchases_with_currency = this_year_purchases.filter(
|
||||||
Purchase.objects.filter(date_purchased__year=year)
|
price_currency__exact=selected_currency
|
||||||
.filter(price_currency__exact=selected_currency)
|
|
||||||
.order_by("date_purchased")
|
|
||||||
)
|
)
|
||||||
all_purchased_without_refunded_this_year = all_purchased_this_year.not_refunded()
|
this_year_purchases_without_refunded = this_year_purchases_with_currency.filter(
|
||||||
all_purchased_refunded_this_year = (
|
date_refunded=None
|
||||||
Purchase.objects.filter(date_purchased__year=year)
|
|
||||||
.filter(price_currency__exact=selected_currency)
|
|
||||||
.refunded()
|
|
||||||
.order_by("date_purchased")
|
|
||||||
)
|
)
|
||||||
|
this_year_purchases_refunded = this_year_purchases_with_currency.refunded()
|
||||||
|
|
||||||
purchased_unfinished = all_purchased_without_refunded_this_year.filter(
|
this_year_purchases_unfinished = this_year_purchases_without_refunded.filter(
|
||||||
date_finished__isnull=True
|
date_finished__isnull=True
|
||||||
)
|
)
|
||||||
|
|
||||||
unfinished_purchases_percent = int(
|
this_year_purchases_unfinished_percent = int(
|
||||||
safe_division(
|
safe_division(
|
||||||
purchased_unfinished.count(), all_purchased_refunded_this_year.count()
|
this_year_purchases_unfinished.count(), this_year_purchases_refunded.count()
|
||||||
)
|
)
|
||||||
* 100
|
* 100
|
||||||
)
|
)
|
||||||
|
|
||||||
all_finished_this_year = Purchase.objects.filter(date_finished__year=year).order_by(
|
purchases_finished_this_year = Purchase.objects.filter(date_finished__year=year)
|
||||||
"date_finished"
|
purchases_finished_this_year_released_this_year = (
|
||||||
)
|
purchases_finished_this_year.filter(edition__year_released=year).order_by(
|
||||||
this_year_finished_this_year = (
|
"date_finished"
|
||||||
Purchase.objects.filter(date_finished__year=year)
|
)
|
||||||
.filter(edition__year_released=year)
|
|
||||||
.order_by("date_finished")
|
|
||||||
)
|
)
|
||||||
purchased_this_year_finished_this_year = (
|
purchased_this_year_finished_this_year = (
|
||||||
all_purchased_without_refunded_this_year.filter(
|
this_year_purchases_without_refunded.intersection(
|
||||||
date_finished__year=year
|
purchases_finished_this_year
|
||||||
).order_by("date_finished")
|
).order_by("date_finished")
|
||||||
)
|
)
|
||||||
|
|
||||||
this_year_spendings = all_purchased_without_refunded_this_year.aggregate(
|
this_year_spendings = this_year_purchases_without_refunded.aggregate(
|
||||||
total_spent=Sum(F("price"))
|
total_spent=Sum(F("price"))
|
||||||
)
|
)
|
||||||
total_spent = this_year_spendings["total_spent"]
|
total_spent = this_year_spendings["total_spent"]
|
||||||
|
|
||||||
games_with_playtime = (
|
games_with_playtime = (
|
||||||
Game.objects.filter(edition__purchase__session__in=year_sessions)
|
Game.objects.filter(edition__purchase__session__in=this_year_sessions)
|
||||||
.annotate(
|
.annotate(
|
||||||
total_playtime=Sum(
|
total_playtime=Sum(
|
||||||
F("edition__purchase__session__duration_calculated")
|
F("edition__purchase__session__duration_calculated")
|
||||||
@ -347,7 +348,7 @@ def stats(request, year: int = 0):
|
|||||||
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
|
||||||
|
|
||||||
total_playtime_per_platform = (
|
total_playtime_per_platform = (
|
||||||
year_sessions.values("purchase__platform__name")
|
this_year_sessions.values("purchase__platform__name")
|
||||||
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
|
||||||
.annotate(platform_name=F("purchase__platform__name"))
|
.annotate(platform_name=F("purchase__platform__name"))
|
||||||
.values("platform_name", "total_playtime")
|
.values("platform_name", "total_playtime")
|
||||||
@ -356,12 +357,18 @@ def stats(request, year: int = 0):
|
|||||||
for item in total_playtime_per_platform:
|
for item in total_playtime_per_platform:
|
||||||
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
|
||||||
|
|
||||||
|
backlog_decrease_count = (
|
||||||
|
Purchase.objects.filter(date_purchased__year__lt=year)
|
||||||
|
.intersection(purchases_finished_this_year)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
"total_hours": format_duration(
|
"total_hours": format_duration(
|
||||||
year_sessions.total_duration_unformatted(), "%2.0H"
|
this_year_sessions.total_duration_unformatted(), "%2.0H"
|
||||||
),
|
),
|
||||||
"total_games": year_played_purchases.count(),
|
"total_games": this_year_played_purchases.count(),
|
||||||
"total_2023_games": year_played_purchases.filter(
|
"total_2023_games": this_year_played_purchases.filter(
|
||||||
edition__year_released=year
|
edition__year_released=year
|
||||||
).count(),
|
).count(),
|
||||||
"top_10_games_by_playtime": top_10_games_by_playtime,
|
"top_10_games_by_playtime": top_10_games_by_playtime,
|
||||||
@ -369,68 +376,115 @@ def stats(request, year: int = 0):
|
|||||||
"total_playtime_per_platform": total_playtime_per_platform,
|
"total_playtime_per_platform": total_playtime_per_platform,
|
||||||
"total_spent": total_spent,
|
"total_spent": total_spent,
|
||||||
"total_spent_currency": selected_currency,
|
"total_spent_currency": selected_currency,
|
||||||
"all_purchased_this_year": all_purchased_without_refunded_this_year,
|
"all_purchased_this_year": this_year_purchases_without_refunded,
|
||||||
"spent_per_game": int(
|
"spent_per_game": int(
|
||||||
safe_division(total_spent, all_purchased_without_refunded_this_year.count())
|
safe_division(total_spent, this_year_purchases_without_refunded.count())
|
||||||
),
|
),
|
||||||
"all_finished_this_year": all_finished_this_year,
|
"all_finished_this_year": purchases_finished_this_year,
|
||||||
"this_year_finished_this_year": this_year_finished_this_year,
|
"this_year_finished_this_year": purchases_finished_this_year_released_this_year,
|
||||||
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
|
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
|
||||||
"total_sessions": year_sessions.count(),
|
"total_sessions": this_year_sessions.count(),
|
||||||
"unique_days": unique_days["dates"],
|
"unique_days": unique_days["dates"],
|
||||||
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
|
||||||
"purchased_unfinished": purchased_unfinished,
|
"purchased_unfinished": this_year_purchases_unfinished,
|
||||||
"unfinished_purchases_percent": unfinished_purchases_percent,
|
"unfinished_purchases_percent": this_year_purchases_unfinished_percent,
|
||||||
"refunded_percent": int(
|
"refunded_percent": int(
|
||||||
safe_division(
|
safe_division(
|
||||||
all_purchased_refunded_this_year.count(),
|
this_year_purchases_refunded.count(),
|
||||||
all_purchased_this_year.count(),
|
this_year_purchases_with_currency.count(),
|
||||||
)
|
)
|
||||||
* 100
|
* 100
|
||||||
),
|
),
|
||||||
"all_purchased_refunded_this_year": all_purchased_refunded_this_year,
|
"all_purchased_refunded_this_year": this_year_purchases_refunded,
|
||||||
"all_purchased_this_year": all_purchased_this_year,
|
"all_purchased_this_year": this_year_purchases_with_currency.order_by(
|
||||||
|
"date_purchased"
|
||||||
|
),
|
||||||
|
"backlog_decrease_count": backlog_decrease_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
request.session["return_path"] = request.path
|
request.session["return_path"] = request.path
|
||||||
return render(request, "stats.html", context)
|
return render(request, "stats.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_purchase(request):
|
def add_purchase(request, edition_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
now = datetime.now()
|
initial = {"date_purchased": now_with_tz()}
|
||||||
initial = {"date_purchased": now}
|
|
||||||
form = PurchaseForm(request.POST or None, initial=initial)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = PurchaseForm(request.POST or None, initial=initial)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("index")
|
purchase = form.save()
|
||||||
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse(
|
||||||
|
"add_session_for_purchase", kwargs={"purchase_id": purchase.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
else:
|
||||||
|
if edition_id:
|
||||||
|
edition = Edition.objects.get(id=edition_id)
|
||||||
|
form = PurchaseForm(
|
||||||
|
initial={
|
||||||
|
**initial,
|
||||||
|
"edition": edition,
|
||||||
|
"platform": edition.platform,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = PurchaseForm(initial=initial)
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Purchase"
|
context["title"] = "Add New Purchase"
|
||||||
return render(request, "add.html", context)
|
context["script_name"] = "add_purchase.js"
|
||||||
|
return render(request, "add_purchase.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_game(request):
|
def add_game(request):
|
||||||
context = {}
|
context = {}
|
||||||
form = GameForm(request.POST or None)
|
form = GameForm(request.POST or None)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
game = form.save()
|
||||||
return redirect("index")
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse("add_edition_for_game", kwargs={"game_id": game.id})
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Game"
|
context["title"] = "Add New Game"
|
||||||
return render(request, "add.html", context)
|
context["script_name"] = "add_game.js"
|
||||||
|
return render(request, "add_game.html", context)
|
||||||
|
|
||||||
|
|
||||||
def add_edition(request):
|
def add_edition(request, game_id=None):
|
||||||
context = {}
|
context = {}
|
||||||
form = EditionForm(request.POST or None)
|
if request.method == "POST":
|
||||||
if form.is_valid():
|
form = EditionForm(request.POST or None)
|
||||||
form.save()
|
if form.is_valid():
|
||||||
return redirect("index")
|
edition = form.save()
|
||||||
|
if "submit_and_redirect" in request.POST:
|
||||||
|
return HttpResponseRedirect(
|
||||||
|
reverse(
|
||||||
|
"add_purchase_for_edition", kwargs={"edition_id": edition.id}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return redirect("index")
|
||||||
|
else:
|
||||||
|
if game_id:
|
||||||
|
game = Game.objects.get(id=game_id)
|
||||||
|
form = EditionForm(
|
||||||
|
initial={"game": game, "name": game.name, "sort_name": game.sort_name}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
form = EditionForm()
|
||||||
|
|
||||||
context["form"] = form
|
context["form"] = form
|
||||||
context["title"] = "Add New Edition"
|
context["title"] = "Add New Edition"
|
||||||
|
context["script_name"] = "add_edition.js"
|
||||||
return render(request, "add_edition.html", context)
|
return render(request, "add_edition.html", context)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "timetracker"
|
name = "timetracker"
|
||||||
version = "1.3.0"
|
version = "1.4.0"
|
||||||
description = "A simple time tracker."
|
description = "A simple time tracker."
|
||||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||||
license = "GPL"
|
license = "GPL"
|
||||||
|
Reference in New Issue
Block a user