diff options
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | nix/default.nix | 2 | ||||
-rw-r--r-- | nix/persistent-nix-shell.nix | 22 | ||||
-rw-r--r-- | nix/worktime.nix | 22 | ||||
-rwxr-xr-x | persistent-nix-shell | 20 | ||||
-rwxr-xr-x | worktime.py | 205 |
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 | |||
5 | stdenv.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 | |||
5 | stdenv.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 | |||
3 | set -e | ||
4 | |||
5 | gcrootsDir=${PWD}/.nix-gc-roots | ||
6 | |||
7 | if [[ ${#@} -ge 1 ]]; then | ||
8 | shellFile=${1} | ||
9 | shift | ||
10 | else | ||
11 | shellFile=${PWD}/shell.nix | ||
12 | fi | ||
13 | |||
14 | set -x | ||
15 | mkdir -p ${gcrootsDir} | ||
16 | nix-instantiate ${shellFile} --indirect --add-root ${gcrootsDir}/shell.drv | ||
17 | nix-store --indirect --add-root ${gcrootsDir}/shell.dep --realise $(nix-store --query --references ${gcrootsDir}/shell.drv) | ||
18 | set +x | ||
19 | |||
20 | exec 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 | |||
3 | import requests | ||
4 | from requests.auth import HTTPBasicAuth | ||
5 | from datetime import * | ||
6 | from xdg import (BaseDirectory) | ||
7 | import configparser | ||
8 | from uritools import uricompose | ||
9 | |||
10 | from dateutil.easter import * | ||
11 | from dateutil.tz import * | ||
12 | from dateutil.parser import isoparse | ||
13 | |||
14 | from enum import Enum | ||
15 | |||
16 | from math import (copysign, ceil) | ||
17 | |||
18 | class TogglAPISection(Enum): | ||
19 | TOGGL = '/api/v8' | ||
20 | REPORTS = '/reports/api/v2' | ||
21 | |||
22 | class 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 | |||
62 | class 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 | |||
160 | def 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 | |||
204 | if __name__ == "__main__": | ||
205 | sys.exit(main()) | ||