-
-
Save GRAYgoose124/096d411ad5df771d67711871fb6c5600 to your computer and use it in GitHub Desktop.
Say hello to Tim.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
import argparse, json, sys, os, shutil, logging | |
from typing import Collection, Optional, Sequence | |
import matplotlib.pyplot as plt | |
import seaborn as sns | |
import numpy as np | |
from pathlib import Path | |
from datetime import datetime, timedelta | |
from dataclass_wizard import JSONWizard, JSONFileWizard | |
from dataclasses import dataclass, field, asdict | |
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) | |
logging.getLogger("matplotlib").setLevel(logging.WARNING) | |
logging.getLogger("seaborn").setLevel(logging.WARNING) | |
log = logging.getLogger(__name__) | |
@dataclass | |
class WorkData(JSONWizard, JSONFileWizard): | |
start_time: Optional[datetime] = None | |
overtime: float = 0.0 | |
hours_left: float = 40.0 | |
last_reset: datetime = field( | |
default_factory=lambda: datetime.now().replace( | |
hour=0, minute=0, second=0, microsecond=0 | |
) | |
) | |
history: list[tuple[datetime, datetime, float] | float] = field( | |
default_factory=list | |
) | |
@property | |
def history_by_day(self): | |
"""Returns a dictionary with the date as key and the hours worked as value. | |
Because some segments will start on one day, and end on another, we need to split | |
the time appropriately. This is done by creating a dictionary with the date as key | |
and the hours worked as value. The hours worked are summed up for each day. | |
""" | |
if len(self.history) == 0: | |
return {} | |
if not hasattr(self, "_history_by_day"): | |
self._history_by_day = {} | |
if hasattr(self, "_last_by_day") and self._last_by_day == len(self.history): | |
return self._history_by_day | |
else: | |
self._last_by_day = len(self.history) - 1 | |
for entry in self.history[self._last_by_day :]: | |
if isinstance(entry, tuple): | |
start, stop, hours = entry | |
start_date = start.date() | |
stop_date = stop.date() | |
if start_date == stop_date: | |
self._history_by_day[start_date] = ( | |
self._history_by_day.get(start_date, 0.0) + hours | |
) | |
else: | |
# Split the hours worked over two days. | |
start_hours = ( | |
start.replace(hour=23, minute=59, second=59) - start | |
).total_seconds() / 3600.0 | |
stop_hours = ( | |
stop - stop.replace(hour=0, minute=0, second=0) | |
).total_seconds() / 3600.0 | |
self._history_by_day[start_date] = ( | |
self._history_by_day.get(start_date, 0.0) + start_hours | |
) | |
self._history_by_day[stop_date] = ( | |
self._history_by_day.get(stop_date, 0.0) + stop_hours | |
) | |
else: | |
self._history_by_day[datetime.now().date()] = ( | |
self._history_by_day.get(datetime.now().date(), 0.0) + entry | |
) # Add the hours worked today. | |
return self._history_by_day | |
@classmethod | |
def from_json_file(cls, file_path: Path): | |
def decoder(file): | |
data = json.load(file) | |
data["history"] = [ | |
tuple(entry) if isinstance(entry, list) else entry | |
for entry in data["history"] | |
] | |
return data | |
return super().from_json_file(file_path, decoder=decoder) | |
def __repr__(self): | |
return f"WorkData(start_time={self.start_time}, overtime={self.overtime}, hours_left={self.hours_left}, last_reset={self.last_reset}, history={self.history})" | |
class Parent(type): | |
"""Generates a class with nested classes that have access to the parent class. | |
The parent then has properties that are the nested classes. | |
class Parent: | |
class Actions(Nested): | |
def start(self): | |
self.data.start_time = datetime.now() | |
Ex. Actions(Nested) -> parent.actions.start() | |
""" | |
class Nested: | |
def __init__(self, parent): | |
self.parent = parent | |
self.data = parent.data | |
def __new__(cls, name, bases, dct): | |
new_kvs = [] | |
for key, value in dct.copy().items(): | |
if isinstance(value, type) and issubclass(value, Parent.Nested): | |
key = key.lower() | |
new_kvs.append((key, value)) | |
obj = super().__new__(cls, name, bases, dct) | |
# add properties to the parent class | |
for key, value in new_kvs: | |
setattr(obj, key, property(lambda self, value=value: value(self))) | |
return obj | |
class WorkTimer(metaclass=Parent): | |
def __init__(self, timer_data_file): | |
self.timer_data_file = timer_data_file | |
self.data = None | |
self.setup() | |
def setup(self): | |
self.data = self._load_data_from_file() or WorkData() | |
self._weekly_reset() | |
class Actions(Parent.Nested): | |
def start(self): | |
if self.data.start_time is None: | |
self.data.start_time = datetime.now() | |
print("Timer started.") | |
else: | |
print("Timer already started, please stop it first.") | |
def stop(self): | |
if self.data.start_time: | |
start_time, stop_time = self.data.start_time, datetime.now() | |
elapsed = (stop_time - start_time).total_seconds() | |
elapsed_hours = elapsed / 3600.0 | |
self.data.hours_left -= elapsed_hours | |
self.data.history.append( | |
(self.data.start_time, stop_time, elapsed_hours) | |
) | |
self.data.start_time = None | |
print( | |
f"Timer stopped, worked for {elapsed} seconds. Hours left this week: {self.data.hours_left:.2f}" | |
) | |
else: | |
print("WorkTimer not started.") | |
def cancel(self): | |
if self.data.start_time: | |
self.data.start_time = None | |
print("Timer cancelled.") | |
else: | |
print("WorkTimer not started.") | |
def add(self, hours: float): | |
self.data.hours_left -= hours | |
self.data.history.append(-hours) | |
print(f"Added {hours} hours to your count. Hope you aren't cheating...") | |
def forget(self, hours: float): | |
self.data.hours_left += hours | |
self.data.history.append(hours) | |
print(f"Forgot {hours} hours from the timer, like you probably forgot Tim.") | |
class Plotter(Parent.Nested): | |
def simple_graph(self): | |
"""Simple linear bar plot of work sections. Each history entry is a start and a stop time.""" | |
x = [start for start, _, _ in self.data.history] | |
y = [hours for _, _, hours in self.data.history] | |
sns.barplot(x=x, y=y) | |
plt.show() | |
def gitlike_contrib_graph(self): | |
"""Git has a yearly contribution graph that shows commits per day in weeks. This function | |
will create a similar graph for the work timer in terms of hours worked per day in a block of 52x7 days. | |
""" | |
history_by_day = self.data.history_by_day | |
days = list(history_by_day.keys()) | |
hours = list(history_by_day.values()) | |
# Create a 52x7 matrix with the hours worked per day. | |
matrix = [[0.0 for _ in range(7)] for _ in range(52)] | |
for day, hour in zip(days, hours): | |
week, weekday = day.isocalendar()[1:] | |
matrix[week - 1][weekday - 1] = hour | |
# Plot the matrix as a heatmap. | |
sns.heatmap(matrix, cmap="viridis") | |
plt.show() | |
class Scorer(Parent.Nested): | |
def print_status(self): | |
print(f"Current hours left this week: {self.data.hours_left:.1f}") | |
print( | |
f"\tincluding overtime: {self.data.hours_left - self.data.overtime:.1f}\n" | |
) | |
uncommitted_hours = timedelta(0) | |
if self.data.start_time: | |
uncommitted_hours = datetime.now() - self.data.start_time | |
print(f"You've been working for {uncommitted_hours}.", end="\n") | |
else: | |
print("Stopped.", end="\n") | |
self.anxious_score(uncommitted_hours.total_seconds() / 3600.0) | |
def simple_score(self, uncommitted_hours=0): | |
"""Simple score based on hours left.""" | |
if self.data.hours_left / 40.0 > 0.80: | |
minutes_worked = (40.0 - self.data.hours_left) * 60.0 | |
print(f"Keep working! You only worked {minutes_worked} minutes dopey!") | |
elif self.data.hours_left / 40.0 < 0.05: | |
print("You can go home now. You've worked enough this week.") | |
else: | |
print("Keep up the good work! You're doing fine.") | |
def anxious_score(self, uncommitted_hours=0): | |
"""The closer to the end of the week, the more your remaining hours count.""" | |
remaining_hours = self.data.hours_left - uncommitted_hours | |
remaining_days = 5 - (datetime.now().weekday() + 1) | |
remaining_current_day = ( | |
1 - (datetime.now().hour + datetime.now().minute / 60) / 24 | |
) | |
work_hours_remaining = remaining_days * 8 + remaining_current_day * 8 | |
score = 1 - (remaining_hours / work_hours_remaining) | |
print( | |
f"Score: {score:.2f}, {work_hours_remaining:.1f} work hours left to complete {remaining_hours:.1f} hours.\n" | |
) | |
# positive is ok, negative is very bad forced work weekend. above 1 means on time | |
if score < 0.0: | |
print("You're behind schedule, you're working this weekend!") | |
elif score < 0.25: | |
print("You're rather behind, but can still make it, if you work hard.") | |
elif score < 0.5: | |
print("Just keep working.") | |
elif score < 0.8: | |
print("You're doing fine. Keep up the good work.") | |
else: | |
print("You're doing great! The world is yours.") | |
def _load_data_from_file(self): | |
if self.timer_data_file.exists(): | |
try: | |
return WorkData.from_json_file(self.timer_data_file) | |
except json.JSONDecodeError: | |
backup_path = self.timer_data_file.with_suffix(".json.bak") | |
shutil.copy(self.timer_data_file, backup_path) | |
print(f"Corrupted data file backed up to {backup_path}") | |
def _weekly_reset(self): | |
now = datetime.now() | |
# reset once a week | |
resets_needed = (now - self.data.last_reset).days // 7 | |
if resets_needed: | |
for _ in range(resets_needed): | |
self.data.overtime -= self.data.hours_left | |
self.data.hours_left = 40.0 | |
# last reset to most recent monday midnight | |
self.data.last_reset = (now - timedelta(days=now.weekday())).replace( | |
hour=0, minute=0, second=0, microsecond=0 | |
) | |
def __enter__(self): | |
return self | |
def __exit__(self, exc_type, exc_val, exc_tb): | |
# save data only if there is no exception | |
if exc_type is None: | |
self.data.to_json_file(self.timer_data_file) | |
else: | |
# save to excepted backup | |
backup_path = self.timer_data_file.with_suffix(".json.exc.bak") | |
self.data.to_json_file(backup_path) | |
def parse_args(): | |
def parse_duration(duration_str): | |
parts = duration_str.split(":") | |
if len(parts) == 1: | |
return int(parts[0]) / 60.0 | |
elif len(parts) == 2: | |
return int(parts[0]) + int(parts[1]) / 60.0 | |
raise ValueError("Invalid duration format. Use MM or HH:MM.") | |
parser = argparse.ArgumentParser(description="Work Timer TUI") | |
parser.add_argument("--start", action="store_true", help="Start the work timer") | |
parser.add_argument("--stop", action="store_true", help="Stop the work timer") | |
parser.add_argument( | |
"--add", type=parse_duration, help="Duration to add in MM or HH:MM format." | |
) | |
parser.add_argument( | |
"--forget", | |
type=parse_duration, | |
help="Duration to forget in MM or HH:MM format.", | |
) | |
parser.add_argument( | |
"--graph", | |
help="Show a graph of the work timer data.", | |
choices=["gitlike", "simple"], | |
default=None, | |
) | |
parser.add_argument( | |
"--timer-file", | |
help="Path to the timer data file.", | |
default=Path.home() / ".worktimer.json", | |
) | |
return parser.parse_args() | |
if __name__ == "__main__": | |
args = parse_args() | |
with WorkTimer(args.timer_file) as wt: | |
if args.add: | |
wt.actions.add(args.add) | |
if args.forget: | |
wt.actions.forget(args.forget) | |
if args.start: | |
wt.actions.start() | |
elif args.stop: | |
wt.actions.stop() | |
wt.scorer.print_status() | |
if args.graph: | |
if len(wt.data.history) > 0: | |
if args.graph == "gitlike": | |
wt.plotter.gitlike_contrib_graph() | |
elif args.graph == "simple": | |
wt.plotter.simple_graph() | |
else: | |
print("No history to plot.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment