diff options
-rw-r--r-- | overlays/worktime/shell.nix | 5 | ||||
-rwxr-xr-x | overlays/worktime/worktime.py | 59 |
2 files changed, 55 insertions, 9 deletions
diff --git a/overlays/worktime/shell.nix b/overlays/worktime/shell.nix new file mode 100644 index 00000000..18d2a68a --- /dev/null +++ b/overlays/worktime/shell.nix | |||
@@ -0,0 +1,5 @@ | |||
1 | { pkgs ? import <nixpkgs> {} }: | ||
2 | |||
3 | pkgs.mkShell { | ||
4 | buildInputs = [(pkgs.python310.withPackages (ps: with ps; [pyxdg python-dateutil uritools requests configparser tabulate]))]; | ||
5 | } | ||
diff --git a/overlays/worktime/worktime.py b/overlays/worktime/worktime.py index 1fc00061..310fa084 100755 --- a/overlays/worktime/worktime.py +++ b/overlays/worktime/worktime.py | |||
@@ -27,6 +27,9 @@ from sys import stderr | |||
27 | 27 | ||
28 | from tabulate import tabulate | 28 | from tabulate import tabulate |
29 | 29 | ||
30 | from itertools import groupby | ||
31 | from functools import cache | ||
32 | |||
30 | class TogglAPISection(Enum): | 33 | class TogglAPISection(Enum): |
31 | TOGGL = '/api/v8' | 34 | TOGGL = '/api/v8' |
32 | REPORTS = '/reports/api/v2' | 35 | REPORTS = '/reports/api/v2' |
@@ -118,8 +121,10 @@ class Worktime(object): | |||
118 | leave_days = set() | 121 | leave_days = set() |
119 | leave_budget = dict() | 122 | leave_budget = dict() |
120 | time_per_day = None | 123 | time_per_day = None |
124 | workdays = None | ||
121 | 125 | ||
122 | @staticmethod | 126 | @staticmethod |
127 | @cache | ||
123 | def holidays(year): | 128 | def holidays(year): |
124 | holidays = dict() | 129 | holidays = dict() |
125 | 130 | ||
@@ -149,6 +154,13 @@ class Worktime(object): | |||
149 | config.read(f"{config_dir}/worktime.ini") | 154 | config.read(f"{config_dir}/worktime.ini") |
150 | return config | 155 | return config |
151 | 156 | ||
157 | def ordinal_workday(self, date): | ||
158 | start_date = datetime(date.year, 1, 1, tzinfo=tzlocal()).date() | ||
159 | return len([1 for offset in range(0, (date - start_date).days + 1) if self.would_be_workday(start_date + timedelta(days = offset))]) | ||
160 | |||
161 | def would_be_workday(self, date): | ||
162 | return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1) | ||
163 | |||
152 | def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): | 164 | def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): |
153 | self.include_running = include_running | 165 | self.include_running = include_running |
154 | self.force_day_to_work = force_day_to_work | 166 | self.force_day_to_work = force_day_to_work |
@@ -178,8 +190,8 @@ class Worktime(object): | |||
178 | 190 | ||
179 | 191 | ||
180 | hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) | 192 | hours_per_week = float(config.get('WORKTIME', 'HoursPerWeek', fallback=40)) |
181 | workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) | 193 | self.workdays = set([int(d.strip()) for d in config.get('WORKTIME', 'Workdays', fallback='1,2,3,4,5').split(',')]) |
182 | self.time_per_day = timedelta(hours = hours_per_week) / len(workdays) | 194 | self.time_per_day = timedelta(hours = hours_per_week) / len(self.workdays) |
183 | 195 | ||
184 | holidays = dict() | 196 | holidays = dict() |
185 | 197 | ||
@@ -237,7 +249,7 @@ class Worktime(object): | |||
237 | if end_date.date() < day or day < start_date.date(): | 249 | if end_date.date() < day or day < start_date.date(): |
238 | continue | 250 | continue |
239 | 251 | ||
240 | if excused_kind == 'leave' and not (day in holidays and holidays[day] >= self.time_per_day) and day.isoweekday() in workdays: | 252 | if excused_kind == 'leave' and self.would_be_workday(day): |
241 | self.leave_days.add(day) | 253 | self.leave_days.add(day) |
242 | holidays[day] = time | 254 | holidays[day] = time |
243 | except IOError as e: | 255 | except IOError as e: |
@@ -256,7 +268,7 @@ class Worktime(object): | |||
256 | if stripped_line: | 268 | if stripped_line: |
257 | [hours, datestr] = stripped_line.split(' ') | 269 | [hours, datestr] = stripped_line.split(' ') |
258 | constr = datestr.split(',') | 270 | constr = datestr.split(',') |
259 | for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(workdays)) * 2))]: | 271 | for d in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1 + int(timedelta(hours = float(hours)).total_seconds() / 60 * (7 / len(self.workdays)) * 2))]: |
260 | for c in constr: | 272 | for c in constr: |
261 | if c in calendar.day_abbr: | 273 | if c in calendar.day_abbr: |
262 | if not d.strftime('%a') == c: break | 274 | if not d.strftime('%a') == c: break |
@@ -283,7 +295,7 @@ class Worktime(object): | |||
283 | end_day = max(end_day, max(list(pull_forward))) | 295 | end_day = max(end_day, max(list(pull_forward))) |
284 | 296 | ||
285 | for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: | 297 | for day in [start_day + timedelta(days = x) for x in range(0, (end_day - start_day).days + 1)]: |
286 | if day.isoweekday() in workdays: | 298 | if day.isoweekday() in self.workdays: |
287 | time_to_work = self.time_per_day | 299 | time_to_work = self.time_per_day |
288 | if day in holidays.keys(): | 300 | if day in holidays.keys(): |
289 | time_to_work -= holidays[day] | 301 | time_to_work -= holidays[day] |
@@ -467,7 +479,8 @@ def holidays(year, **args): | |||
467 | print(tabulate(table_data, tablefmt="plain")) | 479 | print(tabulate(table_data, tablefmt="plain")) |
468 | 480 | ||
469 | def leave(year, table, **args): | 481 | def leave(year, table, **args): |
470 | worktime = Worktime(**dict(**args, end_datetime = datetime(year = year + 1, month = 1, day = 1, tzinfo=tzlocal()) - timedelta(microseconds=1))) | 482 | def_year = datetime.now(tzlocal()).year |
483 | worktime = Worktime(**dict(**args, end_datetime = datetime(year = (year if year else def_year) + 1, month = 1, day = 1, tzinfo=tzlocal()) - timedelta(microseconds=1))) | ||
471 | config = Worktime.config() | 484 | config = Worktime.config() |
472 | date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') | 485 | date_format = config.get('WORKTIME', 'DateFormat', fallback='%Y-%m-%d') |
473 | leave_expires = config.get('WORKTIME', 'LeaveExpires', fallback=None) | 486 | leave_expires = config.get('WORKTIME', 'LeaveExpires', fallback=None) |
@@ -475,6 +488,7 @@ def leave(year, table, **args): | |||
475 | leave_expires = datetime.strptime(leave_expires, '%m-%d').date() | 488 | leave_expires = datetime.strptime(leave_expires, '%m-%d').date() |
476 | 489 | ||
477 | leave_budget = deepcopy(worktime.leave_budget) | 490 | leave_budget = deepcopy(worktime.leave_budget) |
491 | year_leave_budget = deepcopy(worktime.leave_budget) if year else None | ||
478 | years = sorted(leave_budget.keys()) | 492 | years = sorted(leave_budget.keys()) |
479 | for day in sorted(worktime.leave_days): | 493 | for day in sorted(worktime.leave_days): |
480 | for iyear in years: | 494 | for iyear in years: |
@@ -484,18 +498,45 @@ def leave(year, table, **args): | |||
484 | continue | 498 | continue |
485 | 499 | ||
486 | leave_budget[iyear] -= 1 | 500 | leave_budget[iyear] -= 1 |
501 | if year_leave_budget and day.year < year: | ||
502 | year_leave_budget[iyear] -= 1 | ||
487 | break | 503 | break |
488 | else: | 504 | else: |
489 | print(f'Unaccounted leave: {day}', file=stderr) | 505 | print(f'Unaccounted leave: {day}', file=stderr) |
490 | 506 | ||
491 | if table: | 507 | if table and year: |
508 | table_data = [] | ||
509 | leave_days = sorted([day for day in worktime.leave_days if day.year == year and worktime.would_be_workday(day)]) | ||
510 | |||
511 | count = 0 | ||
512 | for _, group in groupby(enumerate(leave_days), lambda kv: kv[0] - worktime.ordinal_workday(kv[1])): | ||
513 | group = list(map(lambda kv: kv[1], group)) | ||
514 | |||
515 | for day in group: | ||
516 | for iyear in years: | ||
517 | if day > leave_expires.replace(year = iyear + 1): | ||
518 | continue | ||
519 | if year_leave_budget[iyear] <= 0: | ||
520 | continue | ||
521 | |||
522 | year_leave_budget[iyear] -= 1 | ||
523 | break | ||
524 | |||
525 | next_count = count + len(group) | ||
526 | if len(group) > 1: | ||
527 | table_data.append([count, group[0].strftime('%m-%d') + '--' + group[-1].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) | ||
528 | else: | ||
529 | table_data.append([count, group[0].strftime('%m-%d'), len(group), sum(year_leave_budget.values())]) | ||
530 | count = next_count | ||
531 | print(tabulate(table_data, tablefmt="plain")) | ||
532 | elif table: | ||
492 | table_data = [] | 533 | table_data = [] |
493 | for year, days in leave_budget.items(): | 534 | for year, days in leave_budget.items(): |
494 | leave_days = sorted([day for day in worktime.leave_days if day.year == year]) | 535 | leave_days = sorted([day for day in worktime.leave_days if day.year == year]) |
495 | table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]] | 536 | table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]] |
496 | print(tabulate(table_data, tablefmt="plain")) | 537 | print(tabulate(table_data, tablefmt="plain")) |
497 | else: | 538 | else: |
498 | print(leave_budget[year]) | 539 | print(leave_budget[year if year else def_year]) |
499 | 540 | ||
500 | def main(): | 541 | def main(): |
501 | parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') | 542 | parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') |
@@ -513,7 +554,7 @@ def main(): | |||
513 | holidays_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate holidays for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?') | 554 | holidays_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate holidays for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?') |
514 | holidays_parser.set_defaults(cmd = holidays) | 555 | holidays_parser.set_defaults(cmd = holidays) |
515 | leave_parser = subparsers.add_parser('leave') | 556 | leave_parser = subparsers.add_parser('leave') |
516 | leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = datetime.now(tzlocal()).year, nargs='?') | 557 | leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?') |
517 | leave_parser.add_argument('--table', action = 'store_true') | 558 | leave_parser.add_argument('--table', action = 'store_true') |
518 | leave_parser.set_defaults(cmd = leave) | 559 | leave_parser.set_defaults(cmd = leave) |
519 | args = parser.parse_args() | 560 | args = parser.parse_args() |