Compare commits
58 Commits
1.0.0
...
49cc3ea0cc
Author | SHA1 | Date | |
---|---|---|---|
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
|
|||
2640a49734
|
|||
65c175afb2
|
|||
0814071a26
|
|||
5f845f866e
|
|||
c3d4697470
|
|||
77293f03e9 | |||
1fa364e2ec | |||
4a6f4a2f9a | |||
9590988b6a | |||
938c82a395 | |||
33939f631c | |||
ac8cd6534a | |||
51d8e953c0 | |||
2eec677f41 | |||
f2eb14d3ef | |||
c337d2200f | |||
8a8b05b0bd | |||
9446065271 | |||
755093845d | |||
d4ab0596da | |||
8dcbe2f0ad | |||
25bc74eff1 | |||
8a7d083fb2 | |||
8296ebcf31
|
|||
04a4f2e0be | |||
4070b4e46e | |||
4892218c83 | |||
6b00a950ce | |||
feee9d6dac | |||
9654fb017d
|
|||
1741397ee7 | |||
da0c8d710b |
23
.drone.yml
@ -11,15 +11,30 @@ steps:
|
||||
- poetry install
|
||||
- poetry env info
|
||||
- poetry run pytest
|
||||
- name: build container
|
||||
- name: build container (prod)
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.kucharczyk.xyz/timetracker
|
||||
tags:
|
||||
- latest
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
|
||||
- name: build container (non-prod)
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.kucharczyk.xyz/timetracker
|
||||
tags:
|
||||
- ${DRONE_COMMIT_REF}
|
||||
- ${DRONE_COMMIT_BRANCH}
|
||||
when:
|
||||
branch:
|
||||
exclude:
|
||||
- main
|
||||
|
||||
|
||||
trigger:
|
||||
event:
|
||||
- push
|
||||
- cron
|
||||
exclude:
|
||||
- pull_request
|
||||
- cron
|
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
@ -5,4 +5,5 @@ __pycache__
|
||||
node_modules
|
||||
package-lock.json
|
||||
db.sqlite3
|
||||
static
|
||||
/static/
|
||||
dist/
|
34
CHANGELOG.md
@ -1,3 +1,37 @@
|
||||
## 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 font files
|
||||
|
||||
## 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)
|
||||
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
|
||||
* Add support for purchase prices
|
||||
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
|
||||
|
||||
## 1.0.1 / 2023-01-30 22:17+01:00
|
||||
|
||||
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
|
||||
* Show markers on smaller graphs to make it clearer which dates the session belong to
|
||||
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
|
||||
|
||||
## 1.0.0 / 2023-01-20 19:54+01:00
|
||||
|
||||
* Breaking
|
||||
|
@ -6,7 +6,7 @@ RUN npm install && \
|
||||
|
||||
FROM python:3.10.9-slim-bullseye
|
||||
|
||||
ENV VERSION_NUMBER 1.0.0
|
||||
ENV VERSION_NUMBER 1.0.3
|
||||
ENV PROD 1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
@ -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" ]
|
||||
|
6
Makefile
@ -7,11 +7,11 @@ HTMLFILES := $(shell find games/templates -type f)
|
||||
npm:
|
||||
npm install
|
||||
|
||||
css: input.css
|
||||
npx tailwindcss -i ./input.css -o ./games/static/base.css
|
||||
css: common/input.css
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
|
||||
|
||||
css-dev: css
|
||||
npx tailwindcss -i ./input.css -o ./games/static/base.css --watch
|
||||
npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
|
||||
|
||||
makemigrations:
|
||||
poetry run python manage.py makemigrations
|
||||
|
@ -12,11 +12,60 @@ textarea {
|
||||
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
|
||||
}
|
||||
|
||||
#session-table {
|
||||
@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,
|
||||
#session-table-head {
|
||||
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;
|
||||
}
|
||||
|
@ -64,7 +64,7 @@ def get_chart(data, title="", xlabel="", ylabel=""):
|
||||
plt.switch_backend("SVG")
|
||||
fig, ax = plt.subplots()
|
||||
fig.set_size_inches(10, 4)
|
||||
ax.plot(x, y)
|
||||
lines = ax.plot(x, y, "-o")
|
||||
first = x[0]
|
||||
last = x[-1]
|
||||
difference = last - first
|
||||
@ -76,7 +76,11 @@ def get_chart(data, title="", xlabel="", ylabel=""):
|
||||
elif difference.days < 720:
|
||||
ax.xaxis.set_major_locator(mdates.MonthLocator())
|
||||
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
|
||||
for line in lines:
|
||||
line.set_marker("")
|
||||
else:
|
||||
for line in lines:
|
||||
line.set_marker("")
|
||||
ax.xaxis.set_major_locator(mdates.YearLocator())
|
||||
ax.xaxis.set_minor_locator(mdates.MonthLocator())
|
||||
|
||||
|
@ -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)
|
||||
|
@ -1,38 +1,95 @@
|
||||
from django import forms
|
||||
|
||||
from games.models import Game, Platform, Purchase, Session
|
||||
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.order_by("game__name"))
|
||||
# purchase = forms.ModelChoiceField(
|
||||
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
|
||||
# )
|
||||
purchase = forms.ModelChoiceField(
|
||||
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",
|
||||
"timestamp_start",
|
||||
"timestamp_end",
|
||||
"duration_manual",
|
||||
"device",
|
||||
"note",
|
||||
]
|
||||
|
||||
|
||||
class EditionChoiceField(forms.ModelChoiceField):
|
||||
def label_from_instance(self, obj) -> str:
|
||||
return f"{obj.name} ({obj.platform}, {obj.year_released})"
|
||||
|
||||
|
||||
class PurchaseForm(forms.ModelForm):
|
||||
game = forms.ModelChoiceField(queryset=Game.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 = ["game", "platform", "date_purchased", "date_refunded"]
|
||||
fields = [
|
||||
"edition",
|
||||
"platform",
|
||||
"date_purchased",
|
||||
"date_refunded",
|
||||
"price",
|
||||
"price_currency",
|
||||
"ownership_type",
|
||||
]
|
||||
|
||||
|
||||
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:
|
||||
model = Edition
|
||||
fields = ["game", "name", "platform", "year_released", "wikidata"]
|
||||
|
||||
|
||||
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}
|
||||
|
41
games/migrations/0008_edition.py
Normal file
@ -0,0 +1,41 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 16:29
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Edition",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"game",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.game"
|
||||
),
|
||||
),
|
||||
(
|
||||
"platform",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
34
games/migrations/0009_create_editions.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 18:51
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def create_edition_of_game(apps, schema_editor):
|
||||
Game = apps.get_model("games", "Game")
|
||||
Edition = apps.get_model("games", "Edition")
|
||||
Platform = apps.get_model("games", "Platform")
|
||||
first_platform = Platform.objects.first()
|
||||
all_games = Game.objects.all()
|
||||
all_editions = Edition.objects.all()
|
||||
for game in all_games:
|
||||
existing_edition = None
|
||||
try:
|
||||
existing_edition = all_editions.objects.get(game=game.id)
|
||||
except:
|
||||
pass
|
||||
if existing_edition == None:
|
||||
edition = Edition()
|
||||
edition.id = game.id
|
||||
edition.game = game
|
||||
edition.name = game.name
|
||||
edition.platform = first_platform
|
||||
edition.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0008_edition"),
|
||||
]
|
||||
|
||||
operations = [migrations.RunPython(create_edition_of_game)]
|
21
games/migrations/0010_alter_purchase_game.py
Normal file
@ -0,0 +1,21 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:06
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0009_create_editions"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="purchase",
|
||||
name="game",
|
||||
field=models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
|
||||
),
|
||||
),
|
||||
]
|
18
games/migrations/0011_rename_game_purchase_edition.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:18
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0010_alter_purchase_game"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="purchase",
|
||||
old_name="game",
|
||||
new_name="edition",
|
||||
),
|
||||
]
|
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0011_rename_game_purchase_edition"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price",
|
||||
field=models.IntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="price_currency",
|
||||
field=models.CharField(default="USD", max_length=3),
|
||||
),
|
||||
]
|
31
games/migrations/0013_purchase_ownership_type.py
Normal file
@ -0,0 +1,31 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0012_purchase_price_purchase_price_currency"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="purchase",
|
||||
name="ownership_type",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("ph", "Physical"),
|
||||
("di", "Digital"),
|
||||
("du", "Digital Upgrade"),
|
||||
("re", "Rented"),
|
||||
("bo", "Borrowed"),
|
||||
("tr", "Trial"),
|
||||
("de", "Demo"),
|
||||
("pi", "Pirated"),
|
||||
],
|
||||
default="di",
|
||||
max_length=2,
|
||||
),
|
||||
),
|
||||
]
|
52
games/migrations/0014_device_session_device.py
Normal file
@ -0,0 +1,52 @@
|
||||
# Generated by Django 4.1.5 on 2023-02-18 19:59
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("games", "0013_purchase_ownership_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Device",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=255)),
|
||||
(
|
||||
"type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("pc", "PC"),
|
||||
("co", "Console"),
|
||||
("ha", "Handheld"),
|
||||
("mo", "Mobile"),
|
||||
("sbc", "Single-board computer"),
|
||||
],
|
||||
default="pc",
|
||||
max_length=3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="session",
|
||||
name="device",
|
||||
field=models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="games.device",
|
||||
),
|
||||
),
|
||||
]
|
@ -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),
|
||||
),
|
||||
]
|
@ -16,14 +16,52 @@ class Game(models.Model):
|
||||
return self.name
|
||||
|
||||
|
||||
class Purchase(models.Model):
|
||||
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
|
||||
|
||||
|
||||
class Purchase(models.Model):
|
||||
PHYSICAL = "ph"
|
||||
DIGITAL = "di"
|
||||
DIGITALUPGRADE = "du"
|
||||
RENTED = "re"
|
||||
BORROWED = "bo"
|
||||
TRIAL = "tr"
|
||||
DEMO = "de"
|
||||
PIRATED = "pi"
|
||||
OWNERSHIP_TYPES = [
|
||||
(PHYSICAL, "Physical"),
|
||||
(DIGITAL, "Digital"),
|
||||
(DIGITALUPGRADE, "Digital Upgrade"),
|
||||
(RENTED, "Rented"),
|
||||
(BORROWED, "Borrowed"),
|
||||
(TRIAL, "Trial"),
|
||||
(DEMO, "Demo"),
|
||||
(PIRATED, "Pirated"),
|
||||
]
|
||||
|
||||
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
|
||||
platform = models.ForeignKey("Platform", on_delete=models.CASCADE)
|
||||
date_purchased = models.DateField()
|
||||
date_refunded = models.DateField(blank=True, null=True)
|
||||
price = models.IntegerField(default=0)
|
||||
price_currency = models.CharField(max_length=3, default="USD")
|
||||
ownership_type = models.CharField(
|
||||
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.game} ({self.platform})"
|
||||
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):
|
||||
@ -48,6 +86,7 @@ class Session(models.Model):
|
||||
timestamp_end = models.DateTimeField(blank=True, null=True)
|
||||
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
|
||||
duration_calculated = models.DurationField(blank=True, null=True)
|
||||
device = models.ForeignKey("Device", on_delete=models.CASCADE, null=True)
|
||||
note = models.TextField(blank=True, null=True)
|
||||
|
||||
objects = SessionQuerySet.as_manager()
|
||||
@ -85,3 +124,23 @@ class Session(models.Model):
|
||||
else:
|
||||
self.duration_calculated = timedelta(0)
|
||||
super(Session, self).save(*args, **kwargs)
|
||||
|
||||
|
||||
class Device(models.Model):
|
||||
PC = "pc"
|
||||
CONSOLE = "co"
|
||||
HANDHELD = "ha"
|
||||
MOBILE = "mo"
|
||||
SBC = "sbc"
|
||||
DEVICE_TYPES = [
|
||||
(PC, "PC"),
|
||||
(CONSOLE, "Console"),
|
||||
(HANDHELD, "Handheld"),
|
||||
(MOBILE, "Mobile"),
|
||||
(SBC, "Single-board computer"),
|
||||
]
|
||||
name = models.CharField(max_length=255)
|
||||
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=PC)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.get_type_display()})"
|
||||
|
BIN
games/static/fonts/IBMPlexMono-Regular.ttf
Normal file
BIN
games/static/fonts/IBMPlexSans-Regular.ttf
Normal file
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");
|
||||
});
|
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
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));
|
||||
});
|
2
games/static/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
@ -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 %}
|
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,16 +9,16 @@
|
||||
<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">
|
||||
<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 'index' %}" class="flex items-center">
|
||||
<span class="text-4xl">⌚</span>
|
||||
<a href="{% url 'list_sessions_recent' %}" class="flex items-center">
|
||||
<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">
|
||||
@ -27,10 +27,14 @@
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
|
||||
{% if game_available and platform_available %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_edition' %}">New Edition</a></li>
|
||||
{% endif %}
|
||||
{% if edition_available %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
|
||||
{% endif %}
|
||||
{% if purchase_available %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_device' %}">New Device</a></li>
|
||||
{% endif %}
|
||||
{% if session_count > 0 %}
|
||||
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
|
||||
@ -43,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>
|
||||
</html>
|
||||
|
@ -1,81 +1,30 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Sessions{% endblock title %}
|
||||
{% load static %}
|
||||
|
||||
{% 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 game %}
|
||||
<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><span>Filtering by "{% firstof purchase platform game %}"</span>
|
||||
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %}
|
||||
{% endif %}
|
||||
{% if dataset.count >= 1 %}
|
||||
<a class="clear-both" href="{% url 'start_session' last.purchase.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-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 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>
|
||||
|
||||
|
||||
<table class="responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="hide-on-small">Start</th>
|
||||
<th class="hide-on-small">End</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% 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.game }}</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="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 %}
|
||||
<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 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>
|
||||
<tr>
|
||||
<td class="purchase-name">{{ data.purchase.edition }}</td>
|
||||
<td class="hide-on-small font-mono">{{ data.timestamp_start | date:"d/m/Y H:i" }}</td>
|
||||
<td class="hide-on-small font-mono">{{ data.timestamp_end | date:"d/m/Y H:i" }}</td>
|
||||
<td class="font-mono">{{ data.duration_formatted }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock content %}
|
||||
|
@ -4,6 +4,12 @@ from games import views
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.index, name="index"),
|
||||
path(
|
||||
"list-sessions/recent",
|
||||
views.list_sessions,
|
||||
{"filter": "recent"},
|
||||
name="list_sessions_recent",
|
||||
),
|
||||
path("add-game/", views.add_game, name="add_game"),
|
||||
path("add-platform/", views.add_platform, name="add_platform"),
|
||||
path("add-session/", views.add_session, name="add_session"),
|
||||
@ -13,16 +19,23 @@ urlpatterns = [
|
||||
name="update_session",
|
||||
),
|
||||
path(
|
||||
"start-session/<int:purchase_id>",
|
||||
"start-session/<int:last_session_id>",
|
||||
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("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"),
|
||||
path("list-sessions/", views.list_sessions, name="list_sessions"),
|
||||
path(
|
||||
"list-sessions/by-purchase/<int:purchase_id>",
|
||||
@ -42,4 +55,16 @@ urlpatterns = [
|
||||
{"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",
|
||||
),
|
||||
]
|
||||
|
155
games/views.py
@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from common.plots import playtime_over_time_chart
|
||||
@ -6,13 +6,21 @@ from common.time import now as now_with_tz
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm
|
||||
from .models import Game, Platform, Purchase, Session
|
||||
from .forms import (
|
||||
GameForm,
|
||||
PlatformForm,
|
||||
PurchaseForm,
|
||||
SessionForm,
|
||||
EditionForm,
|
||||
DeviceForm,
|
||||
)
|
||||
from .models import Game, Platform, Purchase, Session, Edition
|
||||
|
||||
|
||||
def model_counts(request):
|
||||
return {
|
||||
"game_available": Game.objects.count() != 0,
|
||||
"edition_available": Edition.objects.count() != 0,
|
||||
"platform_available": Platform.objects.count() != 0,
|
||||
"purchase_available": Purchase.objects.count() != 0,
|
||||
"session_count": Session.objects.count(),
|
||||
@ -37,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):
|
||||
@ -47,20 +55,96 @@ def update_session(request, session_id=None):
|
||||
return redirect("list_sessions")
|
||||
|
||||
|
||||
def start_session(request, purchase_id=None):
|
||||
session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()})
|
||||
def edit_session(request, session_id=None):
|
||||
context = {}
|
||||
session = Session.objects.get(id=session_id)
|
||||
form = SessionForm(request.POST or None, instance=session)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("list_sessions")
|
||||
context["title"] = "Edit Session"
|
||||
context["form"] = form
|
||||
return render(request, "add_session.html", context)
|
||||
|
||||
|
||||
def edit_purchase(request, purchase_id=None):
|
||||
context = {}
|
||||
purchase = Purchase.objects.get(id=purchase_id)
|
||||
form = PurchaseForm(request.POST or None, instance=purchase)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("list_sessions")
|
||||
context["title"] = "Edit Purchase"
|
||||
context["form"] = form
|
||||
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)
|
||||
form = EditionForm(request.POST or None, instance=edition)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("list_sessions")
|
||||
context["title"] = "Edit Edition"
|
||||
context["form"] = form
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def start_session(request, last_session_id: int):
|
||||
last_session = Session.objects.get(id=last_session_id)
|
||||
session = SessionForm(
|
||||
{
|
||||
"purchase": last_session.purchase.id,
|
||||
"timestamp_start": now_with_tz(),
|
||||
"device": last_session.device,
|
||||
}
|
||||
)
|
||||
session.save()
|
||||
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(request, filter="", purchase_id="", platform_id="", game_id=""):
|
||||
def list_sessions(
|
||||
request,
|
||||
filter="",
|
||||
purchase_id="",
|
||||
platform_id="",
|
||||
game_id="",
|
||||
edition_id="",
|
||||
ownership_type: str = "",
|
||||
):
|
||||
context = {}
|
||||
context["title"] = "Sessions"
|
||||
|
||||
if filter == "purchase":
|
||||
dataset = Session.objects.filter(purchase=purchase_id)
|
||||
@ -68,9 +152,22 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
|
||||
elif filter == "platform":
|
||||
dataset = Session.objects.filter(purchase__platform=platform_id)
|
||||
context["platform"] = Platform.objects.get(id=platform_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__game=game_id)
|
||||
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=first_day_of_year
|
||||
).order_by("-timestamp_start")
|
||||
context["title"] = "This year"
|
||||
else:
|
||||
# by default, sort from newest to oldest
|
||||
dataset = Session.objects.all().order_by("-timestamp_start")
|
||||
@ -84,8 +181,9 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
|
||||
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 Session.objects.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)
|
||||
@ -117,6 +215,18 @@ def add_game(request):
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def add_edition(request):
|
||||
context = {}
|
||||
form = EditionForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("index")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Edition"
|
||||
return render(request, "add_edition.html", context)
|
||||
|
||||
|
||||
def add_platform(request):
|
||||
context = {}
|
||||
form = PlatformForm(request.POST or None)
|
||||
@ -129,8 +239,17 @@ def add_platform(request):
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def index(request):
|
||||
def add_device(request):
|
||||
context = {}
|
||||
context["total_duration"] = Session().duration_sum
|
||||
context["title"] = "Index"
|
||||
return render(request, "index.html", context)
|
||||
form = DeviceForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect("index")
|
||||
|
||||
context["form"] = form
|
||||
context["title"] = "Add New Device"
|
||||
return render(request, "add.html", context)
|
||||
|
||||
|
||||
def index(request):
|
||||
return redirect("list_sessions_recent")
|
||||
|
@ -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,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "timetracker"
|
||||
version = "1.0.0"
|
||||
version = "1.0.3"
|
||||
description = "A simple time tracker."
|
||||
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
|
||||
license = "GPL"
|
||||
|
@ -1,14 +1,18 @@
|
||||
const defaultTheme = require('tailwindcss/defaultTheme')
|
||||
|
||||
module.exports = {
|
||||
darkMode: 'class',
|
||||
content: ["./**/*.{html,js}"],
|
||||
theme: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
extend: {
|
||||
fontFamily: {
|
||||
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
|
||||
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
|
||||
}
|
||||
},
|
||||
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
|
||||
|
@ -19,9 +19,10 @@ from django.urls import include, path
|
||||
from django.views.generic import RedirectView
|
||||
|
||||
urlpatterns = [
|
||||
path("", RedirectView.as_view(url="/tracker/list-sessions")),
|
||||
path("", RedirectView.as_view(url="/tracker")),
|
||||
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))
|
||||
|