22 Commits

Author SHA1 Message Date
da9be74050 Merge pull request 'v0.1.4: minor fix' (#6) from fix/5-edit-orphan-project into dev
Reviewed-on: #6
2025-04-23 21:59:32 +00:00
7d3f13657f bumped to version 0.1.4 2025-04-23 22:39:58 +02:00
0be774531c made project parent blank 2025-04-23 22:10:46 +02:00
756b51309a bumped to version 0.1.3 2025-03-02 16:09:09 +01:00
cef66f2bd4 made parent empty lines hidden 2025-03-02 16:08:14 +01:00
2aa80e094a fixed daily totals only for productive parents 2025-03-02 14:46:24 +01:00
57231c0d34 Merge pull request 'minor ui improvements' (#2) from feat/ui-improvements-1 into dev
Reviewed-on: #2
2025-03-01 13:23:52 +00:00
842d0ff11e bumped version to 0.1.2 2025-03-01 14:21:22 +01:00
87005efcf5 added weekly total + highlight clocking inconsistencies 2025-03-01 14:20:16 +01:00
7f845dcb1d made empty project lines hidden 2025-03-01 13:40:24 +01:00
9e2566ba03 updated app version to 0.1.1 2025-03-01 13:13:02 +01:00
af938f405d Merge pull request 'fixed minor issue with remote duration conversion' (#1) from dev into main
Reviewed-on: #1
2025-02-23 20:47:53 +00:00
c090b611e2 fixed minor issue with remote duration conversion 2025-02-23 21:45:51 +01:00
9d33377ac1 added README.md + main.py 2025-02-15 12:20:52 +01:00
72ad6ec081 added 404 page + prepared for production setup 2025-02-14 16:11:03 +01:00
4f5f12473d completely removed weekly stats + added .gitignore 2025-02-14 15:05:09 +01:00
cce82f02af removed weekly stats 2025-02-14 15:02:16 +01:00
58a8ae750a fixed duration conversion 2025-02-12 13:53:17 +01:00
b24cb55ba8 added theory/real SageX hours difference row 2025-02-04 17:06:34 +01:00
eb0cab6295 added real sagex hours + changed to DurationField 2025-02-04 00:19:05 +01:00
6777207f4e reworked dashboard with table summary 2025-02-03 12:06:59 +01:00
aeed1f5fbe added new project form 2025-02-02 23:19:18 +01:00
28 changed files with 758 additions and 163 deletions

3
.env.template Normal file
View File

@ -0,0 +1,3 @@
DJANGO_SECRET_KEY=
DJANGO_ENV=prod
DJANGO_HOSTS=localhost,127.0.0.1

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
__pycache__
/db.sqlite3
/.env

87
README.md Normal file
View File

@ -0,0 +1,87 @@
# Time Dispatcher
### Table of contents
<!-- TOC -->
* [Prerequisites](#prerequisites)
* [Installation](#installation)
* [Run the app](#run-the-app)
* [Production](#production)
* [Development](#development)
* [Updating](#updating)
<!-- TOC -->
---
**Time Dispatcher** is a QoL tool to help you track, analyze and allocate your time spent on various projects.
Here is a list of the main features available :
- Import tasks, projects and time imputations from CSV (compatible with [Super Productivity](https://github.com/johannesjo/super-productivity))
- Create parents to group multiple projects
- Track your clock-ins / -outs and/or remote work
- Mark each parent as either productive or not, i.e. whether its projects take a share of the "clocked" time
- Automatically compute the time that should be reported as spent on each productive parent (for example to input in SageX)
- View your monthly working times in a nice table or in a summarized form on the dashboard
## Prerequisites
- Python 3+
## Installation
1. Clone the repository
```shell
git clone https://git.kb28.ch/HEL/TimeDispatcher.git
```
2. Go inside the project directory
```shell
cd TimeDispatcher/
```
3. Install the Python requirements
```shell
pip install -r requirements.txt
```
4. Apply the database migrations
```shell
python manage.py migrate
```
5. Set the appropriate settings in the `.env` file:
1. Copy `.env.template` to `.env`
```shell
cp .env.template .env
```
2. Fill in the settings
- `DJANGO_SECRET_KEY`: The secret key used by Django.
This is a long random string.
For example, it can be generated using Python with
```python
import secrets
secrets.token_urlsafe(64)
```
- `DJANGO_ENV`: Current environment, either `prod` or `dev`
- `DJANGO_HOSTS`: Comma-separated list of allowed hosts used by Django
## Run the app
### Production
1. Start the Uvicorn server
```shell
python main.py
```
2. Open [the app](http://localhost:8000/) in your browser
### Development
1. Start the Django server
```shell
python manage.py runserver
```
2. Open [the app](http://localhost:8000/) in your browser
## Updating
(Before updating to a new version, it is recommended to back up the database file, i.e. `db.sqlite3`)
To update to a new version:
1. Download the new sources
- from the [release page](https://git.kb28.ch/HEL/TimeDispatcher/releases)
- or pull the new version with git
2. Run the database migrations:
```shell
python manage.py migrate
```

View File

@ -2,8 +2,18 @@ from datetime import datetime
class DateConverter:
regex = '\d{4}-\d{1,2}-\d{1,2}'
format = '%Y-%m-%d'
regex = r"\d{4}-\d{1,2}-\d{1,2}"
format = r"%Y-%m-%d"
def to_python(self, value):
return datetime.strptime(value, self.format).date()
def to_url(self, value):
return value.strftime(self.format)
class YearMonthConverter:
regex = r"\d{4}-\d{1,2}"
format = r"%Y-%m"
def to_python(self, value):
return datetime.strptime(value, self.format).date()

View File

@ -9,25 +9,28 @@ https://docs.djangoproject.com/en/5.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.0.1"
APP_VERSION = "0.1.4"
load_dotenv(BASE_DIR / ".env")
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-^*x5i_#=$9kuj6k^v0cy5dqefmzo%j*i&0w93i%!zmgsa_z)2z'
SECRET_KEY = os.environ["DJANGO_SECRET_KEY"]
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
DEBUG = os.environ.get("DJANGO_ENV", "dev").lower() != "prod"
ALLOWED_HOSTS = ["localhost", "192.168.2.68"]
ALLOWED_HOSTS = list(map(lambda h: h.strip(), os.environ.get("DJANGO_HOSTS", "localhost,127.0.0.1").split(",")))
# Application definition
@ -57,8 +60,7 @@ ROOT_URLCONF = 'TimeDispatcher.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates']
,
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -111,7 +113,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = 'Europe/Zurich'
USE_I18N = True
@ -121,7 +123,12 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/
STATICFILES_DIRS = [
BASE_DIR / "dispatcher" / "static"
]
STATIC_URL = 'static/'
_STATIC_ROOT = BASE_DIR / "dispatcher" / "static"
# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

View File

@ -14,13 +14,15 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, register_converter
from django.urls import path, register_converter, re_path
from django.views.static import serve
from TimeDispatcher.converters import DateConverter
from TimeDispatcher import settings
from TimeDispatcher.converters import DateConverter, YearMonthConverter
from dispatcher import views
register_converter(DateConverter, "date")
register_converter(YearMonthConverter, "year_month")
urlpatterns = [
path("", views.dashboard_view, name="dashboard"),
@ -32,10 +34,14 @@ urlpatterns = [
path("parents/new/", views.new_parent_view, name="new_parent"),
path("parents/<int:id>/on_delete/", views.parent_on_delete_view, name="parent_on_delete"),
path("parents/<int:id>/", views.parent_view, name="parent"),
path("projects/new/", views.new_project_view, name="new_project"),
path("projects/<int:id>/on_delete/", views.project_on_delete_view, name="project_on_delete"),
path("projects/<int:id>/set_parent/", views.set_parent, name="set_parent"),
path("projects/<int:id>/", views.project_view, name="project"),
path("stats/by-month/<int:year>/<int:month>/", views.get_stats_by_month, name="stats_by_month"),
path("stats/between/<date:start_date>/<date:end_date>/", views.get_stats_between, name="stats_between"),
path("clockings/<date:date>/", views.set_clocking, name="set_clocking")
path("clockings/<date:date>/", views.set_clocking, name="set_clocking"),
path("sagex/<int:id>/<year_month:month>/", views.set_real_sagex, name="set_real_sagex"),
]
if not settings.DEBUG:
urlpatterns.append(re_path(r"^static/(?P<path>.*)$", serve, {"document_root": settings._STATIC_ROOT}))

View File

@ -1,8 +1,9 @@
from TimeDispatcher.settings import APP_VERSION
from TimeDispatcher.settings import APP_VERSION, DEBUG
def version(request):
return {
"debug": DEBUG,
"version": APP_VERSION
}

View File

@ -1,6 +1,22 @@
import datetime
from datetime import timedelta
from dispatcher.models import Task, Project
def convert_timedelta(td: datetime.timedelta):
total_seconds = td.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
def str_to_timedelta(duration: str):
parts = duration.split(":")
hours = int(parts[0])
minutes = int(parts[1])
return timedelta(hours=hours, minutes=minutes)
def import_tasks(csv_content: str):
tasks = []
for line in csv_content.splitlines()[1:]:

View File

@ -0,0 +1,66 @@
# Generated by Django 5.1.5 on 2025-02-03 17:53
import datetime
from datetime import timedelta
import django.db.models.deletion
from django.db import migrations, models
def time_to_duration(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.old_remote:
remote: datetime.time = clocking.old_remote
clocking.remote = timedelta(hours=remote.hour, minutes=remote.minute)
else:
clocking.remote = timedelta()
clocking.save()
def duration_to_time(apps, schema_editor):
Clocking = apps.get_model("dispatcher", "Clocking")
for clocking in Clocking.objects.all().iterator():
if clocking.remote.total_seconds() != 0:
remote: datetime.timedelta = clocking.remote
total_seconds = remote.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
clocking.old_remote = datetime.time(int(hours), int(minutes), int(seconds))
else:
clocking.old_remote = None
clocking.save()
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0008_alter_clocking_in_am_alter_clocking_in_pm_and_more'),
]
operations = [
migrations.RenameField(
model_name='clocking',
old_name='remote',
new_name='old_remote'
),
migrations.AddField(
model_name='clocking',
name='remote',
field=models.DurationField(default=datetime.timedelta),
),
migrations.RunPython(time_to_duration, duration_to_time),
migrations.RemoveField(
model_name='clocking',
name='old_remote'
),
migrations.CreateModel(
name='RealSageXHours',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('hours', models.DurationField(default=datetime.timedelta)),
('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='dispatcher.parent')),
],
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.1.5 on 2025-02-03 18:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0009_alter_clocking_remote_realsagexhours'),
]
operations = [
migrations.AddConstraint(
model_name='realsagexhours',
constraint=models.UniqueConstraint(fields=('parent', 'date'), name='unique_monthly_sagex'),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 5.1.5 on 2025-04-23 20:09
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0010_realsagexhours_unique_monthly_sagex'),
]
operations = [
migrations.AlterField(
model_name='project',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dispatcher.parent'),
),
]

View File

@ -1,3 +1,5 @@
from datetime import timedelta
from django.db import models
class Parent(models.Model):
@ -13,7 +15,8 @@ class Project(models.Model):
parent = models.ForeignKey(
Parent,
on_delete=models.CASCADE,
null=True
null=True,
blank=True
)
name = models.CharField(max_length=256)
@ -39,4 +42,15 @@ class Clocking(models.Model):
out_am = models.TimeField(null=True, default=None, verbose_name="Clock out AM")
in_pm = models.TimeField(null=True, default=None, verbose_name="Clock in PM")
out_pm = models.TimeField(null=True, default=None, verbose_name="Clock out PM")
remote = models.TimeField(null=True, default=None)
remote = models.DurationField(default=timedelta)
class RealSageXHours(models.Model):
parent = models.ForeignKey(Parent, on_delete=models.CASCADE)
date = models.DateField()
hours = models.DurationField(default=timedelta)
class Meta:
constraints = [
models.UniqueConstraint(fields=["parent", "date"], name="unique_monthly_sagex")
]

View File

@ -3,7 +3,8 @@ from datetime import datetime, timedelta, time
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from dispatcher.models import Clocking
from dispatcher.core import convert_timedelta
from dispatcher.models import Clocking, RealSageXHours
class ClockingSerializer(ModelSerializer):
@ -24,8 +25,19 @@ class ClockingSerializer(ModelSerializer):
out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm
if obj.remote is not None:
total += timedelta(hours=obj.remote.hour, minutes=obj.remote.minute)
total += obj.remote
seconds = total.seconds
minutes = seconds // 60
return minutes
class RealSageXHoursSerializer(ModelSerializer):
hours = serializers.SerializerMethodField()
class Meta:
model = RealSageXHours
fields = "__all__"
def get_hours(self, obj: RealSageXHours):
return convert_timedelta(obj.hours)

View File

@ -77,6 +77,12 @@ footer .sep {
height: 1em;
}
footer .debug {
margin-right: auto;
font-style: italic;
color: #ffb33d;
}
main {
flex-grow: 1;
display: flex;

View File

@ -55,9 +55,15 @@ function formatDate(date) {
}
function formatDuration(duration) {
let res = ""
if (duration < 0) {
duration *= -1
res += "-"
}
let hours = Math.floor(duration / 60)
duration -= hours * 60
let res = ""
duration = Math.round(duration)
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")

View File

@ -34,21 +34,89 @@
background-color: var(--dark4);
}
.by-range #list {
.by-range .tables {
display: flex;
flex-direction: column;
gap: 0.8em;
}
.by-range #list .row {
display: flex;
gap: 1.2em;
padding: 0.4em 0.8em;
background-color: var(--dark3);
.by-range .tables table {
border-collapse: collapse;
}
.by-range #list .no-data {
font-style: italic;
padding: 0.4em 0.8em;
background-color: var(--dark2);
.by-range .tables tr {
height: 2em;
}
.by-range .tables td {
padding: 0 0.4em;
min-width: 3em;
text-align: right;
}
#headers-table th {
text-align: right;
padding: 0 0.8em;
}
#projects-table th {
text-align: center;
padding: 0 0.4em;
}
.by-range tr.project-nums {
border-bottom: solid var(--light1) 2px;
}
#projects-table tr.project-nums td {
text-align: center;
}
#projects-table th, #projects-table td {
border-left: solid var(--light4) 1px;
}
.by-range tr.real-sagex-hours .sagex-hours {
height: 2em;
input {
max-width: 3em;
background: none;
color: var(--light1);
&:focus-visible {
outline: solid var(--dark4) 1px;
}
&::-webkit-inner-spin-button {
display: none;
}
&.hours {
text-align: right;
}
&.minutes {
text-align: left;
max-width: 2em;
}
}
&.saved {
input {
animation-name: sagex-saved;
animation-duration: 1s;
}
}
}
@keyframes sagex-saved {
0% {
}
10%, 60% {
border-color: var(--accent);
}
100% {
}
}

View File

@ -1,5 +1,4 @@
let prevMonthBtn, nextMonthBtn, month
let prevWeekBtn, nextWeekBtn, week
let curYear = new Date().getFullYear()
let curMonth = new Date().getMonth()
let curWeekDate = new Date()
@ -10,7 +9,7 @@ function prevMonth() {
curMonth += 12
curYear -= 1
}
updateListMonthly()
updateTableMonthly()
}
function nextMonth() {
curMonth = curMonth + 1
@ -18,18 +17,10 @@ function nextMonth() {
curMonth -= 12
curYear += 1
}
updateListMonthly()
}
function prevWeek() {
curWeekDate = new Date(curWeekDate.valueOf() - 7 * DAY_MS)
updateListWeekly()
}
function nextWeek() {
curWeekDate = new Date(curWeekDate.valueOf() + 7 * DAY_MS)
updateListWeekly()
updateTableMonthly()
}
function updateListMonthly() {
function updateTableMonthly() {
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let today = new Date()
@ -45,50 +36,142 @@ function updateListMonthly() {
if (res.status !== "success") {
return
}
setListElements(res.data.parents)
updateTable(res.data)
})
}
function updateListWeekly() {
let weekNum = curWeekDate.getWeek()
let weekDay = (curWeekDate.getDay() + 6) % 7
let startDate = new Date(curWeekDate.valueOf() - weekDay * DAY_MS)
let endDate = new Date(startDate.valueOf() + 6 * DAY_MS)
function updateTable(data) {
let totalWorked = data.clockings.map(c => c.total).reduce((a, b) => a + b, 0)
let today = new Date()
let date = `${MONTHS[startDate.getMonth()]} ${startDate.getDate()}`
if (startDate.getFullYear() !== today.getFullYear()) {
date += " " + startDate.getFullYear().toString().padStart(4, "0")
let parents = data.parents.filter(parent => parent.duration !== null && parent.is_productive)
let headers = document.getElementById("headers-table")
let table = document.getElementById("projects-table")
let projNames = table.querySelector(".project-names")
let projNums = table.querySelector(".project-nums")
let totDurations = table.querySelector(".total-durations")
let workingRatios = table.querySelector(".working-time-ratios")
let imputedRatios = table.querySelector(".imputed-time-ratios")
let sagexHours = table.querySelector(".sagex-hours")
let realSagexHours = table.querySelector(".real-sagex-hours")
let differences = table.querySelector(".sagex-diff")
table.querySelectorAll("tr").forEach(row => row.innerHTML = "")
let totalImputed = 0
parents.forEach(parent => {
totalImputed += +parent.duration
})
headers.querySelector(".total-clocking").innerText = formatDuration(totalWorked)
headers.querySelector(".total-duration").innerText = formatDuration(totalImputed)
let sagexHoursTemplate = getTemplate("sagex-hours")
let totalSagex = 0
let totalRealSagex = 0
parents.forEach(parent => {
let duration = +parent.duration
let name = document.createElement("th")
name.innerText = parent.name
projNames.appendChild(name)
projNums.insertCell(-1).innerText = parent.project_num
table.querySelector(".clocking").insertCell(-1)
totDurations.insertCell(-1).innerText = formatDuration(duration)
let ratioWorking = duration / totalWorked
let ratioImputed = duration / totalImputed
workingRatios.insertCell(-1).innerText = formatPercentage(ratioWorking)
imputedRatios.insertCell(-1).innerText = formatPercentage(ratioImputed)
let sagexDuration = duration * totalWorked / totalImputed
totalSagex += sagexDuration
let sagexCell = sagexHours.insertCell(-1)
sagexCell.innerText = formatDuration(sagexDuration)
sagexCell.dataset.duration = sagexDuration
let td = sagexHoursTemplate.cloneNode(true)
let [h, m, s] = parent.real_sagex_hours.split(":")
let hoursInput = td.querySelector("input.hours")
let minutesInput = td.querySelector("input.minutes")
hoursInput.value = h
minutesInput.value = m
let timeout = null
minutesInput.addEventListener("input", () => reformatTime(minutesInput))
td.addEventListener("change", () => {
if (timeout !== null) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
updateSagex(parent.id, td)
}, 1000)
})
realSagexHours.appendChild(td)
let real = (+h)*60 + (+m)
let diff = real - sagexDuration
differences.insertCell(-1).innerText = formatDuration(diff)
totalRealSagex += real
})
headers.querySelector(".sagex-hours-total").innerText = formatDuration(totalSagex)
headers.querySelector(".real-sagex-hours-total").innerText = formatDuration(totalRealSagex)
headers.querySelector(".sagex-diff-total").innerText = formatDuration(totalRealSagex - totalSagex)
}
function reformatTime(input) {
let value = input.value
if (value.length === 1) {
input.value = "0" + value
} else if (value.length > 2) {
input.value = value.slice(value.length - 2)
}
document.getElementById("week").innerText = `Week ${weekNum} (${date})`
}
fetch(`stats/between/${formatDate(startDate)}/${formatDate(endDate)}/`).then(res => {
function getDuration(td) {
let hours = +td.querySelector(".hours").value
let minutes = +td.querySelector(".minutes").value
return hours * 60 + minutes
}
function updateSagex(id, cell) {
let minutesInput = cell.querySelector(".minutes")
reformatTime(minutesInput)
let newDuration = getDuration(cell)
let year = curYear.toString().padStart(4, "0")
let month = (curMonth + 1).toString().padStart(2, "0")
let date = `${year}-${month}`
let fd = new FormData()
fd.set("minutes", newDuration)
req(`/sagex/${id}/${date}/`, {
method: "POST",
body: fd
}).then(res => {
return res.json()
}).then(res => {
if (res.status !== "success") {
return
}
setListElements(res.data.parents)
})
}
if (res.status === "success") {
cell.classList.add("saved")
cell.addEventListener("animationend", () => {
cell.classList.remove("saved")
}, {once: true})
let theoreticalRow = document.querySelector("#projects-table tr.sagex-hours")
let realRow = document.querySelector("#projects-table tr.real-sagex-hours")
let diffRow = document.querySelector("#projects-table tr.sagex-diff")
let totalRealCell = document.querySelector("#headers-table tr.real-sagex-hours .real-sagex-hours-total")
let totalDiffCell = document.querySelector("#headers-table tr.sagex-diff .sagex-diff-total")
function setListElements(data) {
data = data.filter(parent => parent.duration !== null)
let list = document.getElementById("list")
let template = document.querySelector(".by-range .template.row").cloneNode(true)
template.classList.remove("template")
list.innerHTML = ""
if (data.length === 0) {
let noData = document.querySelector(".by-range .template.no-data").cloneNode(true)
noData.classList.remove("template")
list.appendChild(noData)
return
}
data.forEach(parent => {
let row = template.cloneNode(true)
row.querySelector(".name").innerText = `${parent.name} (${parent.project_num})`
row.querySelector(".duration").innerText = formatDuration(parent.duration)
list.appendChild(row)
let durationsTheory = Array.from(theoreticalRow.cells).map(c => +c.dataset.duration)
let durationsReal = Array.from(realRow.cells).map(getDuration)
let totalTheory = durationsTheory.reduce((a, b) => a + b, 0)
let totalReal = durationsReal.reduce((a, b) => a + b, 0)
let totalDiff = totalReal - totalTheory
totalRealCell.innerText = formatDuration(totalReal)
totalDiffCell.innerText = formatDuration(totalDiff)
let theory = +theoreticalRow.cells[cell.cellIndex].dataset.duration
let diff = newDuration - theory
diffRow.cells[cell.cellIndex].innerText = formatDuration(diff)
} else {
alert(res.error)
}
})
}
@ -97,31 +180,8 @@ window.addEventListener("load", () => {
nextMonthBtn = document.getElementById("next-month")
month = document.getElementById("month")
prevWeekBtn = document.getElementById("prev-week")
nextWeekBtn = document.getElementById("next-week")
week = document.getElementById("week")
prevMonthBtn.addEventListener("click", () => prevMonth())
nextMonthBtn.addEventListener("click", () => nextMonth())
prevWeekBtn.addEventListener("click", () => prevWeek())
nextWeekBtn.addEventListener("click", () => nextWeek())
let monthGrp = document.getElementById("month-sel")
let weekGrp = document.getElementById("week-sel")
rangeSel = document.getElementById("range-sel")
rangeSel.addEventListener("change", () => {
let mode = rangeSel.value
if (mode === "weekly") {
monthGrp.classList.add("hidden")
weekGrp.classList.remove("hidden")
updateListWeekly()
} else {
monthGrp.classList.remove("hidden")
weekGrp.classList.add("hidden")
updateListMonthly()
}
})
updateListMonthly()
updateTableMonthly()
})

View File

@ -0,0 +1,20 @@
.wrapper {
margin: auto;
max-width: 25em;
width: 100%;
text-align: center;
display: flex;
flex-direction: column;
gap: 2em;
}
.desc {
line-height: 1.4;
}
.url {
font-family: monospace;
background-color: rgba(255, 255, 255, 0.11);
padding: 0.1em 0.4em;
border-radius: 4px;
}

BIN
dispatcher/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -1,3 +1,7 @@
:root {
--invalid-col: #f1232394;
}
#table {
border-collapse: collapse;
font-size: 80%;
@ -49,6 +53,11 @@
border-top: solid #71717185 1px;
td {
padding: 0.4em;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
@ -85,6 +94,17 @@
border-bottom: var(--border);
}
.day-totals {
td {
text-align: right;
font-style: italic;
&.invalid {
background-color: var(--invalid-col);
font-weight: bold;
}
}
}
col.day-5, col.day-6 {
background-color: var(--dark3);
}

View File

@ -9,6 +9,7 @@ class Table {
this.weekNames = elmt.querySelector(".week-names")
this.dayNames = elmt.querySelector(".day-names")
this.dayDates = elmt.querySelector(".day-dates")
this.dayTotals = elmt.querySelector(".day-totals")
this.columns = elmt.querySelector(".columns")
this.timeInputTemplate = getTemplate("time-input")
@ -22,6 +23,7 @@ class Table {
this.endDate = today
this.totalProjects = 0
this.dailyTotals = []
this.clockingTotals = []
this.clockingRemotes = []
@ -67,6 +69,7 @@ class Table {
this.weekNames.querySelectorAll("td").forEach(c => c.remove())
this.dayNames.querySelectorAll("td").forEach(c => c.remove())
this.dayDates.innerHTML = ""
this.dayTotals.querySelectorAll("td").forEach(c => c.remove())
this.times.querySelectorAll("tr.project").forEach(r => r.remove())
this.nDays = 0
}
@ -112,6 +115,7 @@ class Table {
this.addClockings(date)
let dayName = this.dayNames.insertCell(this.nDays + 2)
let dayDate = this.dayDates.insertCell(this.nDays)
this.dayTotals.insertCell(this.nDays + 1)
let weekDay = (date.getDay() + 6) % 7
dayName.innerText = DAYS_SHORT[weekDay]
dayDate.innerText = `${dayPadded}/${monthPadded}`
@ -211,37 +215,46 @@ class Table {
displayData(data) {
this.displayClockings(data.clockings)
this.totalProjects = 0
this.dailyTotals = Array(this.nDays).fill(0)
data.parents.forEach(parent => {
let parentDurations = Array(this.nDays).fill(0)
let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("")
let total = 0
project.tasks.forEach(task => {
let date = new Date(task.date)
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
let hours = task.duration / 60
durations[i] = hours
total += hours
parentDurations[i] += hours
if (parent.is_productive) {
this.dailyTotals[i] += hours
}
})
return {
id: project.id,
name: project.name,
durations: durations
durations: durations,
total: total
}
})
this.addProject(
parent.id,
parent.name,
parent.project_num,
parentDurations.map(v => v === 0 ? "" : v),
true,
parent.is_productive
)
if (parentDurations.reduce((a, b) => a + b, 0) !== 0) {
this.addProject(
parent.id,
parent.name,
parent.project_num,
parentDurations.map(v => v === 0 ? "" : v),
true,
parent.is_productive
)
projects.forEach(project => {
this.addProject(project.id, project.name, "", project.durations)
})
projects.filter(p => p.total !== 0).forEach(project => {
this.addProject(project.id, project.name, "", project.durations)
})
}
})
this.updateTotals()
}
@ -334,6 +347,24 @@ class Table {
let sagexTime = imputedTimeRatio * totalClockings
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
})
for (let i = 0; i < this.nDays; i++) {
let total = this.dailyTotals[i]
let cell = this.dayTotals.cells[i + 1]
if (total === 0) {
cell.innerText = ""
} else {
cell.innerText = Math.round(total * 100) / 100
}
let clocking = this.clockings.querySelector(".clocking.total").cells[i + 1]
if (total > this.clockingTotals[i]) {
clocking.classList.add("invalid")
cell.classList.add("invalid")
} else {
clocking.classList.remove("invalid")
cell.classList.remove("invalid")
}
}
}
post(endpoint, data) {

View File

@ -1,17 +1,19 @@
import datetime
from datetime import timedelta
from django.db.models import Sum, Q, QuerySet
from django.db.models import Sum, Q, QuerySet, Subquery, OuterRef, DurationField, Value, DecimalField
from django.db.models.functions import Coalesce
from django.http import JsonResponse
from django.shortcuts import render, get_object_or_404, redirect
from django.views import generic
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from rest_framework.status import HTTP_400_BAD_REQUEST
from dispatcher.core import import_tasks
from dispatcher.core import import_tasks, convert_timedelta, str_to_timedelta
from dispatcher.forms import ProjectForm, ImportForm, ParentForm
from dispatcher.models import Project, Parent, Clocking, Task
from dispatcher.serializers import ClockingSerializer
from dispatcher.models import Project, Parent, Clocking, Task, RealSageXHours
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request):
@ -96,6 +98,17 @@ def project_view(request, id):
context["form"] = ProjectForm(instance=project)
return render(request, "edit.html", context)
def new_project_view(request):
context = {
"class": "project"
}
form = ProjectForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("projects")
context["form"] = form
return render(request, "add.html", context)
def table_view(request):
return render(request, "table.html")
@ -127,6 +140,12 @@ def get_stats_by_month(request, year: int, month: int):
if month < 1 or month > 12:
return JsonResponse({"status": "error", "error": f"Invalid month {month}"})
sagex_subquery = RealSageXHours.objects.filter(
parent=OuterRef("pk"),
date__year=year,
date__month=month
).values("hours")[:1]
parents = Parent.objects.annotate(
total_duration=Sum(
"project__task__duration",
@ -135,6 +154,11 @@ def get_stats_by_month(request, year: int, month: int):
project__task__date__month=month
)
)
).annotate(
real_sagex_hours=Coalesce(
Subquery(sagex_subquery, output_field=DurationField()),
timedelta(0)
)
)
projects = Project.objects.annotate(
total_duration=Sum(
@ -145,37 +169,22 @@ def get_stats_by_month(request, year: int, month: int):
)
)
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects)})
def get_stats_between(request, start_date: datetime.date, end_date: datetime.date):
parents = Parent.objects.annotate(
total_duration=Sum(
"project__task__duration",
filter=Q(
project__task__date__gte=start_date,
project__task__date__lt=end_date + timedelta(days=1)
)
)
clockings = Clocking.objects.filter(
date__year=year,
date__month=month
)
projects = Project.objects.annotate(
total_duration=Sum(
"task__duration",
filter=Q(
task__date__gte=start_date,
task__date__lt=end_date + timedelta(days=1)
)
)
)
return JsonResponse({"status": "success", "data": format_stats(parents, projects)})
return JsonResponse({"status": "success", "data": format_stats(parents, projects, clockings)})
def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project]):
def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project], clockings: QuerySet[Clocking]):
data = {
"parents": [
{
"id": parent.id,
"name": parent.name,
"project_num": parent.project_num,
"duration": parent.total_duration
"is_productive": parent.is_productive,
"duration": parent.total_duration,
"real_sagex_hours": convert_timedelta(parent.real_sagex_hours)
}
for parent in parents
],
@ -186,8 +195,10 @@ def format_stats(parents: QuerySet[Parent], projects: QuerySet[Project]):
"duration": project.total_duration
}
for project in projects
]
],
"clockings": ClockingSerializer(clockings, many=True).data
}
Clocking.objects.filter()
return data
class ParentsView(generic.ListView):
@ -199,6 +210,7 @@ class ProjectsView(generic.ListView):
model = Project
template_name = "projects.html"
context_object_name = "elements"
ordering = ["parent"]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
@ -253,7 +265,13 @@ def set_clocking(request, date: datetime.date):
clocking.out_am = request.POST.get("out_am", clocking.out_am) or None
clocking.in_pm = request.POST.get("in_pm", clocking.in_pm) or None
clocking.out_pm = request.POST.get("out_pm", clocking.out_pm) or None
clocking.remote = request.POST.get("remote", clocking.remote) or None
remote = request.POST.get("remote", clocking.remote) or None
if remote is not None:
if isinstance(remote, str):
remote = str_to_timedelta(remote)
else:
remote = timedelta()
clocking.remote = remote
clocking.save()
clocking.refresh_from_db()
@ -261,3 +279,33 @@ def set_clocking(request, date: datetime.date):
"status": "success",
"clocking": ClockingSerializer(clocking).data
})
@require_POST
def set_real_sagex(request, id, month: datetime.date):
parent = get_object_or_404(Parent, id=id)
minutes = request.POST.get("minutes")
if minutes is None:
return JsonResponse({
"status": "error",
"error": "Missing minutes field"
}, status=HTTP_400_BAD_REQUEST)
try:
minutes = int(minutes)
except ValueError:
return JsonResponse({
"status": "error",
"error": "Invalid value for minutes, must be an int"
}, status=HTTP_400_BAD_REQUEST)
hours, minutes = divmod(minutes, 60)
entry, created = RealSageXHours.objects.get_or_create(parent=parent, date=month)
entry.hours = timedelta(hours=int(hours), minutes=int(minutes))
entry.save()
entry.refresh_from_db()
return JsonResponse({
"status": "success",
"sagex": RealSageXHoursSerializer(entry).data
})

16
main.py Normal file
View File

@ -0,0 +1,16 @@
import uvicorn
from TimeDispatcher.asgi import application
def main():
uvicorn.run(
application,
host="127.0.0.1",
port=8000,
workers=1
)
if __name__ == "__main__":
main()

View File

@ -1,2 +1,4 @@
Django~=5.1.5
djangorestframework~=3.15.2
python-dotenv~=1.0.1
uvicorn~=0.34.0

13
templates/404.html Normal file
View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% load static %}
{% block title %}404 Not Found{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "error.css" %}">
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
<div class="wrapper">
<h1 class="title">Page not found</h1>
<p class="desc">Oops, page <span class="url">{{ request.path }}</span> could not be found. Go back to <a href="{% url "dashboard" %}">Dashboard</a></p>
</div>
{% endblock %}

View File

@ -22,6 +22,7 @@
{% block main %}{% endblock %}
</main>
<footer>
{% if debug %}<span class="debug">Debug</span>{% endif %}
<span><i>Time Dispatcher v{{ version }}</i></span>
<span class="sep"></span>
<span><a href="https://git.kb28.ch/HEL/TimeDispatcher" target="_blank">Gitea</a></span>

View File

@ -7,28 +7,68 @@
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
{% csrf_token %}
<table class="template">
<td class="template sagex-hours">
<input type="number" class="hours" min="0" step="1" />:<input type="number" class="minutes" min="0" step="1" />
</td>
</table>
<div class="by-range">
<div class="template row">
<div class="name"></div>
<div class="duration"></div>
</div>
<div class="template no-data">No Data</div>
<div class="controls">
<div id="month-sel" class="group">
<button id="prev-month"><</button>
<div id="month">Month</div>
<button id="next-month">></button>
</div>
<div id="week-sel" class="group hidden">
<button id="prev-week"><</button>
<div id="week">Week</div>
<button id="next-week">></button>
</div>
<select id="range-sel">
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div id="list"></div>
<div class="tables">
<table id="headers-table">
<tr class="project-names">
<th colspan="2"></th>
</tr>
<tr class="project-nums">
<th colspan="2">N° SageX</th>
</tr>
<tr class="clocking">
<th>Total clocked</th>
<td class="total-clocking"></td>
</tr>
<tr class="total-durations">
<th>Total</th>
<td class="total-duration"></td>
</tr>
<tr class="working-time-ratios">
<th>% working time</th>
<td class="working-time-ratio"></td>
</tr>
<tr class="imputed-time-ratios">
<th>% imputed time</th>
<td></td>
</tr>
<tr class="sagex-hours">
<th>Hours on SageX (theoretical)</th>
<td class="sagex-hours-total"></td>
</tr>
<tr class="real-sagex-hours">
<th>Hours on SageX (real)</th>
<td class="real-sagex-hours-total"></td>
</tr>
<tr class="sagex-diff">
<th>Difference</th>
<td class="sagex-diff-total"></td>
</tr>
</table>
<table id="projects-table">
<tr class="project-names"></tr>
<tr class="project-nums"></tr>
<tr class="clocking"></tr>
<tr class="total-durations"></tr>
<tr class="working-time-ratios"></tr>
<tr class="imputed-time-ratios"></tr>
<tr class="sagex-hours"></tr>
<tr class="real-sagex-hours"></tr>
<tr class="sagex-diff"></tr>
</table>
</div>
</div>
{% endblock %}

View File

@ -63,6 +63,9 @@
<th rowspan="2">N° sagex</th>
</tr>
<tr class="day-dates"></tr>
<tr class="day-totals">
<th colspan="2">Total Productive</th>
</tr>
</tbody>
</table>
{% endblock %}