summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--nix/default.nix2
-rw-r--r--nix/persistent-nix-shell.nix22
-rw-r--r--nix/worktime.nix22
-rwxr-xr-xpersistent-nix-shell20
-rwxr-xr-xworktime.py205
6 files changed, 272 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..41fbeb0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
**/result
diff --git a/nix/default.nix b/nix/default.nix
index cef8800..097d092 100644
--- a/nix/default.nix
+++ b/nix/default.nix
@@ -8,4 +8,6 @@ self: super:
8 rolling-directory = self.callPackage ./rolling-directory.nix {}; 8 rolling-directory = self.callPackage ./rolling-directory.nix {};
9 recv = self.callPackage ./recv.nix {}; 9 recv = self.callPackage ./recv.nix {};
10 notmuch-tcp = self.callPackage ./notmuch-tcp.nix {}; 10 notmuch-tcp = self.callPackage ./notmuch-tcp.nix {};
11 persistent-nix-shell = self.callPackage ./persistent-nix-shell.nix {};
12 worktime = self.callPackage ./worktime.nix {};
11} 13}
diff --git a/nix/persistent-nix-shell.nix b/nix/persistent-nix-shell.nix
new file mode 100644
index 0000000..7b371c6
--- /dev/null
+++ b/nix/persistent-nix-shell.nix
@@ -0,0 +1,22 @@
1{ stdenv
2, zsh
3}:
4
5stdenv.mkDerivation rec {
6 pname = "persistent-nix-shell";
7 version = "0.1";
8 src = ../persistent-nix-shell;
9
10 phases = [ "buildPhase" "installPhase" ];
11
12 inherit zsh;
13
14 buildPhase = ''
15 substituteAll $src persistent-nix-shell
16 '';
17
18 installPhase = ''
19 install -m 0755 -D -t $out/bin \
20 persistent-nix-shell
21 '';
22}
diff --git a/nix/worktime.nix b/nix/worktime.nix
new file mode 100644
index 0000000..ddab8b2
--- /dev/null
+++ b/nix/worktime.nix
@@ -0,0 +1,22 @@
1{ stdenv
2, python37
3}:
4
5stdenv.mkDerivation rec {
6 pname = "worktime";
7 version = "3";
8 src = ../worktime.py;
9
10 phases = [ "buildPhase" "installPhase" ];
11
12 python = python37.withPackages (ps: with ps; [pyxdg dateutil uritools requests configparser]);
13
14 buildPhase = ''
15 substituteAll $src worktime
16 '';
17
18 installPhase = ''
19 install -m 0755 -D -t $out/bin \
20 worktime
21 '';
22}
diff --git a/persistent-nix-shell b/persistent-nix-shell
new file mode 100755
index 0000000..a17f6de
--- /dev/null
+++ b/persistent-nix-shell
@@ -0,0 +1,20 @@
1#!@zsh@/bin/zsh
2
3set -e
4
5gcrootsDir=${PWD}/.nix-gc-roots
6
7if [[ ${#@} -ge 1 ]]; then
8 shellFile=${1}
9 shift
10else
11 shellFile=${PWD}/shell.nix
12fi
13
14set -x
15mkdir -p ${gcrootsDir}
16nix-instantiate ${shellFile} --indirect --add-root ${gcrootsDir}/shell.drv
17nix-store --indirect --add-root ${gcrootsDir}/shell.dep --realise $(nix-store --query --references ${gcrootsDir}/shell.drv)
18set +x
19
20exec nix-shell $(readlink ${gcrootsDir}/shell.drv) ${@}
diff --git a/worktime.py b/worktime.py
new file mode 100755
index 0000000..9c72d30
--- /dev/null
+++ b/worktime.py
@@ -0,0 +1,205 @@
1#!@python@/bin/python
2
3import requests
4from requests.auth import HTTPBasicAuth
5from datetime import *
6from xdg import (BaseDirectory)
7import configparser
8from uritools import uricompose
9
10from dateutil.easter import *
11from dateutil.tz import *
12from dateutil.parser import isoparse
13
14from enum import Enum
15
16from math import (copysign, ceil)
17
18class TogglAPISection(Enum):
19 TOGGL = '/api/v8'
20 REPORTS = '/reports/api/v2'
21
22class TogglAPI(object):
23 def __init__(self, api_token, workspace_id):
24 self._api_token = api_token
25 self._workspace_id = workspace_id
26
27 def _make_url(self, api=TogglAPISection.TOGGL, section=['time_entries', 'current'], params={}):
28 if api is TogglAPISection.REPORTS:
29 params.update({'user_agent': 'worktime', 'workspace_id': self._workspace_id})
30
31 api_path = api.value
32 section_path = '/'.join(section)
33 return uricompose(scheme='https', host='www.toggl.com', path=f"{api_path}/{section_path}", query=params)
34
35 def _query(self, url, method):
36
37 headers = {'content-type': 'application/json'}
38
39 if method == 'GET':
40 return requests.get(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
41 elif method == 'POST':
42 return requests.post(url, headers=headers, auth=HTTPBasicAuth(self._api_token, 'api_token'))
43 else:
44 raise ValueError(f"Undefined HTTP method “{method}”")
45
46 def get_billable_hours(self, start_date, end_date=datetime.now(timezone.utc)):
47 url = self._make_url(api = TogglAPISection.REPORTS, section = ['summary'], params={'since': start_date.astimezone(timezone.utc).isoformat(), 'until': end_date.astimezone(timezone.utc).isoformat()})
48 r = self._query(url = url, method='GET')
49 return timedelta(milliseconds=r.json()['total_billable'])
50
51 def get_running_clock(self, now=datetime.now(timezone.utc)):
52 url = self._make_url(api = TogglAPISection.TOGGL, section = ['time_entries', 'current'])
53 r = self._query(url = url, method='GET').json()
54
55 if not r or not r['data'] or not r['data']['billable']:
56 return None
57
58 start = isoparse(r['data']['start'])
59
60 return now - start if start <= now else None
61
62class Worktime(object):
63 time_to_work = timedelta()
64 time_worked = timedelta()
65 running_entry = None
66 now = datetime.now(tzlocal())
67 time_pulled_forward = timedelta()
68 is_workday = False
69
70 def __init__(self, start_datetime=None, end_datetime=None, now=None):
71 if now:
72 self.now = now
73
74 config = configparser.ConfigParser()
75 config_dir = BaseDirectory.load_first_config('worktime')
76 config.read(f"{config_dir}/worktime.ini")
77 api = TogglAPI(api_token=config['TOGGL']['ApiToken'], workspace_id=config['TOGGL']['Workspace'])
78 date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d')
79
80 start_date = start_datetime or datetime.strptime(config['WORKTIME']['StartDate'], date_format).replace(tzinfo=tzlocal())
81 end_date = end_datetime or self.now
82
83 hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40))
84 workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')])
85 hours_per_day = hours_per_week / len(workdays)
86
87 holidays = set()
88
89 for year in range(start_date.year, end_date.year + 1):
90 y_easter = datetime.combine(easter(year), time(), tzinfo=tzlocal())
91
92 # Legal holidays in munich, bavaria
93 holidays.add(datetime(year, 1, 1, tzinfo=tzlocal()).date())
94 holidays.add(datetime(year, 1, 6, tzinfo=tzlocal()).date())
95 holidays.add((y_easter+timedelta(days=-2)).date())
96 holidays.add((y_easter+timedelta(days=+1)).date())
97 holidays.add(datetime(year, 5, 1, tzinfo=tzlocal()).date())
98 holidays.add((y_easter+timedelta(days=+39)).date())
99 holidays.add((y_easter+timedelta(days=+50)).date())
100 holidays.add((y_easter+timedelta(days=+60)).date())
101 holidays.add(datetime(year, 8, 15, tzinfo=tzlocal()).date())
102 holidays.add(datetime(year, 10, 3, tzinfo=tzlocal()).date())
103 holidays.add(datetime(year, 11, 1, tzinfo=tzlocal()).date())
104 holidays.add(datetime(year, 12, 25, tzinfo=tzlocal()).date())
105 holidays.add(datetime(year, 12, 26, tzinfo=tzlocal()).date())
106
107 try:
108 with open(f"{config_dir}/excused", 'r') as excused:
109 for line in excused:
110 stripped_line = line.strip()
111 if stripped_line:
112 holidays.add(datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date())
113 except IOError as e:
114 if e.errno != 2:
115 raise e
116
117 pull_forward = dict()
118
119 try:
120 with open(f"{config_dir}/pull-forward", 'r') as excused:
121 for line in excused:
122 stripped_line = line.strip()
123 if stripped_line:
124 [hours, date] = stripped_line.split(' ')
125 day = datetime.strptime(date, date_format).replace(tzinfo=tzlocal()).date()
126 if day > end_date.date():
127 pull_forward[day] = timedelta(hours = float(hours))
128 except IOError as e:
129 if e.errno != 2:
130 raise e
131
132
133 days_to_work = set()
134
135 start_day = start_date.date()
136 end_day = end_date.date()
137 if pull_forward:
138 end_day = max(end_day, max(list(pull_forward)))
139
140 for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]:
141 if day.isoweekday() in workdays and not day in holidays:
142 days_to_work.add(day)
143
144 self.is_workday = self.now.date() in days_to_work
145
146 self.time_to_work = timedelta(hours = len([day for day in days_to_work if day <= end_date.date()]) * hours_per_day)
147 for day in list(pull_forward):
148 days_forward = set([d for d in days_to_work if d >= end_date.date() and d < day and not d in pull_forward])
149 hours_per_day_forward = pull_forward[day] / len(days_forward) if len(days_forward) > 0 else timedelta()
150 days_forward.discard(end_date.date())
151 self.time_pulled_forward += pull_forward[day] - hours_per_day_forward * len(days_forward)
152 self.time_to_work += self.time_pulled_forward
153
154 self.time_worked = api.get_billable_hours(start_date, self.now)
155 self.running_entry = api.get_running_clock(self.now)
156
157 if self.running_entry:
158 self.time_worked += self.running_entry
159
160def main():
161 worktime = Worktime()
162
163 def difference_string(difference):
164 total_minutes_difference = round(difference / timedelta(minutes = 1))
165 (hours_difference, minutes_difference) = divmod(abs(total_minutes_difference), 60)
166 sign = '' if total_minutes_difference >= 0 else '-'
167
168 difference_string = f"{sign}"
169 if hours_difference != 0:
170 difference_string += f"{hours_difference}h"
171 if hours_difference == 0 or minutes_difference != 0:
172 difference_string += f"{minutes_difference}m"
173
174 return difference_string
175
176
177 difference = worktime.time_to_work - worktime.time_worked
178 total_minutes_difference = 5 * ceil(difference / timedelta(minutes = 5))
179
180 if worktime.running_entry and abs(difference) < timedelta(days = 1) and (total_minutes_difference > 0 or abs(worktime.running_entry) >= abs(difference)) :
181 clockout_time = worktime.now + difference
182 clockout_time += (5 - clockout_time.minute % 5) * timedelta(minutes = 1)
183 clockout_time = clockout_time.replace(second = 0, microsecond = 0)
184
185 if total_minutes_difference >= 0:
186 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
187 print("{difference_string}/{clockout_time}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")))
188 else:
189 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
190 print("{clockout_time}/{difference_string}".format(difference_string = difference_string, clockout_time = clockout_time.strftime("%H:%M")))
191 else:
192 if worktime.running_entry:
193 difference_string = difference_string(abs(total_minutes_difference) * timedelta(minutes = 1))
194 indicator = '↘' if total_minutes_difference >= 0 else '↗' # '\u25b6'
195
196 print(f"{indicator} {difference_string}")
197 else:
198 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
199 if worktime.is_workday:
200 print(difference_string)
201 else:
202 print(f"({difference_string})")
203
204if __name__ == "__main__":
205 sys.exit(main())