18 Commits

Author SHA1 Message Date
d96d2c590e Merge pull request 'v0.1.4: minor fix' (#7) from dev into main
Reviewed-on: #7
2025-04-23 22:00:44 +00:00
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
8393855514 Merge pull request 'v0.1.3: minor fixes' (#4) from dev into main
Reviewed-on: #4
2025-03-02 15:10:42 +00: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
f48cbdb03a Merge pull request 'v0.1.2: Minor ui improvements' (#3) from dev into main
Reviewed-on: #3
2025-03-01 13:25:13 +00: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
18 changed files with 264 additions and 28 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
View File

@ -1,2 +1,3 @@
__pycache__
db.sqlite3
/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

@ -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,9 +14,10 @@ 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 import settings
from TimeDispatcher.converters import DateConverter, YearMonthConverter
from dispatcher import views
@ -41,3 +42,6 @@ urlpatterns = [
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

@ -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

@ -15,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)

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

@ -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;
}

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

@ -267,7 +267,8 @@ def set_clocking(request, date: datetime.date):
clocking.out_pm = request.POST.get("out_pm", clocking.out_pm) or None
remote = request.POST.get("remote", clocking.remote) or None
if remote is not None:
remote = str_to_timedelta(remote)
if isinstance(remote, str):
remote = str_to_timedelta(remote)
else:
remote = timedelta()
clocking.remote = remote
@ -307,4 +308,4 @@ def set_real_sagex(request, id, month: datetime.date):
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
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

@ -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 %}