Compare commits
	
		
			23 Commits
		
	
	
		
			1.0.2
			...
			0b7da3550c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
| 
						
						
							
						
						b78c4ba9c5
	
				 | 
					
					
						|||
| 
						
						
							
						
						1df889c45d
	
				 | 
					
					
						|||
| 
						
						
							
						
						468d05a9e2
	
				 | 
					
					
						|||
| 
						
						
							
						
						2640a49734
	
				 | 
					
					
						|||
| 
						
						
							
						
						65c175afb2
	
				 | 
					
					
						|||
| 
						
						
							
						
						0814071a26
	
				 | 
					
					
						|||
| 
						
						
							
						
						5f845f866e
	
				 | 
					
					
						|||
| 
						
						
							
						
						c3d4697470
	
				 | 
					
					
						|||
| 77293f03e9 | |||
| 1fa364e2ec | |||
| 4a6f4a2f9a | |||
| 9590988b6a | |||
| 938c82a395 | |||
| 33939f631c | 
							
								
								
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,17 @@
 | 
			
		||||
root = true
 | 
			
		||||
 | 
			
		||||
[*]
 | 
			
		||||
end_of_line = lf
 | 
			
		||||
insert_final_newline = true
 | 
			
		||||
 | 
			
		||||
[*.{js,py}]
 | 
			
		||||
charset = utf-8
 | 
			
		||||
 | 
			
		||||
# 4 space indentation
 | 
			
		||||
[*.py]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 4
 | 
			
		||||
 | 
			
		||||
[**/*.js]
 | 
			
		||||
indent_style = space
 | 
			
		||||
indent_size = 2
 | 
			
		||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						@ -5,5 +5,5 @@ __pycache__
 | 
			
		||||
node_modules
 | 
			
		||||
package-lock.json
 | 
			
		||||
db.sqlite3
 | 
			
		||||
static
 | 
			
		||||
/static/
 | 
			
		||||
dist/
 | 
			
		||||
							
								
								
									
										13
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@ -1,3 +1,16 @@
 | 
			
		||||
## Unreleased
 | 
			
		||||
 | 
			
		||||
* Improve form appearance
 | 
			
		||||
* Add helper buttons next to datime fields
 | 
			
		||||
* Change recent session view to current year instead of last 30 days
 | 
			
		||||
 | 
			
		||||
## 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
 | 
			
		||||
* Allow editing filtered entities from session list
 | 
			
		||||
 | 
			
		||||
## 1.0.2 / 2023-02-18 21:48+01:00
 | 
			
		||||
 | 
			
		||||
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ RUN npm install && \
 | 
			
		||||
 | 
			
		||||
FROM python:3.10.9-slim-bullseye
 | 
			
		||||
 | 
			
		||||
ENV VERSION_NUMBER 1.0.2
 | 
			
		||||
ENV VERSION_NUMBER 1.0.3
 | 
			
		||||
ENV PROD 1
 | 
			
		||||
ENV PYTHONUNBUFFERED=1
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,11 +12,59 @@ textarea {
 | 
			
		||||
  @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and  (min-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media screen and  (max-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 150px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#session-table {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
 | 
			
		||||
  grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.purchase-name > span:nth-child(2) {
 | 
			
		||||
  @apply ml-4
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.purchase-name > span:nth-child(2) > a > img {
 | 
			
		||||
  @apply opacity-0 transition-opacity duration-500
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.purchase-name:hover > span:nth-child(2) > a > img {
 | 
			
		||||
  @apply opacity-50
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.purchase-name > span:nth-child(2) > a > img:hover {
 | 
			
		||||
  @apply opacity-100
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#button-container button {
 | 
			
		||||
  @apply mx-1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  @apply text-right;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th label {
 | 
			
		||||
  @apply mr-4;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button-container {
 | 
			
		||||
  @apply flex space-x-2 justify-center
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.basic-button {
 | 
			
		||||
  @apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,11 @@
 | 
			
		||||
from django.contrib import admin
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
 | 
			
		||||
 | 
			
		||||
# Register your models here.
 | 
			
		||||
admin.site.register(Game)
 | 
			
		||||
admin.site.register(Purchase)
 | 
			
		||||
admin.site.register(Platform)
 | 
			
		||||
admin.site.register(Session)
 | 
			
		||||
admin.site.register(Edition)
 | 
			
		||||
admin.site.register(Device)
 | 
			
		||||
 | 
			
		||||
@ -2,13 +2,22 @@ from django import forms
 | 
			
		||||
 | 
			
		||||
from games.models import Game, Platform, Purchase, Session, Edition, Device
 | 
			
		||||
 | 
			
		||||
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(
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__name")
 | 
			
		||||
        queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "timestamp_start": custom_datetime_widget,
 | 
			
		||||
            "timestamp_end": custom_datetime_widget,
 | 
			
		||||
        }
 | 
			
		||||
        model = Session
 | 
			
		||||
        fields = [
 | 
			
		||||
            "purchase",
 | 
			
		||||
@ -20,11 +29,20 @@ class SessionForm(forms.ModelForm):
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditionChoiceField(forms.ModelChoiceField):
 | 
			
		||||
    def label_from_instance(self, obj) -> str:
 | 
			
		||||
        return f"{obj.name} ({obj.platform}, {obj.year_released})"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseForm(forms.ModelForm):
 | 
			
		||||
    edition = forms.ModelChoiceField(queryset=Edition.objects.order_by("name"))
 | 
			
		||||
    edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "date_purchased": custom_date_widget,
 | 
			
		||||
            "date_refunded": custom_date_widget,
 | 
			
		||||
        }
 | 
			
		||||
        model = Purchase
 | 
			
		||||
        fields = [
 | 
			
		||||
            "edition",
 | 
			
		||||
@ -38,9 +56,12 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditionForm(forms.ModelForm):
 | 
			
		||||
    game = forms.ModelChoiceField(queryset=Game.objects.order_by("name"))
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Edition
 | 
			
		||||
        fields = ["game", "name", "platform"]
 | 
			
		||||
        fields = ["game", "name", "platform", "year_released", "wikidata"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
# Generated by Django 4.1.5 on 2023-02-20 14:55
 | 
			
		||||
 | 
			
		||||
from django.db import migrations, models
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Migration(migrations.Migration):
 | 
			
		||||
 | 
			
		||||
    dependencies = [
 | 
			
		||||
        ("games", "0014_device_session_device"),
 | 
			
		||||
    ]
 | 
			
		||||
 | 
			
		||||
    operations = [
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="wikidata",
 | 
			
		||||
            field=models.CharField(blank=True, default=None, max_length=50, null=True),
 | 
			
		||||
        ),
 | 
			
		||||
        migrations.AddField(
 | 
			
		||||
            model_name="edition",
 | 
			
		||||
            name="year_released",
 | 
			
		||||
            field=models.IntegerField(default=2023),
 | 
			
		||||
        ),
 | 
			
		||||
    ]
 | 
			
		||||
@ -20,6 +20,8 @@ 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)
 | 
			
		||||
    wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return self.name
 | 
			
		||||
@ -56,7 +58,10 @@ class Purchase(models.Model):
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    def __str__(self):
 | 
			
		||||
        return f"{self.edition} ({self.platform}, {self.get_ownership_type_display()})"
 | 
			
		||||
        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()})"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Platform(models.Model):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 292 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/edition_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 321 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 22 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/game_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 306 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_black.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 312 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/purchase_white.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 364 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/schedule.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 42 KiB  | 
							
								
								
									
										29
									
								
								games/static/js/add_edition.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,29 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @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);
 | 
			
		||||
  targetElement.addEventListener("focus", targetElementHandler);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener("load", () => {
 | 
			
		||||
  syncSelectInputUntilChanged("#id_game", "#id_name");
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										16
									
								
								games/static/js/add_session.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
import { toISOUTCString } from "./utils.js";
 | 
			
		||||
 | 
			
		||||
for (let button of document.querySelectorAll("[data-target]")) {
 | 
			
		||||
  let target = button.getAttribute("data-target");
 | 
			
		||||
  let type = button.getAttribute("data-type");
 | 
			
		||||
  let targetElement = document.querySelector(`#id_${target}`);
 | 
			
		||||
  button.addEventListener("click", (event) => {
 | 
			
		||||
    event.preventDefault();
 | 
			
		||||
    if (type == "now") {
 | 
			
		||||
      targetElement.value = toISOUTCString(new Date);
 | 
			
		||||
    } else if (type == "toggle") {
 | 
			
		||||
      if (targetElement.type == "datetime-local") targetElement.type = "text";
 | 
			
		||||
      else targetElement.type = "datetime-local";
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										16
									
								
								games/static/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,16 @@
 | 
			
		||||
/**
 | 
			
		||||
 * @description Formats Date to a UTC string accepted by the datetime-local input field.
 | 
			
		||||
 * @param {Date} date
 | 
			
		||||
 * @returns {string}
 | 
			
		||||
 */
 | 
			
		||||
export 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}`;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								games/static/main.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,43 @@
 | 
			
		||||
function elt(type, props, ...children) {
 | 
			
		||||
  let dom = document.createElement(type);
 | 
			
		||||
  if (props) Object.assign(dom, props);
 | 
			
		||||
  for (let child of children) {
 | 
			
		||||
    if (typeof child != "string") dom.appendChild(child);
 | 
			
		||||
    else dom.appendChild(document.createTextNode(child));
 | 
			
		||||
  }
 | 
			
		||||
  return dom;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @param {Node} targetNode
 | 
			
		||||
 */
 | 
			
		||||
function addToggleButton(targetNode) {
 | 
			
		||||
  let manualToggleButton = elt(
 | 
			
		||||
    "td",
 | 
			
		||||
    {},
 | 
			
		||||
    elt(
 | 
			
		||||
      "div",
 | 
			
		||||
      { className: "basic-button" },
 | 
			
		||||
      elt(
 | 
			
		||||
        "button",
 | 
			
		||||
        {
 | 
			
		||||
          onclick: (event) => {
 | 
			
		||||
            let textInputField = elt("input", { type: "text", id: targetNode.id });
 | 
			
		||||
            targetNode.replaceWith(textInputField);
 | 
			
		||||
            event.target.addEventListener("click", (event) => {
 | 
			
		||||
              textInputField.replaceWith(targetNode);
 | 
			
		||||
            });
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        "Toggle manual"
 | 
			
		||||
      )
 | 
			
		||||
    )
 | 
			
		||||
  );
 | 
			
		||||
  targetNode.parentElement.appendChild(manualToggleButton);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
 | 
			
		||||
 | 
			
		||||
toggleableFields.map((selector) => {
 | 
			
		||||
  addToggleButton(document.querySelector(selector));
 | 
			
		||||
});
 | 
			
		||||
@ -3,11 +3,15 @@
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data" class="mx-auto">
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <table class="mx-auto">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        {{ form.as_p }}
 | 
			
		||||
 | 
			
		||||
        <input type="submit" value="Submit"/>
 | 
			
		||||
        {{ form.as_table }}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								games/templates/add_edition.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,22 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <form method="post" enctype="multipart/form-data">
 | 
			
		||||
        <table class="mx-auto">
 | 
			
		||||
        {% csrf_token %}
 | 
			
		||||
 | 
			
		||||
        {{ form.as_table }}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
{% block scripts %}
 | 
			
		||||
{% load static %}
 | 
			
		||||
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
 | 
			
		||||
{% endblock scripts %}
 | 
			
		||||
							
								
								
									
										36
									
								
								games/templates/add_session.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,36 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% 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>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
    {% load static %}
 | 
			
		||||
    <script type="module" src="{% static 'js/add_session.js' %}"></script>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
@ -12,13 +12,13 @@
 | 
			
		||||
        <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'base.css' %}" />
 | 
			
		||||
    </head>
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    <body class="dark">
 | 
			
		||||
        <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">
 | 
			
		||||
@ -47,6 +47,7 @@
 | 
			
		||||
        </div>
 | 
			
		||||
        {% load version %}
 | 
			
		||||
        <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
 | 
			
		||||
    {% block scripts %}{% endblock scripts %}
 | 
			
		||||
    </body>
 | 
			
		||||
 | 
			
		||||
</html>
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
{% extends 'base.html' %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
@ -10,13 +12,28 @@
 | 
			
		||||
            {% if dataset.count >= 1 %}
 | 
			
		||||
            <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if purchase or platform or edition %}
 | 
			
		||||
            {% if purchase or platform or edition or game or ownership_type %}
 | 
			
		||||
            <span class="block">
 | 
			
		||||
                <a class="text-red-400 inline" href="{% url 'list_sessions' %}">
 | 
			
		||||
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline">
 | 
			
		||||
                        <path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
 | 
			
		||||
                    </svg>
 | 
			
		||||
                </a>Filtering by "{% firstof purchase platform edition %}"
 | 
			
		||||
                </a>
 | 
			
		||||
                {% if purchase %}
 | 
			
		||||
                Filtering by purchase "{{ purchase }}"
 | 
			
		||||
                (<a href="{% url 'edit_purchase' purchase.id %}" class="hover:underline dark:text-white">Edit</a>)
 | 
			
		||||
                {% elif platform %}
 | 
			
		||||
                Filtering by purchase "{{ platform }}"
 | 
			
		||||
                (<a href="{% url 'edit_platform' platform.id %}" class="hover:underline dark:text-white">Edit</a>)
 | 
			
		||||
                {% elif game %}
 | 
			
		||||
                Filtering by purchase "{{ game }}"
 | 
			
		||||
                (<a href="{% url 'edit_game' game.id %}" class="hover:underline dark:text-white">Edit</a>)
 | 
			
		||||
                {% elif edition %}
 | 
			
		||||
                Filtering by purchase "{{ edition }}"
 | 
			
		||||
                (<a href="{% url 'edit_edition' edition.id %}" class="hover:underline dark:text-white">Edit</a>)
 | 
			
		||||
                {% elif ownership_type %}
 | 
			
		||||
                Filtering by ownership type "{{ ownership_type }}"
 | 
			
		||||
                {% endif%}
 | 
			
		||||
            </span>
 | 
			
		||||
            {% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_edition' purchase.edition.id %}">See all platforms</a>{% endif %}
 | 
			
		||||
            {% endif %}
 | 
			
		||||
@ -31,16 +48,38 @@
 | 
			
		||||
            </a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </div>
 | 
			
		||||
    <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Name</div>
 | 
			
		||||
    <div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-2xl mx-auto dark:bg-slate-700 p-2 justify-center">
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Purchase</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Platform</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg">Duration</div>
 | 
			
		||||
        <div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div>
 | 
			
		||||
        {% for data in dataset %}
 | 
			
		||||
            <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.edition }}</a></div>
 | 
			
		||||
            <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div>
 | 
			
		||||
            <div class="purchase-name">
 | 
			
		||||
                <span class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">{{ data.purchase.edition }} <span class="dark:text-slate-400">(<a class="hover:underline" href="{% url 'list_sessions_by_ownership_type' data.purchase.ownership_type %}">{{ data.purchase.get_ownership_type_display }}</a>)</span></span>
 | 
			
		||||
                
 | 
			
		||||
                <span>
 | 
			
		||||
                    <a href="{% url 'list_sessions_by_game'  data.purchase.edition.game.id %}">
 | 
			
		||||
                        <img src="{% static 'icons/game_white.png' %}" width="32" class="inline" alt="Filter by this game" title="Filter by this game" />
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <a href="{% url 'list_sessions_by_edition'  data.purchase.edition.id %}">
 | 
			
		||||
                        <img src="{% static 'icons/edition_white.png' %}" width="32" class="inline" alt="Filter by this edition" title="Filter by this edition" />
 | 
			
		||||
                    </a>
 | 
			
		||||
                    <a href="{% url 'list_sessions_by_purchase' data.purchase.id %}">
 | 
			
		||||
                        <img src="{% static 'icons/purchase_white.png' %}" width="32" class="inline" alt="Filter by this purchase" title="Filter by this purchase" />
 | 
			
		||||
                    </a>
 | 
			
		||||
                </span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
 | 
			
		||||
                <a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">
 | 
			
		||||
                    {% if data.purchase.platform != data.purchase.edition.platform %}
 | 
			
		||||
                        {{data.purchase.edition.platform}} on {{ data.purchase.platform }}
 | 
			
		||||
                    {% else %}
 | 
			
		||||
                        {{ data.purchase.platform }}
 | 
			
		||||
                    {% endif %}
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div>
 | 
			
		||||
            <div class="dark:text-slate-400 text-center">
 | 
			
		||||
                {% if data.unfinished %}
 | 
			
		||||
 | 
			
		||||
@ -31,6 +31,8 @@ urlpatterns = [
 | 
			
		||||
    path("add-purchase/", views.add_purchase, name="add_purchase"),
 | 
			
		||||
    path("add-edition/", views.add_edition, name="add_edition"),
 | 
			
		||||
    path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
 | 
			
		||||
    path("edit-game/<int:game_id>", views.edit_game, name="edit_game"),
 | 
			
		||||
    path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
 | 
			
		||||
    path("add-device/", views.add_device, name="add_device"),
 | 
			
		||||
    path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
 | 
			
		||||
    path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
 | 
			
		||||
@ -47,10 +49,22 @@ urlpatterns = [
 | 
			
		||||
        {"filter": "platform"},
 | 
			
		||||
        name="list_sessions_by_platform",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-game/<int:game_id>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "game"},
 | 
			
		||||
        name="list_sessions_by_game",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-edition/<int:edition_id>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "edition"},
 | 
			
		||||
        name="list_sessions_by_edition",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "list-sessions/by-ownership/<str:ownership_type>",
 | 
			
		||||
        views.list_sessions,
 | 
			
		||||
        {"filter": "ownership_type"},
 | 
			
		||||
        name="list_sessions_by_ownership_type",
 | 
			
		||||
    ),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@ -45,7 +45,7 @@ def add_session(request):
 | 
			
		||||
 | 
			
		||||
    context["title"] = "Add New Session"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_session.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def update_session(request, session_id=None):
 | 
			
		||||
@ -79,6 +79,30 @@ def edit_purchase(request, purchase_id=None):
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_game(request, game_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    purchase = Game.objects.get(id=game_id)
 | 
			
		||||
    form = GameForm(request.POST or None, instance=purchase)
 | 
			
		||||
    if form.is_valid():
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Game"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_platform(request, platform_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    purchase = Platform.objects.get(id=platform_id)
 | 
			
		||||
    form = PlatformForm(request.POST or None, instance=purchase)
 | 
			
		||||
    if form.is_valid():
 | 
			
		||||
        form.save()
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Platform"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_edition(request, edition_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    edition = Edition.objects.get(id=edition_id)
 | 
			
		||||
@ -110,7 +134,15 @@ def delete_session(request, session_id=None):
 | 
			
		||||
    return redirect("list_sessions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id=""):
 | 
			
		||||
def list_sessions(
 | 
			
		||||
    request,
 | 
			
		||||
    filter="",
 | 
			
		||||
    purchase_id="",
 | 
			
		||||
    platform_id="",
 | 
			
		||||
    game_id="",
 | 
			
		||||
    edition_id="",
 | 
			
		||||
    ownership_type: str = "",
 | 
			
		||||
):
 | 
			
		||||
    context = {}
 | 
			
		||||
    context["title"] = "Sessions"
 | 
			
		||||
 | 
			
		||||
@ -123,11 +155,19 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id
 | 
			
		||||
    elif filter == "edition":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__edition=edition_id)
 | 
			
		||||
        context["edition"] = Edition.objects.get(id=edition_id)
 | 
			
		||||
    elif filter == "game":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__edition__game=game_id)
 | 
			
		||||
        context["game"] = Game.objects.get(id=game_id)
 | 
			
		||||
    elif filter == "ownership_type":
 | 
			
		||||
        dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
 | 
			
		||||
        context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
 | 
			
		||||
    elif filter == "recent":
 | 
			
		||||
        current_year = datetime.now().year
 | 
			
		||||
        first_day_of_year = datetime(current_year, 1, 1)
 | 
			
		||||
        dataset = Session.objects.filter(
 | 
			
		||||
            timestamp_start__gte=datetime.now() - timedelta(days=30)
 | 
			
		||||
            timestamp_start__gte=first_day_of_year
 | 
			
		||||
        ).order_by("-timestamp_start")
 | 
			
		||||
        context["title"] = "Last 30 days"
 | 
			
		||||
        context["title"] = "This year"
 | 
			
		||||
    else:
 | 
			
		||||
        # by default, sort from newest to oldest
 | 
			
		||||
        dataset = Session.objects.all().order_by("-timestamp_start")
 | 
			
		||||
@ -142,7 +182,7 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", edition_id
 | 
			
		||||
    # cannot use dataset[0] here because that might be only partial QuerySet
 | 
			
		||||
    context["last"] = Session.objects.all().order_by("timestamp_start").last()
 | 
			
		||||
    # charts are always oldest->newest
 | 
			
		||||
    if Session.objects.count() >= 2:
 | 
			
		||||
    if dataset.count() >= 2:
 | 
			
		||||
        context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
 | 
			
		||||
 | 
			
		||||
    return render(request, "list_sessions.html", context)
 | 
			
		||||
@ -183,7 +223,7 @@ def add_edition(request):
 | 
			
		||||
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    context["title"] = "Add New Edition"
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_edition.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def add_platform(request):
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
[tool.poetry]
 | 
			
		||||
name = "timetracker"
 | 
			
		||||
version = "1.0.2"
 | 
			
		||||
version = "1.0.3"
 | 
			
		||||
description = "A simple time tracker."
 | 
			
		||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
 | 
			
		||||
license = "GPL"
 | 
			
		||||
 | 
			
		||||
@ -149,3 +149,5 @@ if _csrf_trusted_origins:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
 | 
			
		||||
else:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = []
 | 
			
		||||
 | 
			
		||||
USE_L10N = False
 | 
			
		||||
 | 
			
		||||