From 6ae8e76dc408ae9c2310dfa1967be811afb3ef61 Mon Sep 17 00:00:00 2001 From: truthless-dev Date: Fri, 4 Jul 2025 22:18:35 -0700 Subject: [PATCH 1/4] feat(db): add database support --- docs/api.rst | 5 + tests/core/test_util.py | 19 +++ worktimer/core/__init__.py | 0 worktimer/core/const.py | 12 ++ worktimer/core/database.py | 266 +++++++++++++++++++++++++++++++++++++ worktimer/core/util.py | 49 +++++++ 6 files changed, 351 insertions(+) create mode 100644 tests/core/test_util.py create mode 100644 worktimer/core/__init__.py create mode 100644 worktimer/core/const.py create mode 100644 worktimer/core/database.py create mode 100644 worktimer/core/util.py diff --git a/docs/api.rst b/docs/api.rst index 362ae4c..1760423 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,3 +2,8 @@ API Reference ============= This section documents the internal Python API. + +.. automodule:: worktimer.core.database + :members: + :undoc-members: + :show-inheritance: diff --git a/tests/core/test_util.py b/tests/core/test_util.py new file mode 100644 index 0000000..84e4366 --- /dev/null +++ b/tests/core/test_util.py @@ -0,0 +1,19 @@ +from worktimer.core import util + + +class TestGetAppDir: + + def setup_method(self): + self.app_dir = util.get_app_dir() + + def test_includes_app_name(self): + assert "worktimer" in self.app_dir.lower() + + +class TestNow: + + def setup_method(self): + self.now = util.now() + + def test_no_microseconds(self): + assert self.now.microsecond == 0 diff --git a/worktimer/core/__init__.py b/worktimer/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/worktimer/core/const.py b/worktimer/core/const.py new file mode 100644 index 0000000..0498872 --- /dev/null +++ b/worktimer/core/const.py @@ -0,0 +1,12 @@ +""" +Application-wide constants +""" + +import os + +from worktimer.core import util + + +PATH_APP = util.get_app_dir() +PATH_CONFIG = os.path.join(PATH_APP, "config.json") +PATH_DB = os.path.join(PATH_APP, "worktimer.db") diff --git a/worktimer/core/database.py b/worktimer/core/database.py new file mode 100644 index 0000000..cea3241 --- /dev/null +++ b/worktimer/core/database.py @@ -0,0 +1,266 @@ +""" +Application Database class module +""" + +import sqlite3 +from datetime import datetime, timedelta + +from worktimer.core import util + + +class Database: + """ + Interface to the underlying db. + """ + + def __init__(self, file_name: str) -> None: + """ + Initialize the db. + + :param file_name: The name of the SQLite database to which to + connect. + :type file_name: str + """ + self.connection = sqlite3.connect(file_name) + self.cursor = self.connection.cursor() + + # Return rows as dict-like objects. + self.cursor.row_factory = sqlite3.Row + + # Do any necessary setup transparently. + self._create_tables() + self._ensure_time_pairs() + + def _create_tables(self) -> bool: + """ + Create necessary tables if they do not already exist + + Create an 'event' table with 'timestamp' and 'working' columns. + If that table already exists (e.g., when this is not the first + time using this db), do nothing. + + :return: Whether the table now exists. + :rtype: bool + """ + sql = """ + CREATE TABLE IF NOT EXISTS event( + event_id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + working INTEGER NOT NULL + ); + """ + try: + self.cursor.execute(sql) + self.connection.commit() + except sqlite3.OperationalError: + self.connection.rollback() + return False + return True + + def _ensure_time_pairs(self) -> bool: + """ + Ensure that all time blocks have a start and an end + + Given our means of tracking events and the fickle nature of + our users, we cannot trust that all start events will have + corresponding stop events. If the user logs a start on Mon, + for example, but forgets to log a stop, or works through the + night until Tue, our pairs might become unpaired. + This method attempts to correct these types of logging errors + by checking the most recent logged event. If it occured today, + do nothing. If it occured yesterday and is not a stop, then add + a stop at the end of that day. If the latter occurs and the date + was yesterday, assume the user worked overnight and helpfully + add a start event at midnight today. + + :return: Whether the db is now in a correct state. + :rtype: bool + """ + sql = """ + SELECT timestamp, working + FROM event + ORDER BY timestamp DESC LIMIT 1; + """ + row = self.cursor.execute(sql).fetchone() + + if row is None or row["working"] == 0: + # No events in db, or the previous time block was logged + # as ended. No extra work needed. + return True + + last_timestamp = datetime.fromisoformat(row["timestamp"]) + last_date = last_timestamp.date() + now = util.now() + current_date = now.date() + if current_date == last_date: + # No need to worry about unfinished time pairs today, as + # will be handled either by the user or by us the next time + # we connect to the db. + return True + + # The latest event is on a day prior to today, and it is an + # unfinished time pair (i.e., a start event was logged but no + # corresponding stop event was logged). We have to assume that + # work ended some time that day. Our best guess is at + # 11:59:59PM. Here we transparently log a stop event at that + # time. + try: + end_day_timestamp = datetime( + year=last_date.year, + month=last_date.month, + day=last_date.day, + hour=23, + minute=59, + second=59, + ).isoformat() + end_day_working = 0 + sql = """ + INSERT INTO event + (timestamp, working) + VALUES + (?, ?); + """ + self.cursor.execute(sql, (end_day_timestamp, end_day_working)) + self.connection.commit() + except sqlite3.OperationalError: + self.connection.rollback() + return False + + # On the other hand, possibly a stop event was purposely not + # logged (e.g., user is working the night shift from one day to + # the next). If this most recent event occured more + # than a day ago, we do not assume that is the case, because + # no one works continuously for more than a day. If the event + # was only yesterday, however, assume that the user is still + # working. + time_difference = current_date - last_date + if time_difference.days > 1: + return True + + try: + start_day_timestamp = datetime( + year=current_date.year, + month=current_date.month, + day=current_date.day, + hour=0, + minute=0, + second=0, + ).isoformat() + start_day_working = 1 + sql = """ + INSERT INTO event + (timestamp, working) + VALUES + (?, ?); + """ + self.cursor.execute(sql, (start_day_timestamp, start_day_working)) + self.connection.commit() + except sqlite3.OperationalError: + self.connection.rollback() + return False + + # Finally, all discrepancies should by now be addressed. + return True + + def close(self) -> None: + """ + Close the connection to the database. + """ + self.connection.close() + + def get_daily_events(self, dt: datetime) -> list: + """ + Collect all events logged on the given date + + :param: dt: The date/time whose events are to be retrieved. + :type dt: :class:`datetime` + + :return: A list of dicts, each containing keys 'timestamp' and 'working'. + :rtype: list[dict[str, str]] + """ + # Convert the given date to a string for db lookup. Add '%' + # to the end to match all events on that date, no matter at what + # time they occured. + date = dt.date().isoformat() + "%" + sql = """ + SELECT timestamp, working + FROM event + WHERE timestamp LIKE ? + ORDER BY timestamp + ; + """ + rows = self.cursor.execute(sql, (date,)) + + # Convert the data returned into useful Python types, rather + # than simple str and int. + cleaned_rows = [] + for row in rows.fetchall(): + # Convert text timestamp to a datetime object. + cleaned_timestamp = datetime.fromisoformat(row["timestamp"]) + # Convert int to bool. + cleaned_working = True if row["working"] == 1 else False + cleaned_row = { + "timestamp": cleaned_timestamp, + "working": cleaned_working, + } + cleaned_rows.append(cleaned_row) + return cleaned_rows + + def get_weekly_events(self, dt: datetime) -> list: + """ + Collect all events logged on the given week + + :param dt: A date/time that falls within the week whose events + are to be retrieved. + :type dt: :class:`datetime` + + :return: a list of lists, possibly empty, one for each day of + the week. Non-empty elements contain dicts equivalent to + those returned by `.get_daily_events`. + :rtype: list[list] + """ + # Determine which day of the week was given (0-6). + weekday = dt.weekday() + # Use the above to calculate the date of the first day of the + # week. + start_of_week = dt - timedelta(days=weekday) + + DAYS_PER_WEEK = 7 + results = [] + for i in range(DAYS_PER_WEEK): + # Shift the day forward by `i` (starting at 0). + day_shift = timedelta(days=i) + daily_events = self.get_daily_events(start_of_week + day_shift) + results.append(daily_events) + return results + + def log_event(self, dt: datetime, working: bool) -> bool: + """ + Log a work event + + :param dt: The time at which the event is to be logged. + :type dt: :class:`datetime` + :param working: Whether work started (True) or finished (False) + at that time. + :type working: bool + + :return: Whether the event was successfully logged. + :rtype: bool + """ + # Convert the time to a string for the db. + time = dt.isoformat() + # Convert the bool to an int for the db. + working_code = 1 if working else 0 + sql = """ + INSERT INTO event + (timestamp, working) + VALUES + (?, ?); + """ + try: + self.cursor.execute(sql, (time, working_code)) + self.connection.commit() + except sqlite3.OperationalError: + self.connection.rollback() + return False + return True diff --git a/worktimer/core/util.py b/worktimer/core/util.py new file mode 100644 index 0000000..65a56a8 --- /dev/null +++ b/worktimer/core/util.py @@ -0,0 +1,49 @@ +""" +Application utility functions. +""" + +from datetime import datetime, timedelta + + +def get_app_dir() -> str: + r"""Returns the config folder for the application. The default behavior + is to return whatever is most appropriate for the operating system. + + To give you an idea, for an app called ``"Foo Bar"``, something like + the following folders could be returned: + + Mac OS X: + ``~/Library/Application Support/Foo Bar`` + Mac OS X (POSIX): + ``~/.foo-bar`` + Unix: + ``~/.config/foo-bar`` + Unix (POSIX): + ``~/.foo-bar`` + Windows (roaming): + ``C:\Users\\AppData\Roaming\Foo Bar`` + Windows (not roaming): + ``C:\Users\\AppData\Local\Foo Bar`` + """ + from click import get_app_dir + + return get_app_dir("WorkTimer", True, False) + + +def now() -> datetime: + """ + Get the current date and time + + The resulting `datetime` object is the same as returned by + :func:`datetime.now`, except that its microseconds are set to 0. This + slightly abbreviated form, and timedeltas resulting from it, can + be printed in a more visually pleasant way with less formatting + consideration in cases where microseconds are not so important. + + :return: The date and time at the moment the function is called. + :rtype: :class:`datetime` + """ + dt = datetime.now() + # Remove the microseconds. + dt -= timedelta(microseconds=dt.microsecond) + return dt From 081b457ee304f5b2d7457d61217948eeb4e9d601 Mon Sep 17 00:00:00 2001 From: truthless-dev Date: Fri, 4 Jul 2025 23:33:08 -0700 Subject: [PATCH 2/4] feat(view): add WorkTimer class as proxy between interface and database --- docs/api.rst | 10 ++ tests/core/test_util.py | 27 +++++ worktimer/core/util.py | 80 ++++++++++++- worktimer/core/worktimer.py | 218 ++++++++++++++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 worktimer/core/worktimer.py diff --git a/docs/api.rst b/docs/api.rst index 1760423..742b8e3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -7,3 +7,13 @@ This section documents the internal Python API. :members: :undoc-members: :show-inheritance: + +.. automodule:: worktimer.core.worktimer + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: worktimer.core.util + :members: + :undoc-members: + :show-inheritance: diff --git a/tests/core/test_util.py b/tests/core/test_util.py index 84e4366..8937f0a 100644 --- a/tests/core/test_util.py +++ b/tests/core/test_util.py @@ -1,3 +1,5 @@ +import pytest + from worktimer.core import util @@ -17,3 +19,28 @@ def setup_method(self): def test_no_microseconds(self): assert self.now.microsecond == 0 + + +@pytest.mark.parametrize( + "input,expected", + [ + (-1, "N/A"), + (0, "Monday"), + (1, "Tuesday"), + (2, "Wednesday"), + (3, "Thursday"), + (4, "Friday"), + (5, "Saturday"), + (6, "Sunday"), + (7, "N/A"), + ], +) +class TestWeekdayName: + + def test_full_name(self, input, expected): + result = util.weekday_name(input) + assert result == expected + + def test_abbreviation(self, input, expected): + result = util.weekday_name(input, True) + assert result == expected[:3] diff --git a/worktimer/core/util.py b/worktimer/core/util.py index 65a56a8..72cf619 100644 --- a/worktimer/core/util.py +++ b/worktimer/core/util.py @@ -2,7 +2,41 @@ Application utility functions. """ -from datetime import datetime, timedelta +from datetime import date, datetime, time, timedelta + + +def format_date(date: datetime | date) -> str: + """ + Format a date in a human-friendly way + + The exact output depends on the system locale. :meth:`datetime.strftime` + is used to do the formatting. + + :param date: The date to format. Note that, if a :class:`datetime` + is given, time attributes are ignored. + :type date: :class:`date` or :class:`datetime` + + :return: A human-friendly format of the date. + :rtype: str + """ + return date.strftime("%A, %d %B %Y") + + +def format_time(time: datetime | time) -> str: + """ + Format a time in a human-friendly way + + The exact output depends on the system locale. :class:`datetime.strftime` + is used to do the formatting. + + :param time: The date to format. Note that, if a :class:`datetime` + is given, date attributes are ignored. + :type time: :class:`time` or :class:`datetime` + + :return: A human-friendly format of the time. + :rtype: str + """ + return time.strftime("%I:%M%p") def get_app_dir() -> str: @@ -47,3 +81,47 @@ def now() -> datetime: # Remove the microseconds. dt -= timedelta(microseconds=dt.microsecond) return dt + + +def time_difference(start: datetime, stop: datetime) -> timedelta: + """ + Calculate the difference between two times + + :param start: The earlier moment. + :type start: :class:`datetime` + :param stop: The later moment. + :type stop: :class:`datetime` + + :return: The length of time from `start` to `stop`. + :rtype: :class:`timedelta` + """ + return stop - start + + +def weekday_name(weekday: int, abbreviate: bool = False) -> str: + """ + Convert a weekday (0-6) to a string (Mon-Sun). + + :param weekday: 0-6 representing the day of the week. Monday is 0. + :type weekday: int + :param Abbreviate: Whether to abbreviate the resulting name to + its first three letters (e.g., "Monday" becomes "Mon"). + :type abbreviate: bool + + :return: The name of the given weekday, or "N/A" if `weekday` is not + between 0 and 6 (inclusive). + :rtype: str + """ + days = ( + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", + "Sunday", + ) + if not (0 <= weekday < len(days)): + return "N/A" + name = days[weekday] + return name if not abbreviate else name[:3] diff --git a/worktimer/core/worktimer.py b/worktimer/core/worktimer.py new file mode 100644 index 0000000..cf9fc28 --- /dev/null +++ b/worktimer/core/worktimer.py @@ -0,0 +1,218 @@ +""" +WorkTimer class module +""" + +from copy import deepcopy +from datetime import datetime, timedelta + +from worktimer.core import util +from worktimer.core.database import Database + + +class WorkTimer: + """ + Representation of a timer that tracks working hours. + """ + + def __init__(self, db: Database) -> None: + """ + Initialize the timer. + + :param db: The Database object which will handle the storing + and retrieving of work events. + :type db: :class:`database` + """ + self.db = db + + def log_work_start(self) -> str: + """ + Start the work timer + + If the timer is already running, do nothing. + + :return: A user-friendly message indicating success or failure. + :rtype: str + """ + now = util.now() + events = self.db.get_daily_events(now) + + # Make sure that we don't start the clock if it is already + # running (i.e., if the latest event is a start event). + if len(events) > 0 and events[-1]["working"]: + return "You are already on the clock." + + # Now it's safe to try to log this event. + if self.db.log_event(now, True): + return "You are now on the clock." + return f"ERROR: Failed to log event ({now}, 1)." + + def log_work_end(self) -> str: + """ + Stop the work timer + + If the timer is already stopped, do nothing. + + :return: A user-friendly message indicating success or failure. + :rtype: str + """ + now = util.now() + events = self.db.get_daily_events(now) + + # Make sure that we don't stop the clock if it is already + # stopped (i.e., if the latest event is a stop event). + if len(events) == 0 or not events[-1]["working"]: + return "You are already off the clock." + + # Now it's safe to try to log the event. + if self.db.log_event(now, False): + return "You are no longer on the clock." + return f"ERROR: Failed to log event ({now}, 0)." + + def calculate_daily_blocks(self, events: list) -> tuple: + """ + Find all blocks of time spent at work in a given day + + :param events: A list of dicts containing keys "timestamp" and + "working", that represent start and stop work events for + the day. + :type events: list[dict[str, str]] + + :return: A tuple containing two objects. + * The first is a :class:`timedelta` representing the total + time worked in the given day. + * The second is a (possibly empty) list of tuples, each of + which represent a block of time spent at work in the + given day. Blocks are length three and contain the + block's start time (as a :class:`datetime`), its end + time (as a :class:`datetime`), and the length of the + block (as a :class:`timedelta`). + :rtype: tuple + """ + # Copy the list because we may need to add a temporary event of + # our own and do not want to modify the caller's version. + events = deepcopy(events) + + # If the number of events is not even, we know we have an + # incomplete time pair. In this case, we'll need to add a temp- + # orary stop event of our own. + if len(events) % 2 != 0: + temp_event = { + "timestamp": util.now(), + "working": False, + } + events.append(temp_event) + + event_count = len(events) + results = [] + total_time = timedelta() + # Iterate through the events, starting at the first (always a + # start event) and skipping every other (stop) event. + for i in range(0, event_count, 2): + # Save this event, which is always a start event. + start = events[i]["timestamp"] + # Save the next event, which is the corresponding stop. + stop = events[i + 1]["timestamp"] + # Calculate how much time was spent from `start` to `stop`. + duration = util.time_difference(start, stop) + # Add this duration to the total time worked for the day. + total_time += duration + block = (start, stop, duration) + results.append(block) + return total_time, results + + def calculate_weekly_blocks(self, weekly_events: list) -> tuple: + """ + Find all time spent at work in a given week + + :param weekly_events: a length-7 list of daily event lists, + representing all events for each day of the week. Each + element is equivalent to that which + `.calculate_daily_blocks` expects. + :type weekly_events: list + + :return: A tuple containing two objects. + * The total time spent at work in the given week (as a + :class:`timedelta`). + * a list of 7 :class:`timedelta` objects, representing the + total length of time at work each day of the week. + :rtype: tuple + """ + results = [] + total_time = timedelta() + for day in weekly_events: + daily_total, blocks = self.calculate_daily_blocks(day) + total_time += daily_total + results.append(daily_total) + return total_time, results + + def get_daily_time_worked(self, dt: datetime) -> str: + """ + Generate a list of time blocks spent working for a given day + + :param dt: The date/datetime of the day to view. + :type dt: :class:`datetime` + + :return: A human-friendly view of the time spent at work on that day. It + includes a header containing the full date; a list of + time pairs including start time, stop time, and duration; + and a footer which shows the total time spent at work. + :rtype: str + """ + results = [] + # Format the header, including the full date (e.g., "Monday, + # 19 May 2025"). + header = f"Time Worked on {util.format_date(dt)}\n" + results.append(header) + + events = self.db.get_daily_events(dt) + total_time, blocks = self.calculate_daily_blocks(events) + for block in blocks: + start, stop, duration = block + # Create human-friendly formats of start and stop times + # (e.g., "12:00PM". + start_time = util.format_time(start) + stop_time = util.format_time(stop) + # Create a human-friendly line detailing this block. + line = f"{start_time} - {stop_time}: {duration}" + results.append(line) + + # Add the total time worked for the entire day. + footer = f"\nTotal time worked: {total_time}" + results.append(footer) + + return "\n".join(results) + + def get_weekly_time_worked(self, dt: datetime) -> str: + """ + Generate a list of daily work hours for a given week + + :param dt: A date/datetime which falls within the week + to view. + :type dt: :class:`datetime` + + :return: A human-friendly view of the time spent at work in that week. It + includes a header containing the full date; a list of + all days including total time worked per day; and a footer + which shows the total time spent at work. + :rtype: str + """ + results = [] + # Format a header, including the full date (e.g., "Monday, + # 19 May 2025"). + header = f"Time worked through the Week of {util.format_date(dt)}\n" + results.append(header) + + events = self.db.get_weekly_events(dt) + total_time, blocks = self.calculate_weekly_blocks(events) + for i, daily_time in enumerate(blocks): + # Convert each day number (0-6) to the name of that day. + day_name = util.weekday_name(i, True) + # Format a human-friendly view of this day. + line = f"{day_name}: {daily_time}" + results.append(line) + + # Add a footer including the total time work that week. + footer = f"\nTotal time worked: {total_time}" + results.append(footer) + + return "\n".join(results) From 90a16040eceb9cbda90d55e698cabb2cee72a603 Mon Sep 17 00:00:00 2001 From: truthless-dev Date: Sat, 5 Jul 2025 02:11:26 -0700 Subject: [PATCH 3/4] feat(cli): add start, stop, day, and week cli commands --- worktimer/cli/cli.py | 12 +++++++++- worktimer/cli/commands/__init__.py | 1 + worktimer/cli/commands/day.py | 27 +++++++++++++++++++++ worktimer/cli/commands/start.py | 12 ++++++++++ worktimer/cli/commands/stop.py | 12 ++++++++++ worktimer/cli/commands/util.py | 38 ++++++++++++++++++++++++++++++ worktimer/cli/commands/week.py | 27 +++++++++++++++++++++ worktimer/core/util.py | 24 +++++++++++++++++++ 8 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 worktimer/cli/commands/__init__.py create mode 100644 worktimer/cli/commands/day.py create mode 100644 worktimer/cli/commands/start.py create mode 100644 worktimer/cli/commands/stop.py create mode 100644 worktimer/cli/commands/util.py create mode 100644 worktimer/cli/commands/week.py diff --git a/worktimer/cli/cli.py b/worktimer/cli/cli.py index dc7745b..6988783 100644 --- a/worktimer/cli/cli.py +++ b/worktimer/cli/cli.py @@ -1,5 +1,7 @@ import click +from worktimer.cli.commands import register_commands + @click.group() @click.version_option( @@ -8,4 +10,12 @@ ) def cli(): """WorkTimer: Simple tracker of time spent at work""" - pass + from worktimer.core import util + + if not util.make_app_dir(): + msg = f"ERROR: Failed to create app directory: `{util.get_app_dir()}`" + click.echo(msg) + raise click.Abort() + + +register_commands(cli) diff --git a/worktimer/cli/commands/__init__.py b/worktimer/cli/commands/__init__.py new file mode 100644 index 0000000..6531e5c --- /dev/null +++ b/worktimer/cli/commands/__init__.py @@ -0,0 +1 @@ +from .util import register_commands diff --git a/worktimer/cli/commands/day.py b/worktimer/cli/commands/day.py new file mode 100644 index 0000000..7b03df2 --- /dev/null +++ b/worktimer/cli/commands/day.py @@ -0,0 +1,27 @@ +from datetime import datetime + +import click + + +@click.command +@click.option( + "-d", + "--date", + help="The date to display, in YYYY-MM-DD format [default: today's date]", + default=lambda: datetime.now().isoformat(), +) +def day(date: str): + """Display detailed time worked on a given day""" + from .util import create_worktimer + + try: + dt = datetime.fromisoformat(date) + except ValueError: + error = click.BadParameter("Format must be YYYY-MM-DD") + error.param_hint = "date" + raise error + + wt = create_worktimer() + msg = wt.get_daily_time_worked(dt) + wt.db.close() + click.echo(msg) diff --git a/worktimer/cli/commands/start.py b/worktimer/cli/commands/start.py new file mode 100644 index 0000000..a6d1477 --- /dev/null +++ b/worktimer/cli/commands/start.py @@ -0,0 +1,12 @@ +import click + + +@click.command +def start(): + """Start the work timer""" + from .util import create_worktimer + + wt = create_worktimer() + msg = wt.log_work_start() + wt.db.close() + click.echo(msg) diff --git a/worktimer/cli/commands/stop.py b/worktimer/cli/commands/stop.py new file mode 100644 index 0000000..a9cc99d --- /dev/null +++ b/worktimer/cli/commands/stop.py @@ -0,0 +1,12 @@ +import click + + +@click.command +def stop(): + """Stop the work timer""" + from .util import create_worktimer + + wt = create_worktimer() + msg = wt.log_work_end() + wt.db.close() + click.echo(msg) diff --git a/worktimer/cli/commands/util.py b/worktimer/cli/commands/util.py new file mode 100644 index 0000000..057b693 --- /dev/null +++ b/worktimer/cli/commands/util.py @@ -0,0 +1,38 @@ +""" +Command utility functions +""" + + +def create_worktimer(): + """ + Create a ready-to-use WorkTimer + + :return: A WorkTimer with database and other dependencies already + set up. + :rtype: :class:`worktimer.core.worktimer.WorkTimer` + """ + from worktimer.core import const + from worktimer.core.database import Database + from worktimer.core.worktimer import WorkTimer + + db = Database(const.PATH_DB) + wt = WorkTimer(db) + return wt + + +def register_commands(cli): + """ + Register all commands with the top-level Click group + + :param cli: The top-level Click group to hold the commands. + :type cli: :class:`click.Group` + """ + from .day import day + from .start import start + from .stop import stop + from .week import week + + cli.add_command(start) + cli.add_command(stop) + cli.add_command(day) + cli.add_command(week) diff --git a/worktimer/cli/commands/week.py b/worktimer/cli/commands/week.py new file mode 100644 index 0000000..996199b --- /dev/null +++ b/worktimer/cli/commands/week.py @@ -0,0 +1,27 @@ +from datetime import datetime + +import click + + +@click.command +@click.option( + "-d", + "--date", + help="The date to display, in YYYY-MM-DD format [default: today's date]", + default=lambda: datetime.now().isoformat(), +) +def week(date: str): + """Display time worked on each day in a given week""" + from .util import create_worktimer + + try: + dt = datetime.fromisoformat(date) + except ValueError: + error = click.BadParameter("Format must be YYYY-MM-DD") + error.param_hint = "date" + raise error + + wt = create_worktimer() + msg = wt.get_weekly_time_worked(dt) + wt.db.close() + click.echo(msg) diff --git a/worktimer/core/util.py b/worktimer/core/util.py index 72cf619..acadbd4 100644 --- a/worktimer/core/util.py +++ b/worktimer/core/util.py @@ -64,6 +64,30 @@ def get_app_dir() -> str: return get_app_dir("WorkTimer", True, False) +def make_app_dir() -> bool: + """ + Ensure that the application data directory exists + + Attempt to create it if it does not. + + :return: Whether the directory now exists + :rtype: bool + """ + from pathlib import Path + + from worktimer.core import const + + directory = Path(const.PATH_APP) + if directory.exists(): + return True + try: + directory.mkdir(parents=True, exist_ok=True) + except OSError: + return False + directory.chmod(0o755) # drwxr-xr-x + return True + + def now() -> datetime: """ Get the current date and time From b772a43c0429cd6db95415ee92e62112dfd42de4 Mon Sep 17 00:00:00 2001 From: truthless-dev Date: Sat, 5 Jul 2025 19:15:23 -0700 Subject: [PATCH 4/4] refactor(db): refactor database connection logic Remove the need for users of `WorkTimer` objects to care about closing its db connection by moving connection and closing into the `WorkTimer` methods. Note that this is still very much imperfect, but from the user's prospective, it is an improvement. --- worktimer/cli/commands/day.py | 1 - worktimer/cli/commands/start.py | 1 - worktimer/cli/commands/stop.py | 1 - worktimer/cli/commands/week.py | 1 - worktimer/core/database.py | 17 +++++++++++------ worktimer/core/worktimer.py | 14 ++++++++++++++ 6 files changed, 25 insertions(+), 10 deletions(-) diff --git a/worktimer/cli/commands/day.py b/worktimer/cli/commands/day.py index 7b03df2..40aae41 100644 --- a/worktimer/cli/commands/day.py +++ b/worktimer/cli/commands/day.py @@ -23,5 +23,4 @@ def day(date: str): wt = create_worktimer() msg = wt.get_daily_time_worked(dt) - wt.db.close() click.echo(msg) diff --git a/worktimer/cli/commands/start.py b/worktimer/cli/commands/start.py index a6d1477..8fc957f 100644 --- a/worktimer/cli/commands/start.py +++ b/worktimer/cli/commands/start.py @@ -8,5 +8,4 @@ def start(): wt = create_worktimer() msg = wt.log_work_start() - wt.db.close() click.echo(msg) diff --git a/worktimer/cli/commands/stop.py b/worktimer/cli/commands/stop.py index a9cc99d..cf006b8 100644 --- a/worktimer/cli/commands/stop.py +++ b/worktimer/cli/commands/stop.py @@ -8,5 +8,4 @@ def stop(): wt = create_worktimer() msg = wt.log_work_end() - wt.db.close() click.echo(msg) diff --git a/worktimer/cli/commands/week.py b/worktimer/cli/commands/week.py index 996199b..14ac600 100644 --- a/worktimer/cli/commands/week.py +++ b/worktimer/cli/commands/week.py @@ -23,5 +23,4 @@ def week(date: str): wt = create_worktimer() msg = wt.get_weekly_time_worked(dt) - wt.db.close() click.echo(msg) diff --git a/worktimer/core/database.py b/worktimer/core/database.py index cea3241..10deb9a 100644 --- a/worktimer/core/database.py +++ b/worktimer/core/database.py @@ -21,15 +21,18 @@ def __init__(self, file_name: str) -> None: connect. :type file_name: str """ - self.connection = sqlite3.connect(file_name) - self.cursor = self.connection.cursor() - - # Return rows as dict-like objects. - self.cursor.row_factory = sqlite3.Row - + self.file_name = file_name # Do any necessary setup transparently. + self.connect() self._create_tables() self._ensure_time_pairs() + self.close() + + def connect(self): + self.connection = sqlite3.connect(self.file_name) + self.cursor = self.connection.cursor() + # Return rows as dict-like objects. + self.cursor.row_factory = sqlite3.Row def _create_tables(self) -> bool: """ @@ -167,6 +170,8 @@ def close(self) -> None: Close the connection to the database. """ self.connection.close() + self.connection = None + self.cursor = None def get_daily_events(self, dt: datetime) -> list: """ diff --git a/worktimer/core/worktimer.py b/worktimer/core/worktimer.py index cf9fc28..e84694b 100644 --- a/worktimer/core/worktimer.py +++ b/worktimer/core/worktimer.py @@ -34,16 +34,20 @@ def log_work_start(self) -> str: :rtype: str """ now = util.now() + self.db.connect() events = self.db.get_daily_events(now) # Make sure that we don't start the clock if it is already # running (i.e., if the latest event is a start event). if len(events) > 0 and events[-1]["working"]: + self.db.close() return "You are already on the clock." # Now it's safe to try to log this event. if self.db.log_event(now, True): + self.db.close() return "You are now on the clock." + self.db.close() return f"ERROR: Failed to log event ({now}, 1)." def log_work_end(self) -> str: @@ -56,16 +60,20 @@ def log_work_end(self) -> str: :rtype: str """ now = util.now() + self.db.connect() events = self.db.get_daily_events(now) # Make sure that we don't stop the clock if it is already # stopped (i.e., if the latest event is a stop event). if len(events) == 0 or not events[-1]["working"]: + self.db.close() return "You are already off the clock." # Now it's safe to try to log the event. if self.db.log_event(now, False): + self.db.close() return "You are no longer on the clock." + self.db.close() return f"ERROR: Failed to log event ({now}, 0)." def calculate_daily_blocks(self, events: list) -> tuple: @@ -164,7 +172,10 @@ def get_daily_time_worked(self, dt: datetime) -> str: header = f"Time Worked on {util.format_date(dt)}\n" results.append(header) + self.db.connect() events = self.db.get_daily_events(dt) + self.db.close() + total_time, blocks = self.calculate_daily_blocks(events) for block in blocks: start, stop, duration = block @@ -202,7 +213,10 @@ def get_weekly_time_worked(self, dt: datetime) -> str: header = f"Time worked through the Week of {util.format_date(dt)}\n" results.append(header) + self.db.connect() events = self.db.get_weekly_events(dt) + self.db.close() + total_time, blocks = self.calculate_weekly_blocks(events) for i, daily_time in enumerate(blocks): # Convert each day number (0-6) to the name of that day.