Skip to content

Instantly share code, notes, and snippets.

@GRAYgoose124
Last active May 8, 2024 02:22
Show Gist options
  • Save GRAYgoose124/096d411ad5df771d67711871fb6c5600 to your computer and use it in GitHub Desktop.
Save GRAYgoose124/096d411ad5df771d67711871fb6c5600 to your computer and use it in GitHub Desktop.
Say hello to Tim.
#!/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