--- /dev/null
+# Punch(card) — Personal time tracking
+# ©2023 Dustin Walde
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+import calendar
+from datetime import datetime, timedelta, date, time
+import logging
+from math import floor
+
+from typing import List, Optional
+
+from errors import InvalidArgumentError, NoSuchCategoryError
+from puncher import Category, Puncher
+
+
+logger = logging.getLogger(__name__)
+
+
+def query(puncher: Puncher, category: Category,
+ start_date: date, end_date: date):
+ result = {
+ "id": category.abbr,
+ "name": category.name,
+ "children": [],
+ "card": [],
+ "card-total": timedelta(0),
+ "total": timedelta(0),
+ "start": start_date,
+ "end": end_date,
+ }
+
+ for child in category.children:
+ child_q = query(puncher, child, start_date, end_date)
+ result["children"].append(child_q)
+ result["total"] += child_q["total"]
+
+ entries = puncher.get_entries(category.abbr, start_date, end_date)
+ if len(entries) == 0:
+ return result
+
+ start_time = datetime.combine(start_date, time(), entries[0].t_in.tzinfo)
+ end_time = datetime.combine(end_date, time(), entries[0].t_in.tzinfo)
+ for entry in entries:
+ in_time = max(start_time, entry.t_in)
+ if entry.is_complete and entry.t_out is not None:
+ out_time = entry.t_out
+ else:
+ out_time = end_time
+ result["card"].append(entry)
+ result["card-total"] += out_time - in_time
+
+ result["total"] += result["card-total"]
+
+ return result
+
+
+def process_args(query_args: List[str]):
+ today = datetime.today()
+ if len(query_args) == 0:
+ print("Today is:\n", today.date().strftime('%d %B %Y'), "\n")
+ calendar.prmonth(today.year, today.month)
+ return
+
+ cat_id = query_args[0]
+ options = set()
+
+ snap_to_week = False
+ set_start = False
+ set_end = False
+ duration = None
+ offset: Optional[timedelta] = None
+ end = None
+ count = 1
+ unit = 'day'
+ parsing_time = timedelta(0)
+
+ def set_current():
+ nonlocal duration, end, offset, parsing_time, set_end, set_start
+ if parsing_time.total_seconds() == 0:
+ return
+
+ if set_start:
+ offset = -parsing_time
+ elif set_end:
+ end = -parsing_time
+ elif offset is None:
+ offset = -parsing_time
+ elif duration is None:
+ duration = parsing_time - offset
+
+ parsing_time = timedelta(0)
+ set_start = False
+ set_end = False
+
+ for arg in query_args[1:]:
+ if arg.startswith('--'):
+ options.add(arg[2:])
+ elif arg == 'today':
+ set_current()
+ count = 1
+ parsing_time = timedelta(seconds=1)
+ elif arg == 'yesterday':
+ set_current()
+ count = 1
+ parsing_time = timedelta(days=-1)
+ elif arg == 'last':
+ set_current()
+ count = 1
+ if not set_end:
+ set_start = True
+ elif arg == 'week' or arg == 'weeks':
+ unit = 'week'
+ snap_to_week = True
+ parsing_time += timedelta(days=7*count)
+ elif arg.startswith('day'):
+ parsing_time += timedelta(days=count)
+ elif arg == 'ago':
+ if not set_end:
+ set_start = True
+ elif arg == 'from':
+ set_current()
+ set_start = True
+ elif arg == 'to':
+ set_current()
+ set_end = True
+ else:
+ set_current()
+ try:
+ count = int(arg)
+ except:
+ raise InvalidArgumentError("Ivalid command line argument " + arg)
+
+ set_current()
+
+ if offset is None:
+ offset = timedelta(0)
+
+ if snap_to_week:
+ start_date = (today + offset).date()
+ offset += timedelta(days=-start_date.weekday())
+
+ if end is None:
+ if duration is None:
+ unit_offset = 0
+ if unit == 'week':
+ unit_offset = 6
+ end = offset + timedelta(days=unit_offset)
+ else:
+ end = offset + duration
+
+ return (cat_id, (today+offset).date(), (today+end).date(), options)
+
+
+def print_query(puncher: Puncher, query_args: List[str]):
+ args = process_args(query_args)
+ if args is None:
+ return
+ cat, start_date, end_date, options = args
+ category = puncher.get_category(cat)
+
+ if category is None:
+ raise NoSuchCategoryError(cat)
+
+ res = query(puncher, category, start_date, end_date)
+ if res is None:
+ return
+
+ print(f"Querying {category.name} from:", res["start"], "to:", res["end"])
+
+ _print_tree(res, 0, "full" in options)
+
+
+def _delta_str(delta: timedelta) -> str:
+ seconds = delta.total_seconds()
+ if seconds == 0:
+ return ""
+ minutes = floor(seconds / 60)
+ hours = floor(minutes / 60)
+ if hours == 0:
+ return f" {minutes%60:2d}m"
+ if minutes % 60 == 0:
+ return f"{hours}h -"
+ return f"{hours:02d}h {minutes%60:2d}m"
+
+
+def _print_tree(res, depth: int, print_entries: bool):
+ # TODO: currently based off of punch in time only
+ # I might want and option to separate out by day when
+ # and entry spans through midnight.
+ lead = " "*depth*2
+ print(lead + res["name"], f'({res["id"]})', _delta_str(res["total"]))
+ if print_entries:
+ last_date = None
+ date_count = 0
+ day_total = timedelta()
+ for entry in res["card"]:
+ start_date = entry.t_in.date()
+ if start_date != last_date:
+ if date_count > 1:
+ print(lead + " " + " "*15 + _delta_str(day_total))
+ print(lead + " ", start_date)
+ last_date = start_date
+ date_count = 1
+ day_total = timedelta()
+ else:
+ date_count += 1
+ start_time = entry.t_in
+ start_time_fmt = entry.t_in.strftime('%H:%M')
+ end_time = entry.t_out
+ if end_time is None:
+ end_time = datetime.now()
+ end_time_fmt = end_time.strftime('%H:%M')
+ day_total += (end_time - start_time)
+ print(lead + " ", start_time_fmt, "-", end_time_fmt, "", _delta_str(end_time - entry.t_in))
+ if date_count > 1:
+ print(lead + " " + " "*15 + _delta_str(day_total))
+ if len(res["children"]) > 0:
+ print(lead + "↑", _delta_str(res["card-total"]))
+
+ for child in res["children"]:
+ _print_tree(child, depth+1, print_entries)
+