--- /dev/null
+# Punch(card) — Personal time tracking
+# ©2023 Dustin Walde
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import datetime
+from math import floor
+from PIL import Image
+
+from time_sheet import TimeSheet
+
+def colstr(hex: str):
+ hex = hex[1:]
+ rgb = [0,0,0]
+ if len(hex) == 3:
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
+ if len(hex) == 6:
+ rgb[0] = int(hex[0:2], 16)
+ rgb[1] = int(hex[2:4], 16)
+ rgb[2] = int(hex[4:], 16)
+
+ return (rgb[0], rgb[1], rgb[2], 255)
+
+IMAGE_SIZE = 8
+HOURS = IMAGE_SIZE
+TARGET_HOURS = 8
+SEGS = IMAGE_SIZE - 2
+SEG_MINS = floor(60/SEGS)
+SEG_SECS = floor(60*(60/SEGS-SEG_MINS))
+
+def day(sheet: TimeSheet, date: datetime.date) -> Image.Image:
+ dt = datetime.datetime.combine(date, datetime.time(), datetime.datetime.now().astimezone().tzinfo)
+ de = dt + datetime.timedelta(days=1)
+ tt = datetime.timedelta(0)
+ ct = 0
+
+ img = Image.new('RGBA', (IMAGE_SIZE, IMAGE_SIZE), (0,0,0,0))
+
+ for i in range(TARGET_HOURS):
+ for j in range(SEGS):
+ img.putpixel((2+j, HOURS-1-i), (255,255,255,48))
+
+ for entry in sheet.entries:
+ if entry is None:
+ continue
+ color = colstr(sheet.categories[entry.category].color)
+ if not entry.is_complete:
+ tt += datetime.datetime.now().astimezone() - max(entry.t_in, dt)
+ for i in range(HOURS):
+ img.putpixel((0,i), color)
+ elif entry.t_in >= dt and entry.t_in <= de:
+ time = min(entry.t_out, de) - entry.t_in # type: ignore
+ tt += time
+ elif entry.t_out >= dt and entry.t_out <= de: # type: ignore
+ time = entry.t_out - max(dt, entry.t_in) # type: ignore
+ tt += time
+ while ct < HOURS * SEGS and tt > datetime.timedelta(0):
+ img.putpixel((2+ct%SEGS, HOURS-1-floor(ct/SEGS)), color)
+ tt += datetime.timedelta(minutes=-SEG_MINS, seconds=-SEG_SECS)
+ ct += 1
+
+ img = img.resize((20,20), Image.NEAREST)
+ return img
+
--- /dev/null
+# Punch(card) — Personal time tracking
+# ©2023 Dustin Walde
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+from datetime import date, datetime, time
+import sys
+from typing import List, Optional
+
+
+from puncher import Puncher
+from time_sheet import TimeSheet
+
+import logging
+
+logging.getLogger().addHandler(logging.StreamHandler(sys.stdout))
+
+
+ERR_MISSING_ARG = 2
+ERR_UNKNOWN_COMMAND = 3
+
+
+def load_time_sheet() -> TimeSheet:
+ ts = TimeSheet()
+ ts.load_csv('/home/users/.local/share/dwalde/time-sheet.csv')
+ return ts
+
+
+def punch(sheet: TimeSheet, dir: str, category: Optional[str] = None, args: List[str] = []):
+ '''
+ Attempt to add and entry to punch a category in or out.
+ _dir_ is the string 'in' or 'out'.
+ '''
+ now = datetime.now().astimezone()
+ if len(args) < 2 or args[0] != "at":
+ punch_time = now
+ else:
+ punch_time = datetime.combine(
+ date.today(),
+ time(hour=int(args[1].split(':')[0]),
+ minute=int(args[1].split(':')[1]),
+ tzinfo=now.tzinfo))
+
+ if dir == "in" and category is not None:
+ sheet.punch_in(category, punch_time)
+ elif dir == "out":
+ sheet.punch_out(category, punch_time)
+
+
+def main(args):
+ if len(args) < 2:
+ print('Missing command (daemon, new, in, out, list, print, query)')
+ exit(ERR_MISSING_ARG)
+
+ if args[1] == 'daemon' or args[1] == 'service':
+ import service
+ service.Service(load_time_sheet()).run()
+
+ sheet: Puncher = load_time_sheet()
+
+ if args[1] == 'new':
+ if len(args) < 3:
+ print("Missing arg group|category")
+ print('punch new [group] <id> <name> [<parent> [<color>]]')
+ exit(ERR_MISSING_ARG)
+ if args[2] == 'group':
+ if len(args) < 5:
+ print('Missing args for new group: new group <id> <name> [parent] [color]')
+ exit(ERR_MISSING_ARG)
+ sheet.new_group(*args[3:])
+ else:
+ if len(args) < 4:
+ print('Missing args for new category: new <id> <name> [parent] [color]')
+ exit(ERR_MISSING_ARG)
+ sheet.new_category(*args[2:])
+
+ elif args[1] == 'out':
+ if len(args) < 3:
+ punch(sheet, 'out')
+ else:
+ punch(sheet, 'out', args[2], args[3:])
+
+ elif args[1] == 'in':
+ if len(args) < 3:
+ print(f'Missing arg for punch in: <category>')
+ exit(ERR_MISSING_ARG)
+ punch(sheet, 'in', args[2], args[3:])
+
+ elif args[1] == 'pop' or args[1] == 'rm' or args[1] == 'undo':
+ if len(args) < 3:
+ print(f'Missing arg for pop {args[2]}: <category>')
+ exit(ERR_MISSING_ARG)
+ sheet.list_category(args[2])
+ resp = input("Remove entry [y/N]? ")
+ if resp.lower() == 'y':
+ sheet.pop(args[2])
+ sheet.list_category(args[2])
+
+ elif args[1] == 'list':
+ if len(args) > 2:
+ sheet.list_category(args[2])
+ else:
+ sheet.list_all()
+
+ elif args[1] == 'to':
+ if len(args) < 3:
+ print(f'Missing category to punch in to.')
+ exit(ERR_MISSING_ARG)
+ punch(sheet, 'out')
+ punch(sheet, 'in', args[2])
+
+ elif args[1] == 'img':
+ import image_generator
+ if len(args) > 2:
+ dt = date.fromisoformat(args[2])
+ else:
+ dt = date.today()
+ image = image_generator.day(sheet, dt)
+ image.save("test.png")
+
+ elif args[1] == 'query':
+ raise NotImplementedError
+
+ else:
+ print(f"Unknown subcommand {args[1]}")
+ exit(ERR_UNKNOWN_COMMAND)
+
+
+if __name__ == "__main__":
+ main(sys.argv)
+
--- /dev/null
+# Punch(card) — Personal time tracking
+# ©2023 Dustin Walde
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import datetime
+import pystray
+from time import sleep
+from watchdog.observers import Observer
+from watchdog.events import FileSystemEventHandler
+
+import image_generator
+from time_sheet import TimeSheet
+
+
+PATH = '/home/users/.local/share/dwalde/time-sheet.csv'
+
+
+class Service:
+
+ def __init__(self, sheet: TimeSheet):
+ self.sheet = sheet
+ self.reload = False
+ self.handler = OnDataModify(self)
+ self.observer = Observer()
+ self.observer.schedule(self.handler, PATH)
+ self.observer.start()
+
+ def keep_update(self, icon: pystray.Icon):
+ icon.visible = True
+ while True:
+ sleep(12)
+ if self.reload:
+ self.sheet.load_csv(PATH)
+ self.reload = False
+ icon.update_menu()
+ icon.icon = image_generator.day(self.sheet, datetime.date.today())
+
+ def _generate_menu_items(self):
+ return [
+ pystray.MenuItem("Punch out",
+ lambda e, v: self.sheet.punch_out(),
+ enabled=len(self.sheet.get_open_entries())==1,
+ default=True),
+ ]
+
+ def run(self):
+ img = image_generator.day(self.sheet, datetime.date.today())
+ icon = pystray.Icon('Punch(card)', icon=img, title="Punch", menu=[lambda: self._generate_menu_items()])
+ icon.run(lambda icn: self.keep_update(icn))
+
+
+class OnDataModify(FileSystemEventHandler):
+
+ def __init__(self, service: Service):
+ self.service = service
+ super().__init__()
+
+ def on_any_event(self, event):
+ self.service.reload = True
+
def get_open_entries(self) -> List[Entry]:
entries = []
for entrys in self.cat_entries.values():
- if len(entrys) > 0 and not entrys[-1].is_complete:
- entries.append(entrys[-1])
+ if len(entrys) > 0 and not self.entries[entrys[-1]].is_complete:
+ entries.append(self.entries[entrys[-1]])
return entries
return None
cat_entries = self.cat_entries[category]
- if len(cat_entries) == 0 or cat_entries[-1].is_complete:
+ if len(cat_entries) == 0 or self.entries[cat_entries[-1]] is None or \
+ self.entries[cat_entries[-1]].is_complete:
logger.error("Category %s has nothing punched in.", category)
return None
if time is None:
time = datetime.now().astimezone()
- entry = cat_entries[-1]
+ entry = self.entries[cat_entries[-1]]
last_time = entry.t_in
if last_time >= time:
logger.error(f"Punch in time is not before {time}.")
abbr = row[0],
name = row[2],
)
+ if len(row) > 4:
+ self.categories[row[0]].color = row[4]
elif row[1] == 'group':
self.categories[row[2]] = Category(
abbr = row[2],
name = row[3],
children = [],
)
+ if len(row) > 5:
+ self.categories[row[2]].color = row[5]
for row in category_rows:
parent = ""
if row[1] == 'category' and len(row) > 3:
current = self.categories[abbr]
if parent.children is None:
logger.warning(f"Parent {parent.abbr} is not a group.")
- else:
+ elif parent.abbr != '' or abbr != '':
parent.children.append(current)
current.parent = parent
self.root = self.categories['']