17 Commits

Author SHA1 Message Date
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
092812b9b7 added project delete + changed to list template 2025-02-02 23:01:17 +01:00
35d5a81e6f added parent delete popup + endpoint 2025-02-02 22:43:52 +01:00
8b0b56cb7b added new parent page + minor css/js improvements 2025-02-02 21:48:12 +01:00
6ff3f05272 added parent is_productive field 2025-02-02 18:47:41 +01:00
3b4ae7a55a changed remote totals to traditional time 2025-02-02 17:34:29 +01:00
87607da643 added parent edit page 2025-02-02 15:33:11 +01:00
d13127a273 added clockings/remote weekly total + minor improvements 2025-02-02 15:15:25 +01:00
ee96598d92 added clockings edit + auto calculations 2025-02-02 13:33:56 +01:00
47 changed files with 1675 additions and 370 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,11 +2,21 @@ 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)
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()
def to_url(self, value):
return value.strftime(self.format)

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.0"
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,23 +14,34 @@ 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"),
path("import/", views.import_view, name="import"),
path("projects/", views.projects_view, name="projects"),
path("projects/", views.ProjectsView.as_view(), name="projects"),
path("parents/", views.ParentsView.as_view(), name="parents"),
path("table/<date:start_date>/<date:end_date>/", views.get_table_data, name="table_data"),
path("table/", views.table_view, name="table"),
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("projects/<int:id>/set_parent", views.set_parent, name="set_parent"),
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("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

@ -1,8 +1,13 @@
from django import forms
from dispatcher.models import Project
from dispatcher.models import Project, Parent
class ParentForm(forms.ModelForm):
class Meta:
model = Parent
fields = "__all__"
class ProjectForm(forms.ModelForm):
class Meta:
model = Project

View File

@ -0,0 +1,25 @@
# Generated by Django 5.1.5 on 2025-01-31 17:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0004_task_unique_daily_task'),
]
operations = [
migrations.CreateModel(
name='Clocking',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField()),
('in_am', models.TimeField(default=None, null=True)),
('out_am', models.TimeField(default=None, null=True)),
('in_pm', models.TimeField(default=None, null=True)),
('out_pm', models.TimeField(default=None, null=True)),
('remote', models.TimeField(default=None, null=True)),
],
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 14:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0005_clocking'),
]
operations = [
migrations.AlterField(
model_name='parent',
name='project_num',
field=models.CharField(blank=True, max_length=32),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-02 16:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0006_alter_parent_project_num'),
]
operations = [
migrations.AddField(
model_name='parent',
name='is_productive',
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,38 @@
# Generated by Django 5.1.5 on 2025-02-02 17:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatcher', '0007_parent_is_productive'),
]
operations = [
migrations.AlterField(
model_name='clocking',
name='in_am',
field=models.TimeField(default=None, null=True, verbose_name='Clock in AM'),
),
migrations.AlterField(
model_name='clocking',
name='in_pm',
field=models.TimeField(default=None, null=True, verbose_name='Clock in PM'),
),
migrations.AlterField(
model_name='clocking',
name='out_am',
field=models.TimeField(default=None, null=True, verbose_name='Clock out AM'),
),
migrations.AlterField(
model_name='clocking',
name='out_pm',
field=models.TimeField(default=None, null=True, verbose_name='Clock out PM'),
),
migrations.AlterField(
model_name='parent',
name='project_num',
field=models.CharField(blank=True, max_length=32, verbose_name='Project number'),
),
]

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

@ -1,8 +1,11 @@
from datetime import timedelta
from django.db import models
class Parent(models.Model):
project_num = models.CharField(max_length=32)
project_num = models.CharField(max_length=32, blank=True, verbose_name="Project number")
name = models.CharField(max_length=256)
is_productive = models.BooleanField(default=False)
def __str__(self):
return self.name
@ -30,3 +33,23 @@ class Task(models.Model):
constraints = [
models.UniqueConstraint(fields=["date", "project", "name"], name="unique_daily_task")
]
class Clocking(models.Model):
date = models.DateField()
in_am = models.TimeField(null=True, default=None, verbose_name="Clock in AM")
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.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")
]

43
dispatcher/serializers.py Normal file
View File

@ -0,0 +1,43 @@
from datetime import datetime, timedelta, time
from rest_framework import serializers
from rest_framework.serializers import ModelSerializer
from dispatcher.core import convert_timedelta
from dispatcher.models import Clocking, RealSageXHours
class ClockingSerializer(ModelSerializer):
total = serializers.SerializerMethodField()
class Meta:
model = Clocking
fields = "__all__"
def get_total(self, obj: Clocking):
total = timedelta()
if obj.in_am is not None and obj.out_am is not None:
in_am = datetime.combine(obj.date, obj.in_am)
out_am = datetime.combine(obj.date, obj.out_am)
total += out_am - in_am
if obj.in_pm is not None and obj.out_pm is not None:
in_pm = datetime.combine(obj.date, obj.in_pm)
out_pm = datetime.combine(obj.date, obj.out_pm)
total += out_pm - in_pm
if obj.remote is not None:
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

@ -7,6 +7,8 @@
--light2: #e5e5e5;
--light3: #cccccc;
--light4: #c5c5c5;
--accent: #69c935;
--accent2: #61ba31;
}
* {
@ -43,6 +45,16 @@ nav a {
transition: background-color 0.2s;
}
nav a.logo {
padding-top: 0;
padding-bottom: 0;
}
nav a.logo img {
object-fit: contain;
height: 2em;
width: 2em;
}
nav a.active {
box-shadow: 0 -2px var(--light1) inset;
}
@ -65,11 +77,18 @@ footer .sep {
height: 1em;
}
footer .debug {
margin-right: auto;
font-style: italic;
color: #ffb33d;
}
main {
flex-grow: 1;
display: flex;
flex-direction: column;
padding: 2em;
overflow-y: auto;
}
button, input, select {
@ -95,5 +114,9 @@ button, select {
}
a {
color: #69c935
color: var(--accent);
}
ul {
padding-left: 2em;
}

View File

@ -0,0 +1,93 @@
const SEC_MS = 1000
const MIN_MS = SEC_MS * 60
const HOUR_MS = MIN_MS * 60
const DAY_MS = HOUR_MS * 24
const DAYS_SHORT = [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
]
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
//////////////////////////////////////////////////////////
// //
// Taken from: https://weeknumber.com/how-to/javascript //
// //
//////////////////////////////////////////////////////////
Date.prototype.getWeek = function() {
let date = new Date(this.getTime())
date.setHours(0, 0, 0, 0)
// Thursday in current week decides the year.
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7)
// January 4 is always in week 1.
let week1 = new Date(date.getFullYear(), 0, 4)
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
}
function formatDate(date) {
let year = date.getFullYear().toString().padStart(4, "0")
let month = (date.getMonth() + 1).toString().padStart(2, "0")
let day = date.getDate().toString().padStart(2, "0")
return `${year}-${month}-${day}`
}
function formatDuration(duration) {
let res = ""
if (duration < 0) {
duration *= -1
res += "-"
}
let hours = Math.floor(duration / 60)
duration -= hours * 60
duration = Math.round(duration)
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")
} else {
res += duration.toString() + "min"
}
return res
}
function formatPercentage(ratio) {
let percentage = Math.round(ratio * 100)
return percentage + "%"
}
function req(url, options = {}) {
let headers = options.headers || {}
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
headers["X-CSRFToken"] = csrftoken
options.headers = headers
return fetch(url, options)
}
function getTemplate(cls) {
let elmt = document.querySelector(".template." + cls).cloneNode(true)
elmt.classList.remove("template")
return elmt
}

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,56 +1,15 @@
let prevMonthBtn, nextMonthBtn, month
let prevWeekBtn, nextWeekBtn, week
let curYear = new Date().getFullYear()
let curMonth = new Date().getMonth()
let curWeekDate = new Date()
const SEC_MS = 1000
const MIN_MS = SEC_MS * 60
const HOUR_MS = MIN_MS * 60
const DAY_MS = HOUR_MS * 24
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
function formatDate(date) {
let year = date.getFullYear().toString().padStart(4, "0")
let month = (date.getMonth() + 1).toString().padStart(2, "0")
let day = date.getDate().toString().padStart(2, "0")
return `${year}-${month}-${day}`
}
function formatDuration(duration) {
let hours = Math.floor(duration / 60)
duration -= hours * 60
let res = ""
if (hours > 0) {
res += hours.toString() + "h"
res += duration.toString().padStart(2, "0")
} else {
res += duration.toString() + "min"
}
return res
}
function prevMonth() {
curMonth = curMonth - 1
if (curMonth < 0) {
curMonth += 12
curYear -= 1
}
updateListMonthly()
updateTableMonthly()
}
function nextMonth() {
curMonth = curMonth + 1
@ -58,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()
@ -85,49 +36,142 @@ function updateListMonthly() {
if (res.status !== "success") {
return
}
setListElements(res.data.parents)
updateTable(res.data)
})
}
function updateListWeekly() {
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 txt = `Week of ${MONTHS[startDate.getMonth()]} ${startDate.getDate()}`
if (startDate.getFullYear() !== today.getFullYear()) {
txt += " " + 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 = txt
}
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)
}
})
}
@ -136,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;
}

View File

@ -0,0 +1,53 @@
.container {
display: flex;
flex-direction: column;
width: 100%;
max-width: 30em;
align-self: center;
gap: 2em;
}
form {
display: flex;
flex-direction: column;
gap: 1.6em;
}
form > div {
display: flex;
flex-direction: column;
gap: 0.4em;
}
form label {
font-style: italic;
color: var(--light2);
}
form button {
align-self: center;
padding: 0.4em 1.2em;
}
form input[type="checkbox"] {
width: 1.6em;
height: 1.6em;
border: solid var(--light1) 2px;
appearance: none;
background: none;
border-radius: 0;
position: relative;
cursor: pointer;
}
form input[type="checkbox"]:checked::after {
content: "";
background-color: var(--accent);
width: 80%;
height: 80%;
border-radius: 10%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}

View File

@ -1,10 +1,29 @@
.list {
display: flex;
.list-wrapper {
width: 100%;
max-width: 40em;
align-self: center;
display: flex;
flex-direction: column;
gap: 1em;
overflow-y: auto;
}
.list-header {
display: flex;
justify-content: flex-end;
gap: 1em;
}
.list-header button {
padding: 0.4em 1.2em;
}
.list {
display: flex;
gap: 0.8em;
flex-direction: column;
padding: 0;
overflow-y: auto;
}
.list li {
@ -13,8 +32,66 @@
marker: none;
gap: 0.8em;
border: solid var(--dark4) 1px;
align-items: center;
}
.list li .actions {
margin-left: auto;
}
.popup {
background-color: #00000040;
position: fixed;
inset: 0;
display: grid;
place-items: center;
}
.popup:not(.show) {
display: none;
}
.popup .popup-container {
display: flex;
flex-direction: column;
gap: 1em;
padding: 2em;
background-color: var(--dark1);
border-radius: 1em;
width: 100%;
max-width: 40em;
box-shadow: 0 0.2em 0.8em var(--dark3);
}
.popup .popup-container .elmt-name {
text-decoration: underline;
}
.popup .popup-container .actions {
display: flex;
justify-content: space-evenly;
gap: 1em;
}
.popup .popup-container .actions button {
padding: 0.4em 1.2em;
}
.popup .popup-container .actions .cancel {
background-color: var(--light2);
}
.popup .popup-container .actions .cancel:hover {
background-color: var(--light3);
}
.popup .popup-container .actions .delete {
--col: #f95f4b;
color: var(--col);
border-color: var(--col);
background-color: var(--dark1);
}
.popup .popup-container .actions .delete:hover {
background-color: var(--dark2);
}

55
dispatcher/static/list.js Normal file
View File

@ -0,0 +1,55 @@
function showDeletePopup(id, name) {
let popup = document.getElementById("delete-popup")
popup.dataset.id = id
popup.querySelectorAll(".elmt-name").forEach(elmt => {
elmt.innerText = name
})
popup.classList.add("show")
}
function deleteElement(id) {
let url = window.location.href
if (!url.endsWith("/")) {
url += "/"
}
url += `${id}/`
req(url, {
method: "DELETE"
}).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
window.location.reload()
}
})
}
let onBeforeDelete = null
window.addEventListener("load", () => {
document.querySelector("button.new").addEventListener("click", () => {
window.location.href = "new/"
})
document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id
row.querySelector("button.edit")?.addEventListener("click", () => {
window.location.href = `${id}/`
})
row.querySelector("button.delete")?.addEventListener("click", async () => {
if (onBeforeDelete) {
await onBeforeDelete(row)
}
showDeletePopup(id, row.dataset.name)
})
})
let deletePopup = document.getElementById("delete-popup")
deletePopup.querySelector(".actions .cancel").addEventListener("click", () => {
deletePopup.classList.remove("show")
})
deletePopup.querySelector(".actions .delete").addEventListener("click", () => {
deleteElement(deletePopup.dataset.id)
})
})

BIN
dispatcher/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="64"
height="64"
viewBox="0 0 64 64"
version="1.1"
id="svg1"
sodipodi:docname="logo.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="true"
inkscape:zoom="11.559708"
inkscape:cx="31.012894"
inkscape:cy="41.047749"
inkscape:window-width="1920"
inkscape:window-height="1016"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="layer1">
<inkscape:grid
id="grid1"
units="px"
originx="0"
originy="0"
spacingx="1"
spacingy="1"
empcolor="#0099e5"
empopacity="0.30196078"
color="#0099e5"
opacity="0.14901961"
empspacing="8"
enabled="true"
visible="true" />
</sodipodi:namedview>
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect3"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,0,1,0,2,0,1 @ F,0,1,1,0,2,0,1 @ F,0,1,1,0,2,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g4">
<path
style="fill:#69c935;fill-opacity:1;stroke-width:6;stroke-linecap:square;stroke:none"
d="m 8,10 v 4 a 2,2 45 0 0 2,2 h 7 a 2,2 45 0 1 2,2 v 36 a 2,2 45 0 0 2,2 h 4 a 2,2 135 0 0 2,-2 V 18 a 2,2 135 0 1 2,-2 h 7 a 2,2 135 0 0 2,-2 V 10 A 2,2 45 0 0 36,8 H 10 a 2,2 135 0 0 -2,2 z"
id="path1"
sodipodi:nodetypes="ccccccccc"
inkscape:path-effect="#path-effect3"
inkscape:original-d="m 8,8 v 8 h 11 v 40 h 8 V 16 H 38 V 8 Z" />
<g
id="g3"
style="stroke:#61ba31;stroke-opacity:1;fill:none">
<path
id="path2"
style="fill:none;stroke:#61ba31;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 44.834929,13.274537 C 51.52452,17.296808 56,24.625752 56,33 56,45.702549 45.702549,56 33,56"
sodipodi:nodetypes="csc" />
<path
style="fill:none;stroke:#61ba31;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="M 44,47 33,33 38,26"
id="path3" />
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -0,0 +1,3 @@
.productive {
box-shadow: 0.2em 0 0 var(--accent) inset;
}

View File

@ -0,0 +1,11 @@
onBeforeDelete = async row => {
await req(`${row.dataset.id}/on_delete/`).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
let popup = document.getElementById("delete-popup")
popup.querySelector(".project-count").innerText = res.projects
popup.querySelector(".task-count").innerText = res.tasks
}
})
}

View File

@ -1,4 +1,8 @@
.productive {
box-shadow: 0.2em 0 0 var(--accent) inset;
}
/*
.projects {
width: 100%;
max-width: 40em;
@ -21,4 +25,5 @@
.projects tbody tr:nth-child(even) {
background-color: var(--dark2);
}
}
*/

View File

@ -1,22 +1,25 @@
onBeforeDelete = async row => {
await req(`${row.dataset.id}/on_delete/`).then(res => {
return res.json()
}).then(res => {
if (res.status === "success") {
let popup = document.getElementById("delete-popup")
popup.querySelector(".task-count").innerText = res.tasks
}
})
}
window.addEventListener("load", () => {
document.querySelectorAll(".projects tbody tr").forEach(row => {
document.querySelectorAll(".list li").forEach(row => {
let id = row.dataset.id
let selector = row.querySelector(".parent-sel")
selector.addEventListener("change", () => {
let fd = new FormData()
fd.set("parent_id", selector.value)
let csrftoken = document.querySelector("input[name='csrfmiddlewaretoken']").value
fetch(`${id}/set_parent`, {
req(`${id}/set_parent/`, {
method: "POST",
body: fd,
headers: {
"X-CSRFToken": csrftoken
}
body: fd
})
})
row.querySelector("button.see").addEventListener("click", () => {
window.location.href = `${id}/`
})
})
})

View File

@ -35,12 +35,29 @@
input {
width: 100%;
text-align: right;
background: none;
color: var(--light1);
}
input[type="time"]::-webkit-calendar-picker-indicator {
display: none;
}
}
&.total {
text-align: right;
border-top: solid #71717185 1px;
td {
padding: 0.4em;
}
}
&.total2, &.remote-total {
text-align: center;
td {
padding: 0.4em;
}
}
}
}
@ -97,6 +114,10 @@
&:first-child {
text-align: left;
&:hover, &:hover ~ td {
background-color: rgba(255, 0, 0, 0.2);
}
}
&:nth-child(2) {
text-align: center;
@ -116,7 +137,7 @@
}
.separator {
height: 2em;
height: 4em;
}
}

View File

@ -1,68 +1,5 @@
let table
let prevMonthBtn, nextMonthBtn, month
let curMonth = new Date().getMonth()
const DAYS_SHORT = [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun"
]
const MONTHS = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December"
]
const SEC_MS = 1000
const MIN_MS = SEC_MS * 60
const HOUR_MS = MIN_MS * 60
const DAY_MS = HOUR_MS * 24
//////////////////////////////////////////////////////////
// //
// Taken from: https://weeknumber.com/how-to/javascript //
// //
//////////////////////////////////////////////////////////
Date.prototype.getWeek = function() {
let date = new Date(this.getTime())
date.setHours(0, 0, 0, 0)
// Thursday in current week decides the year.
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7)
// January 4 is always in week 1.
let week1 = new Date(date.getFullYear(), 0, 4)
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
}
function formatDate(date) {
let year = date.getFullYear().toString().padStart(4, "0")
let month = (date.getMonth() + 1).toString().padStart(2, "0")
let day = date.getDate().toString().padStart(2, "0")
return `${year}-${month}-${day}`
}
function getTemplate(cls) {
let elmt = document.querySelector(".template." + cls).cloneNode(true)
elmt.classList.remove("template")
return elmt
}
class Table {
constructor(elmt) {
@ -84,6 +21,10 @@ class Table {
this.startDate = today
this.endDate = today
this.totalProjects = 0
this.clockingTotals = []
this.clockingRemotes = []
this.update()
}
@ -133,7 +74,7 @@ class Table {
addClockings(date) {
let dates = this.clockings.querySelector(".dates")
let dateCell = document.createElement("th")
let weekDay = DAYS_SHORT[(date.getDay() + 1) % 7]
let weekDay = DAYS_SHORT[(date.getDay() + 6) % 7]
let monthDay = date.getDate().toString().padStart(2, "0")
dateCell.innerText = `${weekDay} ${monthDay}`
dates.appendChild(dateCell)
@ -141,18 +82,25 @@ class Table {
let clockings = this.clockings.querySelectorAll(".clocking")
clockings.forEach((clocking, i) => {
if (!(clocking.dataset.type || clocking.classList.contains("total"))) {
return;
}
let cell = clocking.insertCell(this.nDays + 1)
if (i === 5) {
if (clocking.classList.contains("total")) {
return
}
cell.replaceWith(this.timeInputTemplate.cloneNode(true))
let inputCell = this.timeInputTemplate.cloneNode(true)
let input = inputCell.querySelector("input")
input.addEventListener("input", () => {
this.setClocking(date, clocking.dataset.type, input.value)
})
cell.replaceWith(inputCell)
})
}
addWeek(startDate, length=7) {
let weekNum = startDate.getWeek()
let weekName = this.weekNames.insertCell(-1)
weekName.innerText = `Week n° ${weekNum}`
weekName.colSpan = length
@ -173,24 +121,50 @@ class Table {
this.columns.appendChild(col)
this.nDays++
}
let weekRemoteTotalCell = this.clockings.querySelector(".clocking.remote-total").insertCell(-1)
weekRemoteTotalCell.colSpan = length
let weekTotalCell = this.clockings.querySelector(".clocking.total2").insertCell(-1)
weekTotalCell.colSpan = length
}
addProject(id, name, sagexNum, times, isParent=false) {
addProject(id, name, sagexNum, times, isParent=false, isProductive=false) {
let row = this.times.insertRow(-1)
row.classList.add("project")
if (isParent) {
row.classList.add("parent")
if (isProductive) {
row.classList.add("productive")
}
}
row.dataset.id = id
row.insertCell(-1).innerText = name
row.insertCell(-1).innerText = sagexNum
let total = 0
times.forEach(time => {
if (time) {
total += time
}
let t = time.toString().split(".")
if (t.length > 1 ){
if (t.length > 1) {
t[1] = t[1].slice(0, 2)
}
row.insertCell(-1).innerText = t.join(".")
})
let totalStr = total.toString().split(".")
if (totalStr.length > 1) {
totalStr[1] = totalStr[1].slice(0, 2)
}
row.insertCell(-1).innerText = totalStr.join(".")
if (isParent) {
row.dataset.total = total
if (isProductive) {
this.totalProjects += total
}
}
row.insertCell(-1)
row.insertCell(-1)
row.insertCell(-1)
}
addMonth(anchorDate) {
@ -215,6 +189,8 @@ class Table {
this.addWeek(monday, len)
monday = nextMonday
}
this.clockings.querySelector(".clocking.total").insertCell(-1).rowSpan = 2
this.endDate = new Date(monday.valueOf() - monday.getDate() * DAY_MS)
}
@ -233,29 +209,143 @@ class Table {
}
displayData(data) {
this.displayClockings(data.clockings)
this.totalProjects = 0
data.parents.forEach(parent => {
this.addProject(
parent.id,
parent.name,
parent.project_num,
Array(this.nDays).fill(""),
true
)
let parentDurations = Array(this.nDays).fill(0)
parent.projects.forEach(project => {
let projects = parent.projects.map(project => {
let durations = Array(this.nDays).fill("")
project.tasks.forEach(task => {
let date = new Date(task.date)
let i = Math.floor((date.valueOf() - this.startDate.valueOf()) / DAY_MS)
durations[i] = task.duration / 60
let hours = task.duration / 60
durations[i] = hours
parentDurations[i] += hours
})
this.addProject(
project.id,
project.name,
"",
durations
)
return {
id: project.id,
name: project.name,
durations: durations
}
})
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)
})
})
this.updateTotals()
}
displayClockings(clockings) {
this.clockingTotals = Array(this.nDays).fill(0)
this.clockingRemotes = Array(this.nDays).fill(0)
clockings.forEach(clocking => {
let date = new Date(clocking.date)
if (date < this.startDate || date > this.endDate) {
return
}
let i = Math.floor((date - this.startDate) / DAY_MS)
this.clockings.querySelectorAll("tr.clocking[data-type]").forEach(row => {
let type = row.dataset.type
let cell = row.cells[i + 1]
if (clocking[type]) {
cell.querySelector("input").value = clocking[type]
}
})
let totalRow = this.clockings.querySelector(".clocking.total")
let totalCell = totalRow.cells[i + 1]
let hours = +clocking.total / 60
this.clockingTotals[i] = hours
totalCell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
if (clocking.remote !== null) {
let remoteParts = clocking.remote.split(":").map(v => +v)
let remoteHours = remoteParts[0] + remoteParts[1] / 60
if (remoteParts.length >= 3) {
remoteHours += remoteParts[2] / 3600
}
this.clockingRemotes[i] = remoteHours
}
})
}
setClocking(date, type, time) {
this.post(`/clockings/${formatDate(date)}/`, {
[type]: time
}).then(res => {
let row = this.clockings.querySelector(".clocking.total")
let i = Math.floor((date - this.startDate) / DAY_MS)
let cell = row.cells[i + 1]
let minutes = +res.clocking.total
let hours = minutes / 60
this.clockingTotals[i] = hours
cell.innerText = hours === 0 ? "" : Math.round(hours * 100) / 100
this.updateTotals()
})
}
updateTotals() {
let totalClockings = this.clockingTotals.reduce((a, b) => a + b, 0)
let totalProjects = this.totalProjects
this.clockings.querySelector(".clocking.total").cells[this.nDays + 1].innerText = Math.round(totalClockings * 100) / 100
let startI = 0
Array.from(this.clockings.querySelector(".clocking.remote-total").cells).forEach((cell, i) => {
let endI = startI + cell.colSpan
let remote = this.clockingRemotes.slice(startI, endI).reduce((a, b) => a + b, 0)
let hour = Math.floor(remote)
let min = Math.floor((remote - hour) * 60)
cell.innerText = hour.toString().padStart(2, "0") + ":" + min.toString().padStart(2, "0")
startI = endI
})
startI = 0
Array.from(this.clockings.querySelector(".clocking.total2").cells).forEach((cell, i) => {
let endI = startI + cell.colSpan
let total = this.clockingTotals.slice(startI, endI).reduce((a, b) => a + b, 0)
cell.innerText = Math.round(total * 100) / 100
startI = endI
})
if (totalClockings === 0) {
console.log("Total clockings = 0")
return
}
if (totalProjects === 0) {
console.log("Total projects = 0")
return
}
this.times.querySelectorAll(".project.parent.productive").forEach(parent => {
let total = +parent.dataset.total
let workingTimeRatio = total / totalClockings
let imputedTimeRatio = total / totalProjects
parent.cells[this.nDays + 3].innerText = formatPercentage(workingTimeRatio)
parent.cells[this.nDays + 4].innerText = formatPercentage(imputedTimeRatio)
let sagexTime = imputedTimeRatio * totalClockings
parent.cells[this.nDays + 5].innerText = Math.round(sagexTime * 100) / 100
})
}
post(endpoint, data) {
let fd = new FormData()
Object.entries(data).forEach(([key, value]) => {
fd.set(key, value)
})
return req(endpoint, {
method: "POST",
body: fd
}).then(res => {
return res.json()
})
}
}

View File

@ -1,15 +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.forms import ProjectForm, ImportForm
from dispatcher.models import Project, Parent
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, RealSageXHours
from dispatcher.serializers import ClockingSerializer, RealSageXHoursSerializer
def dashboard_view(request):
@ -21,19 +25,89 @@ def dashboard_view(request):
def projects_view(request):
context = {
"class_name": "parent",
"projects": Project.objects.all(),
"parents": Parent.objects.all()
}
return render(request, "projects.html", context)
def parent_view(request, id):
parent = get_object_or_404(Parent, id=id)
if request.method == "DELETE":
parent.delete()
return JsonResponse({"status": "success"})
context = {
"class": "parent",
"id": id
}
form = ParentForm(request.POST or None, request.FILES or None, instance=parent)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = ParentForm(instance=parent)
return render(request, "edit.html", context)
def parent_on_delete_view(request, id):
parent = get_object_or_404(Parent, id=id)
projects = parent.project_set.count()
tasks = Task.objects.filter(project__parent=parent).count()
return JsonResponse({
"status": "success",
"projects": projects,
"tasks": tasks
})
def project_on_delete_view(request, id):
project = get_object_or_404(Project, id=id)
tasks = project.task_set.count()
return JsonResponse({
"status": "success",
"tasks": tasks
})
def new_parent_view(request):
context = {
"class": "parent"
}
form = ParentForm(request.POST or None, request.FILES or None)
if form.is_valid():
form.save()
return redirect("parents")
context["form"] = form
return render(request, "add.html", context)
def project_view(request, id):
project = get_object_or_404(Project, id=id)
context = {}
if request.method == "DELETE":
project.delete()
return JsonResponse({"status": "success"})
context = {
"class": "project",
"id": id
}
form = ProjectForm(request.POST or None, request.FILES or None, instance=project)
if form.is_valid():
form.save()
context["form"] = ProjectForm(instance=project)
return render(request, "project.html", context)
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")
@ -41,7 +115,7 @@ def table_view(request):
@require_POST
def set_parent(request, id):
project = get_object_or_404(Project, id=id)
parent_id = request.POST.get("parent_id")
parent_id = request.POST.get("parent_id") or None
try:
parent = Parent.objects.get(id=parent_id)
except Parent.DoesNotExist:
@ -52,7 +126,6 @@ def set_parent(request, id):
return JsonResponse({"status": "success"})
def import_view(request):
if request.method == "POST":
form = ImportForm(request.POST, request.FILES)
@ -67,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",
@ -75,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(
@ -85,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
],
@ -126,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):
@ -135,9 +206,25 @@ class ParentsView(generic.ListView):
template_name = "parents.html"
context_object_name = "elements"
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)
context["parents"] = Parent.objects.all()
return context
def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
end_date = end_date + timedelta(days=1)
clockings = Clocking.objects.filter(
date__gte=start_date,
date__lte=end_date
)
parents = Parent.objects.all().order_by("id")
data = {
"parents": [
@ -145,6 +232,7 @@ def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
"id": parent.id,
"name": parent.name,
"project_num": parent.project_num,
"is_productive": parent.is_productive,
"projects": [
{
"id": project.id,
@ -164,6 +252,59 @@ def get_table_data(request, start_date: datetime.date, end_date: datetime.date):
]
}
for parent in parents
]
],
"clockings": ClockingSerializer(clockings, many=True).data
}
return JsonResponse({"status": "success", "data": data})
return JsonResponse({"status": "success", "data": data})
@require_POST
@csrf_exempt
def set_clocking(request, date: datetime.date):
clocking, created = Clocking.objects.get_or_create(date=date)
clocking.in_am = request.POST.get("in_am", clocking.in_am) or None
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
remote = request.POST.get("remote", clocking.remote) or None
if remote is not None:
remote = str_to_timedelta(remote)
else:
remote = timedelta()
clocking.remote = remote
clocking.save()
clocking.refresh_from_db()
return JsonResponse({
"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 +1,4 @@
Django~=5.1.5
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 %}

16
templates/add.html Normal file
View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% block title %}New {{ class }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">
<h2>New {{ class }}</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Create</button>
</form>
</div>
{% endblock %}

View File

@ -4,6 +4,7 @@
<head>
<meta charset="UTF-8">
<title>{% block title %}Title{% endblock %}</title>
<link rel="shortcut icon" href="{% static "logo.svg" %}" type="image/x-svg">
<link rel="stylesheet" href="{% static "base.css" %}">
<script src="{% static "base.js" %}"></script>
{% block head %}{% endblock %}
@ -11,6 +12,7 @@
<body>
<header>
<nav>
<a class="logo" href="{% url "dashboard" %}"><img src="{% static "logo.svg" %}" alt="Time Dispatcher logo"></a>
{% for nav_link in nav_links %}
<a href="{% url nav_link.view %}" {% if request.resolver_match.view_name == nav_link.view %}class="active"{% endif %}>{{ nav_link.label }}</a>
{% endfor %}
@ -20,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 %}

16
templates/edit.html Normal file
View File

@ -0,0 +1,16 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Editing {{ class }} {{ id }}{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<div class="container">
<h2>Editing {{ class }} {{ id }}</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
</div>
{% endblock %}

View File

@ -1,12 +1,19 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Import tasks{% endblock %}
{% block head %}
<link rel="stylesheet" href="{% static "form.css" %}">
{% endblock %}
{% block main %}
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="file">File</label>
<input type="file" name="file" id="file">
</div>
<button type="submit">Import</button>
</form>
<div class="container">
<h1>Import tasks</h1>
<form action="" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<div>
<label for="file">File</label>
<input type="file" name="file" id="file">
</div>
<button type="submit">Import</button>
</form>
</div>
{% endblock %}

View File

@ -2,18 +2,36 @@
{% load static %}
{% block head %}
<link rel="stylesheet" href="{% static "list.css" %}">
<script src="{% static "list.js" %}"></script>
{% block extra-head %}{% endblock %}
{% endblock %}
{% block main %}
<ul class="list">
{% for element in elements %}
<li data-id="{{ element.id }}">
{% block element %}{% endblock %}
<div class="actions">
{% block actions %}{% endblock %}
</div>
</li>
{% empty %}
<li class="empty">No {% block element_name %}{% endblock %} defined yet</li>
{% endfor %}
</ul>
{% csrf_token %}
<div class="list-wrapper">
<div class="list-header">
<button class="new">New {{ class_name }}</button>
</div>
<ul class="list">
{% for element in elements %}
<li data-id="{{ element.id }}" data-name="{% block element-name %}{% endblock %}" class="{% block extra-class %}{% endblock %}">
{% block element %}{% endblock %}
<div class="actions">
{% block actions %}{% endblock %}
</div>
</li>
{% empty %}
<li class="empty">No {{ class_name }} defined yet</li>
{% endfor %}
</ul>
</div>
<div class="popup" id="delete-popup">
<div class="popup-container">
<h2 class="title">Delete <span class="elmt-name"></span> ?</h2>
<p class="desc">{% block delete-desc %}{% endblock %}</p>
<div class="actions">
<button class="cancel">Cancel</button>
<button class="delete">Delete</button>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,11 +1,27 @@
{% extends "list.html" %}
{% load static %}
{% block title %}Parents{% endblock %}
{% block element_name %}parent{% endblock %}
{% block extra-head %}
<link rel="stylesheet" href="{% static "parents.css" %}">
<script src="{% static "parents.js" %}"></script>
{% endblock %}
{% block extra-class %}{% if element.is_productive %}productive{% endif %}{% endblock %}
{% block element-name %}{{ element.name }}{% endblock %}
{% block element %}
<div class="name">{{ element.name }}</div>
<div class="project_num">({{ element.project_num }})</div>
{% if element.project_num %}
<div class="project_num">({{ element.project_num }})</div>
{% endif %}
{% endblock %}
{% block actions %}
<button class="edit">Edit</button>
{% endblock %}
<button class="delete">Delete</button>
{% endblock %}
{% block delete-desc %}
Are sure you want to delete <span class="elmt-name"></span> ?
This action is <u>IRREVERSIBLE</u> ! By deleting this element, you will also delete:
<ul>
<li><span class="project-count"></span> project(s)</li>
<li><span class="task-count"></span> task imputation(s)</li>
</ul>
{% endblock %}

View File

@ -1,10 +0,0 @@
{% extends "base.html" %}
{% block main %}
<h2>Editing project</h2>
<form action="" method="POST">
{% csrf_token %}
{{ form }}
<button type="submit">Save</button>
</form>
{% endblock %}

View File

@ -1,39 +1,29 @@
{% extends "base.html" %}
{% extends "list.html" %}
{% load static %}
{% block title %}Projects{% endblock %}
{% block head %}
{% block extra-head %}
<link rel="stylesheet" href="{% static "projects.css" %}">
<script src="{% static "projects.js" %}"></script>
{% endblock %}
{% block footer %}{% endblock %}
{% block main %}
{% csrf_token %}
<table class="projects">
<thead>
<tr>
<th>Project</th>
<th>Parent</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr data-id="{{ project.id }}">
<td>{{ project.name }}</td>
<td>
<select class="parent-sel">
<option value=""></option>
{% for parent in parents %}
<option value="{{ parent.id }}" {% if project.parent.id == parent.id %}selected{% endif %}>{{ parent.name }}</option>
{% endfor %}
</select>
</td>
<td>
<button class="see">See</button>
<button class="delete">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
{% block extra-class %}{% if element.parent.is_productive %}productive{% endif %}{% endblock %}
{% block element-name %}{{ element.name }}{% endblock %}
{% block element %}
<div class="name">{{ element.name }}</div>
{% endblock %}
{% block actions %}
<select class="parent-sel">
<option value=""></option>
{% for parent in parents %}
<option value="{{ parent.id }}" {% if element.parent.id == parent.id %}selected{% endif %}>{{ parent.name }}</option>
{% endfor %}
</select>
<button class="edit">Edit</button>
<button class="delete">Delete</button>
{% endblock %}
{% block delete-desc %}
Are sure you want to delete <span class="elmt-name"></span> ?
This action is <u>IRREVERSIBLE</u> ! By deleting this element, you will also delete:
<ul>
<li><span class="task-count"></span> task imputation(s)</li>
</ul>
{% endblock %}

View File

@ -6,6 +6,7 @@
<script src="{% static "table.js" %}"></script>
{% endblock %}
{% block main %}
{% csrf_token %}
<div class="controls">
<button id="prev-month"><</button>
<div id="month">Month</div>
@ -27,28 +28,30 @@
<tr class="dates">
<td colspan="2"></td>
</tr>
<tr class="clocking">
<tr class="clocking" data-type="in_am">
<th colspan="2">Check-IN AM</th>
<th rowspan="6" class="total-header">TOTAL</th>
<th rowspan="6" class="total-header">% working time</th>
<th rowspan="6" class="total-header">% imputed time</th>
<th rowspan="6" class="total-header">time on sagex</th>
<th rowspan="8" class="total-header">% working time</th>
<th rowspan="8" class="total-header">% imputed time</th>
<th rowspan="8" class="total-header">time on sagex</th>
</tr>
<tr class="clocking">
<tr class="clocking" data-type="out_am">
<th colspan="2">Check-OUT AM</th>
</tr>
<tr class="clocking">
<tr class="clocking" data-type="in_pm">
<th colspan="2">Check-IN PM</th>
</tr>
<tr class="clocking">
<tr class="clocking" data-type="out_pm">
<th colspan="2">Check-OUT PM</th>
</tr>
<tr class="clocking">
<th colspan="2">Remote</th>
<tr class="clocking" data-type="remote">
<th colspan="2" rowspan="2">Remote</th>
</tr>
<tr class="clocking">
<th colspan="2">TOTAL</th>
<tr class="clocking remote-total"></tr>
<tr class="clocking total">
<th colspan="2" rowspan="2">TOTAL</th>
</tr>
<tr class="clocking total2"></tr>
</tbody>
<tr class="separator"></tr>
<tbody class="times">