summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xoverlays/worktime/worktime/__main__.py170
1 files changed, 134 insertions, 36 deletions
diff --git a/overlays/worktime/worktime/__main__.py b/overlays/worktime/worktime/__main__.py
index 2dc9ed72..9bc7ac3b 100755
--- a/overlays/worktime/worktime/__main__.py
+++ b/overlays/worktime/worktime/__main__.py
@@ -26,12 +26,14 @@ from sys import stderr
26from tabulate import tabulate 26from tabulate import tabulate
27 27
28from itertools import groupby 28from itertools import groupby
29from functools import cache 29from functools import cache, partial
30 30
31import backoff 31import backoff
32 32
33from pathlib import Path 33from pathlib import Path
34 34
35from collections import defaultdict
36
35 37
36class TogglAPISection(Enum): 38class TogglAPISection(Enum):
37 TOGGL = '/api/v8' 39 TOGGL = '/api/v8'
@@ -168,7 +170,7 @@ class Worktime(object):
168 running_entry = None 170 running_entry = None
169 now = datetime.now(tzlocal()) 171 now = datetime.now(tzlocal())
170 time_pulled_forward = timedelta() 172 time_pulled_forward = timedelta()
171 is_workday = False 173 now_is_workday = False
172 include_running = True 174 include_running = True
173 time_to_work = None 175 time_to_work = None
174 force_day_to_work = True 176 force_day_to_work = True
@@ -213,6 +215,9 @@ class Worktime(object):
213 def would_be_workday(self, date): 215 def would_be_workday(self, date):
214 return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1) 216 return date.isoweekday() in self.workdays and date not in set(day for (day, val) in Worktime.holidays(date.year).items() if val >= 1)
215 217
218 def is_workday(self, date, extra=True):
219 return date in self.days_to_work or (extra and date in self.extra_days_to_work)
220
216 def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs): 221 def __init__(self, start_datetime=None, end_datetime=None, now=None, include_running=True, force_day_to_work=True, **kwargs):
217 self.include_running = include_running 222 self.include_running = include_running
218 self.force_day_to_work = force_day_to_work 223 self.force_day_to_work = force_day_to_work
@@ -229,8 +234,8 @@ class Worktime(object):
229 ) 234 )
230 date_format = config.get("WORKTIME", {}).get("DateFormat", '%Y-%m-%d') 235 date_format = config.get("WORKTIME", {}).get("DateFormat", '%Y-%m-%d')
231 236
232 start_date = start_datetime or datetime.strptime(config.get("WORKTIME", {}).get("StartDate"), date_format).replace(tzinfo=tzlocal()) 237 self.start_date = start_datetime or datetime.strptime(config.get("WORKTIME", {}).get("StartDate"), date_format).replace(tzinfo=tzlocal())
233 end_date = end_datetime or self.now 238 self.end_date = end_datetime or self.now
234 239
235 try: 240 try:
236 with open(Path(config_dir) / "reset", 'r') as reset: 241 with open(Path(config_dir) / "reset", 'r') as reset:
@@ -238,8 +243,8 @@ class Worktime(object):
238 stripped_line = line.strip() 243 stripped_line = line.strip()
239 reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()) 244 reset_date = datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal())
240 245
241 if reset_date > start_date and reset_date <= end_date: 246 if reset_date > self.start_date and reset_date <= self.end_date:
242 start_date = reset_date 247 self.start_date = reset_date
243 except IOError as e: 248 except IOError as e:
244 if e.errno != 2: 249 if e.errno != 2:
245 raise e 250 raise e
@@ -252,11 +257,11 @@ class Worktime(object):
252 holidays = dict() 257 holidays = dict()
253 258
254 leave_per_year = int(config.get("WORKTIME", {}).get("LeavePerYear", 30)) 259 leave_per_year = int(config.get("WORKTIME", {}).get("LeavePerYear", 30))
255 for year in range(start_date.year, end_date.year + 1): 260 for year in range(self.start_date.year, self.end_date.year + 1):
256 holidays |= {k: v * self.time_per_day for k, v in Worktime.holidays(year).items()} 261 holidays |= {k: v * self.time_per_day for k, v in Worktime.holidays(year).items()}
257 leave_frac = 1 262 leave_frac = 1
258 if date(year, 1, 1) < start_date.date(): 263 if date(year, 1, 1) < self.start_date.date():
259 leave_frac = (date(year + 1, 1, 1) - start_date.date()) / (date(year + 1, 1, 1) - date(year, 1, 1)) 264 leave_frac = (date(year + 1, 1, 1) - self.start_date.date()) / (date(year + 1, 1, 1) - date(year, 1, 1))
260 self.leave_budget |= {year: floor(leave_per_year * leave_frac)} 265 self.leave_budget |= {year: floor(leave_per_year * leave_frac)}
261 266
262 try: 267 try:
@@ -266,7 +271,7 @@ class Worktime(object):
266 if stripped_line: 271 if stripped_line:
267 [datestr, count] = stripped_line.split(' ') 272 [datestr, count] = stripped_line.split(' ')
268 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() 273 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date()
269 if day != start_date.date(): 274 if day != self.start_date.date():
270 continue 275 continue
271 276
272 self.leave_budget[day.year] = (self.leave_budget[day.year] if day.year in self.leave_budget else 0) + int(count) 277 self.leave_budget[day.year] = (self.leave_budget[day.year] if day.year in self.leave_budget else 0) + int(count)
@@ -302,7 +307,7 @@ class Worktime(object):
302 parse_datestr(stripped_line) 307 parse_datestr(stripped_line)
303 308
304 for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]: 309 for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]:
305 if end_date.date() < day or day < start_date.date(): 310 if self.end_date.date() < day or day < self.start_date.date():
306 continue 311 continue
307 312
308 if excused_kind == 'leave' and self.would_be_workday(day): 313 if excused_kind == 'leave' and self.would_be_workday(day):
@@ -314,8 +319,8 @@ class Worktime(object):
314 319
315 pull_forward = dict() 320 pull_forward = dict()
316 321
317 start_day = start_date.date() 322 start_day = self.start_date.date()
318 end_day = end_date.date() 323 end_day = self.end_date.date()
319 324
320 try: 325 try:
321 with open(Path(config_dir) / "pull-forward", 'r') as excused: 326 with open(Path(config_dir) / "pull-forward", 'r') as excused:
@@ -339,13 +344,13 @@ class Worktime(object):
339 else: 344 else:
340 if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break 345 if not d == datetime.strptime(c, date_format).replace(tzinfo=tzlocal()).date(): break
341 else: 346 else:
342 if d >= end_date.date(): 347 if d >= self.end_date.date():
343 pull_forward[d] = min(timedelta(hours = float(hours)), self.time_per_day - (holidays[d] if d in holidays else timedelta())) 348 pull_forward[d] = min(timedelta(hours = float(hours)), self.time_per_day - (holidays[d] if d in holidays else timedelta()))
344 except IOError as e: 349 except IOError as e:
345 if e.errno != 2: 350 if e.errno != 2:
346 raise e 351 raise e
347 352
348 days_to_work = dict() 353 self.days_to_work = dict()
349 354
350 if pull_forward: 355 if pull_forward:
351 end_day = max(end_day, max(list(pull_forward))) 356 end_day = max(end_day, max(list(pull_forward)))
@@ -356,9 +361,9 @@ class Worktime(object):
356 if day in holidays.keys(): 361 if day in holidays.keys():
357 time_to_work -= holidays[day] 362 time_to_work -= holidays[day]
358 if time_to_work > timedelta(): 363 if time_to_work > timedelta():
359 days_to_work[day] = time_to_work 364 self.days_to_work[day] = time_to_work
360 365
361 extra_days_to_work = dict() 366 self.extra_days_to_work = dict()
362 367
363 try: 368 try:
364 with open(Path(config_dir) / "days-to-work", 'r') as extra_days_to_work_file: 369 with open(Path(config_dir) / "days-to-work", 'r') as extra_days_to_work_file:
@@ -369,15 +374,15 @@ class Worktime(object):
369 if len(splitLine) == 2: 374 if len(splitLine) == 2:
370 [hours, datestr] = splitLine 375 [hours, datestr] = splitLine
371 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date() 376 day = datetime.strptime(datestr, date_format).replace(tzinfo=tzlocal()).date()
372 extra_days_to_work[day] = timedelta(hours = float(hours)) 377 self.extra_days_to_work[day] = timedelta(hours = float(hours))
373 else: 378 else:
374 extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = self.time_per_day 379 self.extra_days_to_work[datetime.strptime(stripped_line, date_format).replace(tzinfo=tzlocal()).date()] = self.time_per_day
375 except IOError as e: 380 except IOError as e:
376 if e.errno != 2: 381 if e.errno != 2:
377 raise e 382 raise e
378 383
379 384
380 self.is_workday = self.now.date() in days_to_work or self.now.date() in extra_days_to_work 385 self.now_is_workday = self.is_workday(self.now.date())
381 386
382 self.time_worked = timedelta() 387 self.time_worked = timedelta()
383 388
@@ -387,37 +392,37 @@ class Worktime(object):
387 if self.running_entry: 392 if self.running_entry:
388 self.time_worked += self.running_entry 393 self.time_worked += self.running_entry
389 394
390 if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in days_to_work or self.now.date() in extra_days_to_work): 395 if self.running_entry and self.include_running and self.force_day_to_work and not (self.now.date() in self.days_to_work or self.now.date() in self.extra_days_to_work):
391 extra_days_to_work[self.now.date()] = timedelta() 396 self.extra_days_to_work[self.now.date()] = timedelta()
392 397
393 self.time_to_work = sum([days_to_work[day] for day in days_to_work.keys() if day <= end_date.date()], timedelta()) 398 self.time_to_work = sum([self.days_to_work[day] for day in self.days_to_work.keys() if day <= self.end_date.date()], timedelta())
394 for day in [d for d in list(pull_forward) if d > end_date.date()]: 399 for day in [d for d in list(pull_forward) if d > self.end_date.date()]:
395 days_forward = set([d for d in days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) 400 days_forward = set([d for d in self.days_to_work.keys() if d >= self.end_date.date() and d < day and (not d in pull_forward or d == self.end_date.date())])
396 extra_days_forward = set([d for d in extra_days_to_work.keys() if d >= end_date.date() and d < day and (not d in pull_forward or d == end_date.date())]) 401 extra_days_forward = set([d for d in self.extra_days_to_work.keys() if d >= self.end_date.date() and d < day and (not d in pull_forward or d == self.end_date.date())])
397 days_forward = days_forward.union(extra_days_forward) 402 days_forward = days_forward.union(extra_days_forward)
398 403
399 extra_day_time_left = timedelta() 404 extra_day_time_left = timedelta()
400 for extra_day in extra_days_forward: 405 for extra_day in extra_days_forward:
401 day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) 406 day_time = max(timedelta(), self.time_per_day - self.extra_days_to_work[extra_day])
402 extra_day_time_left += day_time 407 extra_day_time_left += day_time
403 extra_day_time = min(extra_day_time_left, pull_forward[day]) 408 extra_day_time = min(extra_day_time_left, pull_forward[day])
404 time_forward = pull_forward[day] - extra_day_time 409 time_forward = pull_forward[day] - extra_day_time
405 if extra_day_time_left > timedelta(): 410 if extra_day_time_left > timedelta():
406 for extra_day in extra_days_forward: 411 for extra_day in extra_days_forward:
407 day_time = max(timedelta(), self.time_per_day - extra_days_to_work[extra_day]) 412 day_time = max(timedelta(), self.time_per_day - self.extra_days_to_work[extra_day])
408 extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left) 413 self.extra_days_to_work[extra_day] += extra_day_time * (day_time / extra_day_time_left)
409 414
410 hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta() 415 hours_per_day_forward = time_forward / len(days_forward) if len(days_forward) > 0 else timedelta()
411 days_forward.discard(end_date.date()) 416 days_forward.discard(self.end_date.date())
412 417
413 self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward) 418 self.time_pulled_forward += time_forward - hours_per_day_forward * len(days_forward)
414 419
415 if end_date.date() in extra_days_to_work: 420 if self.end_date.date() in self.extra_days_to_work:
416 self.time_pulled_forward += extra_days_to_work[end_date.date()] 421 self.time_pulled_forward += self.extra_days_to_work[self.end_date.date()]
417 422
418 self.time_to_work += self.time_pulled_forward 423 self.time_to_work += self.time_pulled_forward
419 424
420 self.time_worked += api.get_billable_hours(start_date, self.now, rounding = config.get("WORKTIME", {}).get("rounding", True)) 425 self.time_worked += api.get_billable_hours(self.start_date, self.now, rounding = config.get("WORKTIME", {}).get("rounding", True))
421 426
422def worktime(**args): 427def worktime(**args):
423 worktime = Worktime(**args) 428 worktime = Worktime(**args)
@@ -458,7 +463,7 @@ def worktime(**args):
458 return f"{indicator}{difference_string}" 463 return f"{indicator}{difference_string}"
459 else: 464 else:
460 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1)) 465 difference_string = difference_string(total_minutes_difference * timedelta(minutes = 1))
461 if worktime.is_workday: 466 if worktime.now_is_workday:
462 return difference_string 467 return difference_string
463 else: 468 else:
464 return f"({difference_string})" 469 return f"({difference_string})"
@@ -498,7 +503,7 @@ def time_worked(now, **args):
498 503
499 clockout_time = None 504 clockout_time = None
500 clockout_difference = None 505 clockout_difference = None
501 if then.is_workday or now.is_workday: 506 if then.now_is_workday or now.now_is_workday:
502 target_time = max(then.time_per_day, now.time_per_day) if then.time_per_day and now.time_per_day else (then.time_per_day if then.time_per_day else now.time_per_day); 507 target_time = max(then.time_per_day, now.time_per_day) if then.time_per_day and now.time_per_day else (then.time_per_day if then.time_per_day else now.time_per_day);
503 difference = target_time - worked 508 difference = target_time - worked
504 clockout_difference = 5 * ceil(difference / timedelta(minutes = 5)) 509 clockout_difference = 5 * ceil(difference / timedelta(minutes = 5))
@@ -543,6 +548,8 @@ def leave(year, table, **args):
543 if leave_expires: 548 if leave_expires:
544 leave_expires = datetime.strptime(leave_expires, '%m-%d').date() 549 leave_expires = datetime.strptime(leave_expires, '%m-%d').date()
545 550
551 days = [worktime.start_date.date() + timedelta(days = x) for x in range(0, (worktime.end_date.date() - worktime.start_date.date()).days + 1)]
552
546 leave_budget = deepcopy(worktime.leave_budget) 553 leave_budget = deepcopy(worktime.leave_budget)
547 year_leave_budget = deepcopy(worktime.leave_budget) if year else None 554 year_leave_budget = deepcopy(worktime.leave_budget) if year else None
548 years = sorted(leave_budget.keys()) 555 years = sorted(leave_budget.keys())
@@ -589,12 +596,99 @@ def leave(year, table, **args):
589 table_data = [] 596 table_data = []
590 for year, days in leave_budget.items(): 597 for year, days in leave_budget.items():
591 leave_days = sorted([day for day in worktime.leave_days if day.year == year]) 598 leave_days = sorted([day for day in worktime.leave_days if day.year == year])
592 table_data += [[year, days, ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]] 599 would_be_workdays = [day for day in days if day.year == year and worktime.would_be_workday(day)]
600 table_data += [[year, days, f"{len(leave_days)}/{len(list(would_be_workdays))}", ','.join(map(lambda d: d.strftime('%m-%d'), leave_days))]]
593 print(tabulate(table_data, tablefmt="plain")) 601 print(tabulate(table_data, tablefmt="plain"))
594 else: 602 else:
595 print(leave_budget[year if year else def_year]) 603 print(leave_budget[year if year else def_year])
596 604
605def classification(classification_name, table, **args):
606 worktime = Worktime(**args)
607 config = Worktime.config()
608 date_format = config.get("WORKTIME", {}).get("DateFormat", '%Y-%m-%d')
609 config_dir = BaseDirectory.load_first_config('worktime')
610 days = [worktime.start_date.date() + timedelta(days = x) for x in range(0, (worktime.end_date.date() - worktime.start_date.date()).days + 1)]
611
612 year_classification = defaultdict(dict)
613 year_offset = defaultdict(lambda: 0)
614
615 extra_classified = dict()
616 classification_files = {
617 Path(config_dir) / classification_name: True,
618 Path(config_dir) / f"extra-{classification_name}": True,
619 Path(config_dir) / f"not-{classification_name}": False,
620 }
621 for path, val in classification_files.items():
622 try:
623 with path.open('r') as fh:
624 for line in fh:
625 stripped_line = line.strip()
626 if stripped_line:
627 fromDay = toDay = None
628 def parse_single(singlestr):
629 return datetime.strptime(singlestr, date_format).replace(tzinfo=tzlocal()).date()
630 if '--' in stripped_line:
631 [fromDay,toDay] = stripped_line.split('--')
632 fromDay = parse_single(fromDay)
633 toDay = parse_single(toDay)
634 else:
635 fromDay = toDay = parse_single(stripped_line)
636
637 for day in [fromDay + timedelta(days = x) for x in range(0, (toDay - fromDay).days + 1)]:
638 extra_classified[day] = val
639 except IOError as e:
640 if e.errno != 2:
641 raise e
642
643 for day in days:
644 if not worktime.is_workday(day, extra=False):
645 continue
646
647 classification_days = set()
648 for default_classification in reversed(config.get("day-classification", {}).get(classification_name, {}).get("default", [])):
649 from_date = None
650 to_date = None
651
652 if "from" in default_classification:
653 from_date = datetime.strptime(default_classification["from"], date_format).replace(tzinfo=tzlocal()).date()
654 if day < from_date:
655 continue
656
657 if "to" in default_classification:
658 to_date = datetime.strptime(default_classification["to"], date_format).replace(tzinfo=tzlocal()).date()
659 if day >= to_date:
660 continue
661
662 classification_days = set([int(d.strip()) for d in default_classification.get("days", "").split(',') if d.strip()])
663
664 default_classification = day.isoweekday() in classification_days
665 override = None
666 if day in extra_classified:
667 override = extra_classified[day]
668 if override != default_classification:
669 year_offset[day.year] += 1 if override else -1
670 year_classification[day.year][day] = override if override is not None else default_classification
671
672 if not table:
673 print(sum(year_offset.values()))
674 else:
675 table_data = []
676 for year in sorted(year_classification.keys()):
677 row_data = [year]
678 count_classified = len([1 for day, classified in year_classification[year].items() if classified])
679 count_would_be_workdays = len([1 for day in days if day.year == year and worktime.would_be_workday(day) and day not in worktime.leave_days])
680 row_data.append(year_offset[year])
681 if len(year_classification[year]) != count_would_be_workdays:
682 row_data.append(f"{count_classified}/{len(year_classification[year])}/{count_would_be_workdays}")
683 else:
684 row_data.append(f"{count_classified}/{len(year_classification[year])}")
685 row_data.append(','.join(sorted([day.strftime('%m-%d') for day, classified in year_classification[year].items() if classified])))
686 table_data.append(row_data)
687 print(tabulate(table_data, tablefmt="plain"))
688
597def main(): 689def main():
690 config = Worktime.config()
691
598 parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API') 692 parser = argparse.ArgumentParser(prog = "worktime", description = 'Track worktime using toggl API')
599 parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal())) 693 parser.add_argument('--time', dest = 'now', metavar = 'TIME', type = lambda s: datetime.fromisoformat(s).replace(tzinfo=tzlocal()), help = 'Time to calculate status for (default: current time)', default = datetime.now(tzlocal()))
600 parser.add_argument('--no-running', dest = 'include_running', action = 'store_false') 694 parser.add_argument('--no-running', dest = 'include_running', action = 'store_false')
@@ -613,6 +707,10 @@ def main():
613 leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?') 707 leave_parser.add_argument('year', metavar = 'YEAR', type = int, help = 'Year to evaluate leave days for (default: current year)', default = None, nargs='?')
614 leave_parser.add_argument('--table', action = 'store_true') 708 leave_parser.add_argument('--table', action = 'store_true')
615 leave_parser.set_defaults(cmd = leave) 709 leave_parser.set_defaults(cmd = leave)
710 for classification_name in config.get('day-classification', {}).keys():
711 classification_parser = subparsers.add_parser(classification_name)
712 classification_parser.add_argument('--table', action = 'store_true')
713 classification_parser.set_defaults(cmd = partial(classification, classification_name=classification_name))
616 args = parser.parse_args() 714 args = parser.parse_args()
617 715
618 args.cmd(**vars(args)) 716 args.cmd(**vars(args))