Compare commits
	
		
			35 Commits
		
	
	
		
			1.0.3
			...
			e3ee832d3f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e3ee832d3f | |||
| 7467e2732d | |||
| 787ee8640f | |||
| ab41222f3c | |||
| 29bf3b1946 | |||
| 3f7ccea2e2 | |||
| b5ffb3586b | |||
| 26d57a238e | |||
| 2d5ad3182c | |||
| 49cc3ea0cc | |||
| 440e1cfb71 | |||
| 1cbd8c5c55 | |||
| bc81a0ee8e | |||
| c5653977ff | |||
| f151730ab6 | |||
| f469a67d94 | |||
| 104ffc9d03 | |||
| a4b13eb247 | |||
| 2307fac83a | |||
| 6b52c0d4c4 | |||
| ff5d8c215d | |||
| cdb3b89b08 | |||
| ffa8198540 | |||
| 0b7da3550c | |||
| e1655d6cfa | |||
| 29c41865d0 | |||
| d21b461726 | |||
| 95489cfb78 | |||
| fa4f1c4810 | |||
| 366c25a1ff | |||
| a3042caa20 | |||
| 7997f9bbb2 | |||
| 
						
						
							
						
						b78c4ba9c5
	
				 | 
					
					
						|||
| 
						
						
							
						
						1df889c45d
	
				 | 
					
					
						|||
| 
						
						
							
						
						468d05a9e2
	
				 | 
					
					
						
							
								
								
									
										17
									
								
								.editorconfig
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
 | 
			
		||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -5,6 +5,5 @@ __pycache__
 | 
			
		||||
node_modules
 | 
			
		||||
package-lock.json
 | 
			
		||||
db.sqlite3
 | 
			
		||||
static/admin/
 | 
			
		||||
static/django_extensions/
 | 
			
		||||
/static/
 | 
			
		||||
dist/
 | 
			
		||||
							
								
								
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							@ -4,5 +4,8 @@
 | 
			
		||||
    ],
 | 
			
		||||
    "python.testing.unittestEnabled": false,
 | 
			
		||||
    "python.testing.pytestEnabled": true,
 | 
			
		||||
    "python.analysis.typeCheckingMode": "basic"
 | 
			
		||||
}
 | 
			
		||||
    "python.analysis.typeCheckingMode": "basic",
 | 
			
		||||
    "[python]": {
 | 
			
		||||
        "editor.defaultFormatter": "ms-python.black-formatter"
 | 
			
		||||
    },
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								CHANGELOG.md
									
									
									
									
									
								
							@ -1,3 +1,18 @@
 | 
			
		||||
## Unreleased
 | 
			
		||||
 | 
			
		||||
* Improve form appearance
 | 
			
		||||
* Add helper buttons next to datime fields
 | 
			
		||||
* Change recent session view to current year instead of last 30 days
 | 
			
		||||
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
 | 
			
		||||
* Add copy button on Add session page to copy times between fields
 | 
			
		||||
* Use the same form when editing a session as when adding a session
 | 
			
		||||
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
 | 
			
		||||
* Focus important fields on forms
 | 
			
		||||
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
 | 
			
		||||
* Change fonts to IBM Plex
 | 
			
		||||
* Only use local WOFF2 font files
 | 
			
		||||
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
 | 
			
		||||
 | 
			
		||||
## 1.0.3 / 2023-02-20 17:16+01:00
 | 
			
		||||
 | 
			
		||||
* Add wikidata ID and year for editions
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,7 @@ RUN chmod +x /entrypoint.sh
 | 
			
		||||
USER timetracker
 | 
			
		||||
ENV PATH="$PATH:/home/timetracker/.local/bin"
 | 
			
		||||
RUN pip install --no-cache-dir poetry
 | 
			
		||||
RUN poetry install --without dev
 | 
			
		||||
RUN poetry install
 | 
			
		||||
 | 
			
		||||
EXPOSE 8000
 | 
			
		||||
CMD [ "/entrypoint.sh" ]
 | 
			
		||||
CMD [ "/entrypoint.sh" ]
 | 
			
		||||
 | 
			
		||||
@ -2,35 +2,84 @@
 | 
			
		||||
@tailwind components;
 | 
			
		||||
@tailwind utilities;
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Mono";
 | 
			
		||||
  src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Sans";
 | 
			
		||||
  src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@font-face {
 | 
			
		||||
  font-family: "IBM Plex Serif";
 | 
			
		||||
  src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
  font-style: normal;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form label {
 | 
			
		||||
  @apply dark:text-slate-400;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table {
 | 
			
		||||
  @apply dark:text-white mx-auto;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tr:nth-child(even) {
 | 
			
		||||
  @apply bg-slate-800
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table tbody tr:nth-child(odd) {
 | 
			
		||||
  @apply bg-slate-900
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th {
 | 
			
		||||
  @apply text-left border-b-2 border-b-slate-500 text-xl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.responsive-table thead th:not(:first-child),
 | 
			
		||||
.responsive-table td:not(:first-child) {
 | 
			
		||||
  @apply border-l border-l-slate-500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@layer utilities {
 | 
			
		||||
  .max-w-20char {
 | 
			
		||||
    max-width: 20ch;
 | 
			
		||||
  }
 | 
			
		||||
  .max-w-35char {
 | 
			
		||||
    max-width: 40ch;
 | 
			
		||||
  }
 | 
			
		||||
  .max-w-40char {
 | 
			
		||||
    max-width: 40ch;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
form input,
 | 
			
		||||
select,
 | 
			
		||||
textarea {
 | 
			
		||||
  @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#session-table {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: 3fr 2fr repeat(2, 1fr) 0.5fr 1fr;
 | 
			
		||||
@media screen and (min-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 300px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.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
 | 
			
		||||
@media screen and (max-width: 768px) {
 | 
			
		||||
  form input,
 | 
			
		||||
  select,
 | 
			
		||||
  textarea {
 | 
			
		||||
    width: 150px;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#button-container button {
 | 
			
		||||
@ -38,9 +87,17 @@ textarea {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  @apply text-left;
 | 
			
		||||
  @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;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -7,5 +7,13 @@ poetry run python manage.py migrate
 | 
			
		||||
echo "Collect static files"
 | 
			
		||||
poetry run python manage.py collectstatic --clear --no-input
 | 
			
		||||
 | 
			
		||||
_term() {
 | 
			
		||||
  echo "Caught SIGTERM signal!"
 | 
			
		||||
  kill -SIGTERM "$gunicorn_pid"
 | 
			
		||||
}
 | 
			
		||||
trap _term SIGTERM
 | 
			
		||||
 | 
			
		||||
echo "Starting app"
 | 
			
		||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile -
 | 
			
		||||
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
 | 
			
		||||
 | 
			
		||||
wait "$gunicorn_pid"
 | 
			
		||||
 | 
			
		||||
@ -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,28 @@ 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"
 | 
			
		||||
)
 | 
			
		||||
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
 | 
			
		||||
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class SessionForm(forms.ModelForm):
 | 
			
		||||
    # purchase = forms.ModelChoiceField(
 | 
			
		||||
    #     queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
 | 
			
		||||
    # )
 | 
			
		||||
    purchase = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__name")
 | 
			
		||||
        queryset=Purchase.objects.order_by("edition__name"),
 | 
			
		||||
        widget=autofocus_select_widget,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
        widgets = {
 | 
			
		||||
            "timestamp_start": custom_datetime_widget,
 | 
			
		||||
            "timestamp_end": custom_datetime_widget,
 | 
			
		||||
        }
 | 
			
		||||
        model = Session
 | 
			
		||||
        fields = [
 | 
			
		||||
            "purchase",
 | 
			
		||||
@ -26,10 +41,16 @@ class EditionChoiceField(forms.ModelChoiceField):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PurchaseForm(forms.ModelForm):
 | 
			
		||||
    edition = EditionChoiceField(queryset=Edition.objects.order_by("name"))
 | 
			
		||||
    edition = EditionChoiceField(
 | 
			
		||||
        queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
 | 
			
		||||
    )
 | 
			
		||||
    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",
 | 
			
		||||
@ -43,6 +64,9 @@ class PurchaseForm(forms.ModelForm):
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class EditionForm(forms.ModelForm):
 | 
			
		||||
    game = forms.ModelChoiceField(
 | 
			
		||||
        queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
 | 
			
		||||
    )
 | 
			
		||||
    platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
 | 
			
		||||
 | 
			
		||||
    class Meta:
 | 
			
		||||
@ -54,15 +78,18 @@ class GameForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Game
 | 
			
		||||
        fields = ["name", "wikidata"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PlatformForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Platform
 | 
			
		||||
        fields = ["name", "group"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class DeviceForm(forms.ModelForm):
 | 
			
		||||
    class Meta:
 | 
			
		||||
        model = Device
 | 
			
		||||
        fields = ["name", "type"]
 | 
			
		||||
        widgets = {"name": autofocus_input_widget}
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexMono-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexMono-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSans-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSans-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSerif-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								games/static/fonts/IBMPlexSerif-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/loading.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								games/static/icons/loading.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 787 B  | 
							
								
								
									
										
											BIN
										
									
								
								games/static/icons/wikidata.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								games/static/icons/wikidata.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 504 B  | 
							
								
								
									
										29
									
								
								games/static/js/add_edition.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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");
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										19
									
								
								games/static/js/add_session.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								games/static/js/add_session.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,19 @@
 | 
			
		||||
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 == "copy") {
 | 
			
		||||
      const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
 | 
			
		||||
      document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
 | 
			
		||||
    } else if (type == "toggle") {
 | 
			
		||||
      if (targetElement.type == "datetime-local") targetElement.type = "text";
 | 
			
		||||
      else targetElement.type = "datetime-local";
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								games/static/js/htmx.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								games/static/js/htmx.min.js
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										16
									
								
								games/static/js/utils.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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
									
								
							
							
						
						
									
										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));
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										2
									
								
								games/static/robots.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								games/static/robots.txt
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,2 @@
 | 
			
		||||
User-agent: *
 | 
			
		||||
Disallow: /
 | 
			
		||||
@ -9,8 +9,9 @@
 | 
			
		||||
 | 
			
		||||
        {{ 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
									
								
							
							
						
						
									
										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 %}
 | 
			
		||||
							
								
								
									
										37
									
								
								games/templates/add_session.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								games/templates/add_session.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,37 @@
 | 
			
		||||
{% 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>
 | 
			
		||||
                    <button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
 | 
			
		||||
                </div>
 | 
			
		||||
            </td>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </tr>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
        <tr>
 | 
			
		||||
            <td></td>
 | 
			
		||||
            <td><input type="submit" value="Submit"/></td>
 | 
			
		||||
        </tr>
 | 
			
		||||
        </table>
 | 
			
		||||
    </form>
 | 
			
		||||
    {% load static %}
 | 
			
		||||
    <script type="module" src="{% static 'js/add_session.js' %}"></script>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
@ -9,11 +9,12 @@
 | 
			
		||||
        <meta name="keywords" content="time, tracking, video games, self-hosted"/>
 | 
			
		||||
        <meta name="viewport" content="width=device-width, initial-scale=1"/>
 | 
			
		||||
        <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
 | 
			
		||||
        <link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
 | 
			
		||||
        <script src="{% static 'js/htmx.min.js' %}"></script>
 | 
			
		||||
        <link rel="stylesheet" href="{% static 'base.css' %}" />
 | 
			
		||||
    </head>
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    <body class="dark">
 | 
			
		||||
        <img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
 | 
			
		||||
        <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">
 | 
			
		||||
@ -47,6 +48,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>
 | 
			
		||||
</html>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										22
									
								
								games/templates/components/button.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								games/templates/components/button.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,22 @@
 | 
			
		||||
<button
 | 
			
		||||
  type="button"
 | 
			
		||||
  title="{{ title }}"
 | 
			
		||||
  autofocus
 | 
			
		||||
  class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
 | 
			
		||||
>
 | 
			
		||||
  <svg
 | 
			
		||||
    xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
    fill="none"
 | 
			
		||||
    viewBox="0 0 24 24"
 | 
			
		||||
    stroke-width="1.5"
 | 
			
		||||
    stroke="currentColor"
 | 
			
		||||
    class="self-center w-6 h-6 inline"
 | 
			
		||||
  >
 | 
			
		||||
    <path
 | 
			
		||||
      stroke-linecap="round"
 | 
			
		||||
      stroke-linejoin="round"
 | 
			
		||||
      d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
 | 
			
		||||
    />
 | 
			
		||||
  </svg>
 | 
			
		||||
  {{ text }}
 | 
			
		||||
</button>
 | 
			
		||||
@ -5,121 +5,73 @@
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
        <div class="text-center text-xl mb-4 dark:text-slate-400">
 | 
			
		||||
            {% if dataset.count >= 2 %}
 | 
			
		||||
            <img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" />
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% if dataset.count >= 1 %}
 | 
			
		||||
            <div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
            {% 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>
 | 
			
		||||
                {% 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 %}
 | 
			
		||||
            {% if dataset.count >= 1 %}
 | 
			
		||||
            <a href="{% url 'start_session' last.id %}">
 | 
			
		||||
                <button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg ">
 | 
			
		||||
                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline">
 | 
			
		||||
                        <path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
 | 
			
		||||
                    </svg>
 | 
			
		||||
                    {{ last.purchase }}
 | 
			
		||||
                </button>
 | 
			
		||||
            </a>
 | 
			
		||||
            {% endif %}
 | 
			
		||||
        </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="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 %}
 | 
			
		||||
                    <span class="text-red-400">Not finished yet.</span>
 | 
			
		||||
                {% elif data.duration_manual %}
 | 
			
		||||
                    --
 | 
			
		||||
                {% else %}
 | 
			
		||||
                    {{ data.timestamp_end | date:"d/m/Y H:i" }}
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center">
 | 
			
		||||
                <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" />
 | 
			
		||||
              </svg>
 | 
			
		||||
              {% endif %}</div>
 | 
			
		||||
            <div id="button-container" class="flex justify-end">
 | 
			
		||||
                {% if data.unfinished %}
 | 
			
		||||
                    <a href="{% url 'update_session' data.id %}">
 | 
			
		||||
                        <button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center  bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
 | 
			
		||||
                                <path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
 | 
			
		||||
                            </svg>
 | 
			
		||||
                        </button>
 | 
			
		||||
                    </a>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
                <a href="{% url 'edit_session' data.id %}">
 | 
			
		||||
                    <button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center  bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
			
		||||
                            <path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" />
 | 
			
		||||
                            <path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" />
 | 
			
		||||
                        </svg>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </a>
 | 
			
		||||
                <a href="{% url 'delete_session' data.id %}">
 | 
			
		||||
                    <button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center  bg-red-600 hover:bg-red-700 focus:ring-red-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg ">
 | 
			
		||||
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
 | 
			
		||||
                            <path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
 | 
			
		||||
                        </svg>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </a>
 | 
			
		||||
            </div>
 | 
			
		||||
        {% endfor %}
 | 
			
		||||
    </div>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
{% if dataset.count >= 1 %}
 | 
			
		||||
<div class="mx-auto text-center my-4">
 | 
			
		||||
  <a
 | 
			
		||||
    id="last-session-start"
 | 
			
		||||
    href="{% url 'start_session' last.id %}"
 | 
			
		||||
    hx-get="{% url 'start_session' last.id %}"
 | 
			
		||||
    hx-indicator="#indicator"
 | 
			
		||||
    hx-swap="afterbegin"
 | 
			
		||||
    hx-target=".responsive-table tbody"
 | 
			
		||||
    hx-select=".responsive-table tbody tr:first-child"
 | 
			
		||||
    onClick="document.querySelector('#last-session-start').classList.add('invisible')"
 | 
			
		||||
    class="{% if last.timestamp_end == null %}invisible{% endif %}"
 | 
			
		||||
  >
 | 
			
		||||
    {% include 'components/button.html' with text=last.purchase title="Start session of last played game" only %}
 | 
			
		||||
  </a>
 | 
			
		||||
</div>
 | 
			
		||||
{% endif %}
 | 
			
		||||
 | 
			
		||||
<table class="responsive-table">
 | 
			
		||||
  <thead>
 | 
			
		||||
    <tr>
 | 
			
		||||
      <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
 | 
			
		||||
      <th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
 | 
			
		||||
      <th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
 | 
			
		||||
      <th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
 | 
			
		||||
    </tr>
 | 
			
		||||
  </thead>
 | 
			
		||||
  <tbody>
 | 
			
		||||
    {% for data in dataset %}
 | 
			
		||||
    <tr>
 | 
			
		||||
      <td
 | 
			
		||||
        class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
 | 
			
		||||
      >
 | 
			
		||||
        <a
 | 
			
		||||
          class="underline decoration-slate-500 sm:decoration-2"
 | 
			
		||||
          href="{% url 'view_game' data.purchase.edition.game.id %}">
 | 
			
		||||
            {{ data.purchase.edition }}
 | 
			
		||||
        </a>
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
 | 
			
		||||
        {{ data.timestamp_start | date:"d/m/Y H:i" }}
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
 | 
			
		||||
        {% if data.unfinished %}
 | 
			
		||||
        <a
 | 
			
		||||
          href="{% url 'update_session' data.id %}"
 | 
			
		||||
          hx-get="{% url 'update_session' data.id %}"
 | 
			
		||||
          hx-swap="outerHTML"
 | 
			
		||||
          hx-target=".responsive-table tbody tr:first-child"
 | 
			
		||||
          hx-select=".responsive-table tbody tr:first-child"
 | 
			
		||||
          hx-indicator="#indicator"
 | 
			
		||||
          onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
 | 
			
		||||
          >
 | 
			
		||||
            <span class="text-yellow-300">Finish now?</span>
 | 
			
		||||
        </a>
 | 
			
		||||
        {% elif data.duration_manual %}
 | 
			
		||||
            -- 
 | 
			
		||||
        {% else %}
 | 
			
		||||
            {{ data.timestamp_end | date:"d/m/Y H:i" }}
 | 
			
		||||
        {% endif %}
 | 
			
		||||
      </td>
 | 
			
		||||
      <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
 | 
			
		||||
        {{ data.duration_formatted }}
 | 
			
		||||
      </td>
 | 
			
		||||
    </tr>
 | 
			
		||||
    {% endfor %}
 | 
			
		||||
  </tbody>
 | 
			
		||||
</table>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										41
									
								
								games/templates/view_game.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								games/templates/view_game.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,41 @@
 | 
			
		||||
{% extends "base.html" %}
 | 
			
		||||
 | 
			
		||||
{% block title %}{{ title }}{% endblock title %}
 | 
			
		||||
 | 
			
		||||
{% load static %}
 | 
			
		||||
 | 
			
		||||
{% block content %}
 | 
			
		||||
    <div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
 | 
			
		||||
        <h1 class="text-4xl">{{ game.name }} (#{{ game.pk }})</h1>
 | 
			
		||||
        <h2 class="text-lg my-2 ml-2">{{ total_playtime }} ({{ first_session.timestamp_end | date:"M Y"}} — {{ first_session.timestamp_end | date:"M Y"}}) </h2>
 | 
			
		||||
        <hr class="border-slate-500">
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Editions</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for edition in editions %}
 | 
			
		||||
            <li class="sm:pl-2">
 | 
			
		||||
                {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
 | 
			
		||||
                {% if edition.wikidata %}
 | 
			
		||||
                <span class="hidden sm:inline">
 | 
			
		||||
                    <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
 | 
			
		||||
                        <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
 | 
			
		||||
                    </a>
 | 
			
		||||
                </span>
 | 
			
		||||
                {% endif %}
 | 
			
		||||
            </li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Purchases</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for purchase in purchases %}
 | 
			
		||||
            <li class="sm:pl-2">{{ purchase.platform }} ({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})</li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        <h1 class="text-3xl mt-4 mb-1">Sessions</h1>
 | 
			
		||||
        <ul>
 | 
			
		||||
            {% for session in sessions %}
 | 
			
		||||
            <li class="sm:pl-2">{{ session.timestamp_end | date:"d/m/Y" }} ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})</li>
 | 
			
		||||
            {% endfor %}
 | 
			
		||||
        </ul>
 | 
			
		||||
        
 | 
			
		||||
    </div>
 | 
			
		||||
{% endblock content %}
 | 
			
		||||
@ -23,15 +23,16 @@ urlpatterns = [
 | 
			
		||||
        views.start_session,
 | 
			
		||||
        name="start_session",
 | 
			
		||||
    ),
 | 
			
		||||
    path(
 | 
			
		||||
        "delete_session/by-id/<int:session_id>",
 | 
			
		||||
        views.delete_session,
 | 
			
		||||
        name="delete_session",
 | 
			
		||||
    ),
 | 
			
		||||
    # path(
 | 
			
		||||
    #     "delete_session/by-id/<int:session_id>",
 | 
			
		||||
    #     views.delete_session,
 | 
			
		||||
    #     name="delete_session",
 | 
			
		||||
    # ),
 | 
			
		||||
    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("game/<int:game_id>/view", views.view_game, name="view_game"),
 | 
			
		||||
    path("game/<int:game_id>/edit", 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"),
 | 
			
		||||
 | 
			
		||||
@ -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):
 | 
			
		||||
@ -64,7 +64,7 @@ def edit_session(request, session_id=None):
 | 
			
		||||
        return redirect("list_sessions")
 | 
			
		||||
    context["title"] = "Edit Session"
 | 
			
		||||
    context["form"] = form
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
    return render(request, "add_session.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_purchase(request, purchase_id=None):
 | 
			
		||||
@ -91,6 +91,20 @@ def edit_game(request, game_id=None):
 | 
			
		||||
    return render(request, "add.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def view_game(request, game_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    game = Game.objects.get(id=game_id)
 | 
			
		||||
    context["title"] = "View Game"
 | 
			
		||||
    context["game"] = game
 | 
			
		||||
    context["editions"] = Edition.objects.filter(game_id=game_id)
 | 
			
		||||
    context["purchases"] = Purchase.objects.filter(edition__game_id=game_id)
 | 
			
		||||
    context["sessions"] = Session.objects.filter(purchase__edition__game_id=game_id)
 | 
			
		||||
    context["total_playtime"] = context["sessions"].total_duration()
 | 
			
		||||
    context["last_session"] = context["sessions"].last
 | 
			
		||||
    context["first_session"] = context["sessions"].first
 | 
			
		||||
    return render(request, "view_game.html", context)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def edit_platform(request, platform_id=None):
 | 
			
		||||
    context = {}
 | 
			
		||||
    purchase = Platform.objects.get(id=platform_id)
 | 
			
		||||
@ -128,10 +142,10 @@ def start_session(request, last_session_id: int):
 | 
			
		||||
    return redirect("list_sessions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def delete_session(request, session_id=None):
 | 
			
		||||
    session = Session.objects.get(id=session_id)
 | 
			
		||||
    session.delete()
 | 
			
		||||
    return redirect("list_sessions")
 | 
			
		||||
# def delete_session(request, session_id=None):
 | 
			
		||||
#     session = Session.objects.get(id=session_id)
 | 
			
		||||
#     session.delete()
 | 
			
		||||
#     return redirect("list_sessions")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def list_sessions(
 | 
			
		||||
@ -162,10 +176,12 @@ def list_sessions(
 | 
			
		||||
        dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
 | 
			
		||||
        context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
 | 
			
		||||
    elif filter == "recent":
 | 
			
		||||
        current_year = 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")
 | 
			
		||||
@ -179,8 +195,9 @@ def list_sessions(
 | 
			
		||||
    context["dataset"] = dataset
 | 
			
		||||
    # cannot use dataset[0] here because that might be only partial QuerySet
 | 
			
		||||
    context["last"] = Session.objects.all().order_by("timestamp_start").last()
 | 
			
		||||
    # charts are always oldest->newest
 | 
			
		||||
    if dataset.count() >= 2:
 | 
			
		||||
    # only if 2 or more non-manual sessions exist
 | 
			
		||||
    if dataset.filter(timestamp_end__isnull=False).count() >= 2:
 | 
			
		||||
        # charts are always oldest->newest
 | 
			
		||||
        context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
 | 
			
		||||
 | 
			
		||||
    return render(request, "list_sessions.html", context)
 | 
			
		||||
@ -221,7 +238,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,7 +1,7 @@
 | 
			
		||||
{
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@tailwindcss/forms": "^0.5.3",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.8",
 | 
			
		||||
    "tailwindcss": "^3.2.4"
 | 
			
		||||
    "@tailwindcss/forms": "^0.5.6",
 | 
			
		||||
    "@tailwindcss/typography": "^0.5.10",
 | 
			
		||||
    "tailwindcss": "^3.3.3"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,14 +1,19 @@
 | 
			
		||||
const defaultTheme = require('tailwindcss/defaultTheme')
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    darkMode: 'class',
 | 
			
		||||
    content: ["./**/*.{html,js}"],
 | 
			
		||||
    content: ["./games/**/*.{html,js}"],
 | 
			
		||||
    theme: {
 | 
			
		||||
        fontFamily: {
 | 
			
		||||
            sans: ['Inter', 'sans-serif'],
 | 
			
		||||
        extend: {
 | 
			
		||||
          fontFamily: {
 | 
			
		||||
            'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
 | 
			
		||||
            'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
 | 
			
		||||
            'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        extend: {},
 | 
			
		||||
    },
 | 
			
		||||
    plugins: [
 | 
			
		||||
        require('@tailwindcss/typography'),
 | 
			
		||||
        require('@tailwindcss/forms')
 | 
			
		||||
    ],
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -40,9 +40,9 @@ INSTALLED_APPS = [
 | 
			
		||||
    "django.contrib.staticfiles",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if DEBUG:
 | 
			
		||||
    INSTALLED_APPS.append("django_extensions")
 | 
			
		||||
    INSTALLED_APPS.append("django.contrib.admin")
 | 
			
		||||
# if DEBUG:
 | 
			
		||||
INSTALLED_APPS.append("django_extensions")
 | 
			
		||||
INSTALLED_APPS.append("django.contrib.admin")
 | 
			
		||||
 | 
			
		||||
MIDDLEWARE = [
 | 
			
		||||
    "django.middleware.security.SecurityMiddleware",
 | 
			
		||||
@ -149,3 +149,5 @@ if _csrf_trusted_origins:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
 | 
			
		||||
else:
 | 
			
		||||
    CSRF_TRUSTED_ORIGINS = []
 | 
			
		||||
 | 
			
		||||
USE_L10N = False
 | 
			
		||||
 | 
			
		||||
@ -23,5 +23,6 @@ urlpatterns = [
 | 
			
		||||
    path("tracker/", include("games.urls")),
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
if settings.DEBUG:
 | 
			
		||||
    urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
# if settings.DEBUG:
 | 
			
		||||
#     urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
urlpatterns.append(path("admin/", admin.site.urls))
 | 
			
		||||
 | 
			
		||||
		Reference in New Issue
	
	Block a user