From 8c74f34a61046c1d07b682ce11ca9274f50863ed Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Wed, 29 Oct 2025 20:56:21 -0500 Subject: [PATCH 1/9] initial commit of project summary plugin --- utt/api/_v1/__init__.py | 1 + utt/components/report_model/model.py | 2 + utt/plugins/0_project_summary.py | 126 +++++++++++++++++++++++++ utt/report/details/view.py | 1 - utt/report/project_summary/__init__.py | 1 + utt/report/project_summary/model.py | 54 +++++++++++ utt/report/project_summary/view.py | 28 ++++++ 7 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 utt/plugins/0_project_summary.py create mode 100644 utt/report/project_summary/__init__.py create mode 100644 utt/report/project_summary/model.py create mode 100644 utt/report/project_summary/view.py diff --git a/utt/api/_v1/__init__.py b/utt/api/_v1/__init__.py index e43abab..dfa6cef 100644 --- a/utt/api/_v1/__init__.py +++ b/utt/api/_v1/__init__.py @@ -14,6 +14,7 @@ from ...report.activities.view import ActivitiesView from ...report.details.view import DetailsView from ...report.per_day.view import PerDayView +from ...report.project_summary.view import ProjectSummaryView from ...report.projects.view import ProjectsView from ...report.summary.view import SummaryView from ._private import register_command, register_component diff --git a/utt/components/report_model/model.py b/utt/components/report_model/model.py index 4911538..6ee5ecf 100644 --- a/utt/components/report_model/model.py +++ b/utt/components/report_model/model.py @@ -1,6 +1,7 @@ from ...report.activities.model import ActivitiesModel from ...report.details.model import DetailsModel from ...report.per_day.model import PerDayModel +from ...report.project_summary.model import ProjectSummaryModel from ...report.projects.model import ProjectsModel from ...report.summary.model import SummaryModel from ..activities import Activities @@ -17,6 +18,7 @@ def __init__(self, activities: Activities, args: ReportArgs, local_timezone: Loc self.args = args self.summary_model = SummaryModel(activities, args.range) self.projects_model = ProjectsModel(activities) + self.project_summary_model = ProjectSummaryModel(activities) self.per_day_model = PerDayModel(activities) self.activities_model = ActivitiesModel(activities) self.details_model = DetailsModel(activities, local_timezone) diff --git a/utt/plugins/0_project_summary.py b/utt/plugins/0_project_summary.py new file mode 100644 index 0000000..9c6a91d --- /dev/null +++ b/utt/plugins/0_project_summary.py @@ -0,0 +1,126 @@ +import argparse + +from ..api import _v1 + + +class ProjectSummaryHandler: + def __init__(self, report_model: _v1._private.ReportModel, output: _v1.Output): + self._report = report_model + self._output = output + + def __call__(self): + view = _v1.ProjectSummaryView(self._report.project_summary_model) + view.render(self._output) + + +def add_args(parser: argparse.ArgumentParser): + parser.add_argument("report_date", metavar="date", type=str, nargs="?") + + parser.add_argument( + "--current-activity", + default="-- Current Activity --", + type=str, + help="Set the current activity", + ) + + parser.add_argument( + "--no-current-activity", + action="store_true", + default=False, + help="Do not display the current activity", + ) + + parser.add_argument( + "--from", + default=None, + dest="from_date", + type=str, + help="Specify an inclusive start date to report.", + ) + + parser.add_argument( + "--to", + default=None, + dest="to_date", + type=str, + help=( + "Specify an inclusive end date to report. " + "If this is a day of the week, then it is the next occurrence " + "from the start date of the report, including the start date " + "itself." + ), + ) + + parser.add_argument( + "--project", + default=None, + type=str, + help="Show activities only for the specified project.", + ) + + parser.add_argument( + "--month", + default=None, + nargs="?", + const="this", + type=str, + help=( + "Specify a month. " + "Allowed formats include, '2019-10', 'Oct', 'this' 'prev'. " + "The report will start on the first day of the month and end " + "on the last. '--from' or '--to' if present will override " + "start and end, respectively. If the month is the current " + "month, 'today' will be the last day of the report." + ), + ) + + parser.add_argument( + "--week", + default=None, + nargs="?", + const="this", + type=str, + help=( + "Specify a week. " + "Allowed formats include, 'this' 'prev', or week number. " + "The report will start on the first day of the week (Monday) " + "and end on the last (Sunday). '--from' or '--to' if present " + "will override start and end, respectively. If the week is " + "the current week, 'today' will be the last day of the report." + ), + ) + + parser.add_argument( + "--csv-section", + choices=list(_v1._private.csv_section_name_to_csv_section.keys()), + default=None, + help="Instead of text output, print CSV of desired section", + ) + + parser.add_argument( + "--per-day", + action="store_true", + default=False, + help="Show total hours per day.", + ) + + parser.add_argument( + "--details", + action="store_true", + default=False, + help="Show details even for multi-day reports.", + ) + + parser.add_argument( + "--comments", + action="store_true", + default=False, + help="Show comments in details sections.", + ) + + +project_summary_command = _v1.Command( + "project-summary", "Show projects sorted by time spent", ProjectSummaryHandler, add_args +) + +_v1.register_command(project_summary_command) diff --git a/utt/report/details/view.py b/utt/report/details/view.py index 654c8a0..81f009d 100644 --- a/utt/report/details/view.py +++ b/utt/report/details/view.py @@ -1,5 +1,4 @@ import csv - from datetime import datetime from pytz.tzinfo import DstTzInfo diff --git a/utt/report/project_summary/__init__.py b/utt/report/project_summary/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/utt/report/project_summary/__init__.py @@ -0,0 +1 @@ + diff --git a/utt/report/project_summary/model.py b/utt/report/project_summary/model.py new file mode 100644 index 0000000..7554716 --- /dev/null +++ b/utt/report/project_summary/model.py @@ -0,0 +1,54 @@ +import itertools +from datetime import timedelta +from typing import Dict, List, Optional + +from ...data_structures.activity import Activity +from .. import formatter +from ..common import filter_activities_by_type + + +class ProjectSummaryModel: + def __init__(self, activities: List[Activity]): + work_activities = filter_activities_by_type(activities, Activity.Type.WORK) + self.projects = _groupby_project_sorted_by_duration(work_activities) + self.current_activity = _get_current_activity_info(activities) + self.total_duration = self._calculate_total_duration() + + def _calculate_total_duration(self) -> str: + total = sum((project["duration_obj"] for project in self.projects), timedelta()) + if self.current_activity: + total += self.current_activity["duration_obj"] + return formatter.format_duration(total) + + +def _get_current_activity_info(activities: List[Activity]) -> Optional[Dict]: + for activity in activities: + if activity.is_current_activity: + return { + "name": activity.name.name, + "duration": formatter.format_duration(activity.duration), + "duration_obj": activity.duration, + } + return None + + +def _groupby_project_sorted_by_duration(activities: List[Activity]) -> List[Dict]: + def key(act): + return act.name.project + + non_current_activities = [act for act in activities if not act.is_current_activity] + result = [] + sorted_activities = sorted(non_current_activities, key=key) + + for project, activities in itertools.groupby(sorted_activities, key): + activities = list(activities) + total_duration = sum((act.duration for act in activities), timedelta()) + result.append( + { + "duration": formatter.format_duration(total_duration), + "project": project, + "duration_obj": total_duration, + } + ) + + return sorted(result, key=lambda result: result["duration_obj"], reverse=True) diff --git a/utt/report/project_summary/view.py b/utt/report/project_summary/view.py new file mode 100644 index 0000000..14d5b62 --- /dev/null +++ b/utt/report/project_summary/view.py @@ -0,0 +1,28 @@ +from ...components.output import Output +from .. import formatter +from .model import ProjectSummaryModel + + +class ProjectSummaryView: + def __init__(self, model: ProjectSummaryModel): + self._model = model + + def render(self, output: Output) -> None: + print(file=output) + print(formatter.title("Project Summary"), file=output) + print(file=output) + + max_project_length = max((len(p["project"]) for p in self._model.projects), default=0) + + for project in self._model.projects: + print(f"{project['project']:<{max_project_length}}: {project['duration']}", file=output) + + if self._model.current_activity: + name = self._model.current_activity["name"] + duration = self._model.current_activity["duration"] + print(f"{name:<{max_project_length}}: {duration}", file=output) + + print(file=output) + print(f"{'Total':<{max_project_length}}: {self._model.total_duration}", file=output) + + print(file=output) From 09fd2cd5f789ca21eaf0c51dd84880e3464946ed Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Wed, 29 Oct 2025 21:37:15 -0500 Subject: [PATCH 2/9] add tests for project summary plugin --- test/integration/Makefile | 15 +- .../data/utt-project-summary.stdout | 10 + test/unit/report/test_project_summary.py | 191 ++++++++++++++++++ utt/report/project_summary/__init__.py | 1 - 4 files changed, 215 insertions(+), 2 deletions(-) create mode 100644 test/integration/data/utt-project-summary.stdout create mode 100644 test/unit/report/test_project_summary.py diff --git a/test/integration/Makefile b/test/integration/Makefile index b8c56e4..3911953 100644 --- a/test/integration/Makefile +++ b/test/integration/Makefile @@ -19,6 +19,7 @@ all: \ report-timezone-daylight-change \ report-hello-only-today \ report-project \ + report-project-summary \ report-per-day \ report-project-per-day \ report-project-per-day-csv \ @@ -56,7 +57,7 @@ completion: $(UTT) @echo ">> COMPLETION" register-python-argcomplete utt >> ~/.bashrc - bash -i -c 'diff <(COMP_LINE="utt" COMP_POINT=4 _python_argcomplete utt && echo $${COMPREPLY[@]} | tr " " "\n" | sort) <(echo -h --help --data --now --timezone --version add config edit hello report stretch | tr " " "\n" | sort)' + bash -i -c 'diff <(COMP_LINE="utt" COMP_POINT=4 _python_argcomplete utt && echo $${COMPREPLY[@]} | tr " " "\n" | sort) <(echo -h --help --data --now --timezone --version add config edit hello project-summary report stretch | tr " " "\n" | sort)' @echo "<< COMPLETION" @@ -255,6 +256,18 @@ report-project: $(UTT) @echo "<< REPORT-PROJECT" +.PHONY: report-project-summary +report-project-summary: $(UTT) + @echo + @echo ">> REPORT-PROJECT-SUMMARY" + + mkdir -p `dirname $(UTT_DATA_FILENAME)` + cp data/utt-1.log $(UTT_DATA_FILENAME) + bash -c 'diff -u <(utt --now "2014-3-19 18:30" project-summary 2014-3-19 --no-current-activity) data/utt-project-summary.stdout' + + @echo "<< REPORT-PROJECT-SUMMARY" + + .PHONY: report-per-day report-per-day: $(UTT) # NOTE: this is not an intended use of the `--per-day` switch. diff --git a/test/integration/data/utt-project-summary.stdout b/test/integration/data/utt-project-summary.stdout new file mode 100644 index 0000000..319820f --- /dev/null +++ b/test/integration/data/utt-project-summary.stdout @@ -0,0 +1,10 @@ + +------------------------------- Project Summary -------------------------------- + +asd : 3h15 + : 1h00 +qwer: 0h45 +A : 0h30 + +Total: 5h30 + diff --git a/test/unit/report/test_project_summary.py b/test/unit/report/test_project_summary.py new file mode 100644 index 0000000..6420fcf --- /dev/null +++ b/test/unit/report/test_project_summary.py @@ -0,0 +1,191 @@ +from datetime import datetime, timedelta +from io import StringIO + +import pytz + +from utt.data_structures.activity import Activity +from utt.report.project_summary.model import ProjectSummaryModel +from utt.report.project_summary.view import ProjectSummaryView + + +def create_activity(name, start_time, duration_minutes, is_current=False): + start = pytz.UTC.localize(start_time) + end = start + timedelta(minutes=duration_minutes) + return Activity(name, start, end, is_current) + + +def test_view_output_with_aligned_colons(): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), + create_activity("project3: task1", datetime(2024, 1, 1, 16, 0), 30), + create_activity("project4: task1", datetime(2024, 1, 1, 17, 0), 30), + create_activity("project5: task1", datetime(2024, 1, 1, 18, 0), 15), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "Project Summary" in lines[1] + assert "project1: 4h00" in lines[3] + assert "project2: 2h45" in lines[4] + assert "project3: 0h30" in lines[5] + assert "project4: 0h30" in lines[6] + assert "project5: 0h15" in lines[7] + assert "Total : 8h00" in lines[9] + + +def test_view_output_with_current_activity(): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), + create_activity("project3: task1", datetime(2024, 1, 1, 16, 0), 30), + create_activity("project4: task1", datetime(2024, 1, 1, 17, 0), 30), + create_activity("project5: task1", datetime(2024, 1, 1, 18, 0), 15), + create_activity("-- Current Activity --", datetime(2024, 1, 1, 19, 0), 220, is_current=True), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00" in lines[3] + assert "project2: 2h45" in lines[4] + assert "project3: 0h30" in lines[5] + assert "project4: 0h30" in lines[6] + assert "project5: 0h15" in lines[7] + assert "-- Current Activity --: 3h40" in lines[8] + assert "Total : 11h40" in lines[10] + + +def test_view_colons_aligned_with_varying_project_lengths(): + activities = [ + create_activity("a: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity("medium-name: task1", datetime(2024, 1, 1, 10, 0), 120), + create_activity("very-long-project-name: task1", datetime(2024, 1, 1, 12, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + colon_positions = [] + for line in lines[3:6]: + if ":" in line and "---" not in line: + colon_positions.append(line.index(":")) + + assert len(set(colon_positions)) == 1, "All colons should be at the same position" + + +def test_view_empty_activities(): + model = ProjectSummaryModel([]) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + assert "Project Summary" in result + assert "Total: 0h00" in result + + +def test_view_single_project(): + activities = [ + create_activity("backend: api work", datetime(2024, 1, 1, 9, 0), 180), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + assert "backend: 3h00" in result + assert "Total : 3h00" in result + + +def test_view_projects_without_names(): + activities = [ + create_activity("standalone task", datetime(2024, 1, 1, 9, 0), 60), + create_activity("another task", datetime(2024, 1, 1, 10, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert ": 1h30" in lines[3] + assert "Total: 1h30" in lines[5] + + +def test_view_sorting_by_duration(): + activities = [ + create_activity("alpha: task1", datetime(2024, 1, 1, 9, 0), 30), + create_activity("beta: task1", datetime(2024, 1, 1, 10, 0), 90), + create_activity("gamma: task1", datetime(2024, 1, 1, 12, 0), 60), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = [line for line in result.split("\n") if ":" in line and "Project Summary" not in line and "---" not in line] + project_lines = [line for line in lines if "Total" not in line] + + assert "beta" in project_lines[0] + assert "gamma" in project_lines[1] + assert "alpha" in project_lines[2] + + +def test_view_large_durations(): + activities = [ + create_activity("marathon: task1", datetime(2024, 1, 1, 9, 0), 1500), + create_activity("sprint: task1", datetime(2024, 1, 2, 10, 0), 600), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + assert "marathon: 25h00" in result + assert "sprint : 10h00" in result + assert "Total : 35h00" in result + + +def test_view_mixed_named_and_unnamed_projects(): + activities = [ + create_activity("asd: A-526", datetime(2024, 1, 1, 9, 0), 195), + create_activity("qwer: b-73", datetime(2024, 1, 1, 12, 15), 45), + create_activity("hard work", datetime(2024, 1, 1, 13, 0), 60), + create_activity("A: z-8", datetime(2024, 1, 1, 14, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "asd : 3h15" in lines[3] + assert " : 1h00" in lines[4] + assert "qwer: 0h45" in lines[5] + assert "A : 0h30" in lines[6] + assert "Total: 5h30" in lines[8] diff --git a/utt/report/project_summary/__init__.py b/utt/report/project_summary/__init__.py index 8b13789..e69de29 100644 --- a/utt/report/project_summary/__init__.py +++ b/utt/report/project_summary/__init__.py @@ -1 +0,0 @@ - From 2b324a2c19a27888a7408debe9b342a14f5209a0 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Wed, 29 Oct 2025 22:14:06 -0500 Subject: [PATCH 3/9] fix: remove unneeded arguments --- utt/plugins/0_project_summary.py | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/utt/plugins/0_project_summary.py b/utt/plugins/0_project_summary.py index 9c6a91d..7fee0ac 100644 --- a/utt/plugins/0_project_summary.py +++ b/utt/plugins/0_project_summary.py @@ -15,6 +15,7 @@ def __call__(self): def add_args(parser: argparse.ArgumentParser): parser.add_argument("report_date", metavar="date", type=str, nargs="?") + parser.set_defaults(csv_section=None, comments=False, details=False, per_day=False) parser.add_argument( "--current-activity", @@ -90,34 +91,6 @@ def add_args(parser: argparse.ArgumentParser): ), ) - parser.add_argument( - "--csv-section", - choices=list(_v1._private.csv_section_name_to_csv_section.keys()), - default=None, - help="Instead of text output, print CSV of desired section", - ) - - parser.add_argument( - "--per-day", - action="store_true", - default=False, - help="Show total hours per day.", - ) - - parser.add_argument( - "--details", - action="store_true", - default=False, - help="Show details even for multi-day reports.", - ) - - parser.add_argument( - "--comments", - action="store_true", - default=False, - help="Show comments in details sections.", - ) - project_summary_command = _v1.Command( "project-summary", "Show projects sorted by time spent", ProjectSummaryHandler, add_args From d32dc7f6d19c85e0ec26e7800ffab005d108956e Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 06:33:04 -0500 Subject: [PATCH 4/9] feat: add --show-perc --- utt/plugins/0_project_summary.py | 12 ++++++++++-- utt/report/project_summary/view.py | 27 ++++++++++++++++++++++----- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/utt/plugins/0_project_summary.py b/utt/plugins/0_project_summary.py index 7fee0ac..3cc5be9 100644 --- a/utt/plugins/0_project_summary.py +++ b/utt/plugins/0_project_summary.py @@ -4,12 +4,13 @@ class ProjectSummaryHandler: - def __init__(self, report_model: _v1._private.ReportModel, output: _v1.Output): + def __init__(self, args: argparse.Namespace,report_model: _v1._private.ReportModel, output: _v1.Output): + self._args = args self._report = report_model self._output = output def __call__(self): - view = _v1.ProjectSummaryView(self._report.project_summary_model) + view = _v1.ProjectSummaryView(self._report.project_summary_model, show_perc=self._args.show_perc) view.render(self._output) @@ -17,6 +18,13 @@ def add_args(parser: argparse.ArgumentParser): parser.add_argument("report_date", metavar="date", type=str, nargs="?") parser.set_defaults(csv_section=None, comments=False, details=False, per_day=False) + parser.add_argument( + "--show-perc", + action="store_true", + default=False, + help="Show percentage of total time for each project", + ) + parser.add_argument( "--current-activity", default="-- Current Activity --", diff --git a/utt/report/project_summary/view.py b/utt/report/project_summary/view.py index 14d5b62..798f72c 100644 --- a/utt/report/project_summary/view.py +++ b/utt/report/project_summary/view.py @@ -1,11 +1,14 @@ +from datetime import timedelta + from ...components.output import Output from .. import formatter from .model import ProjectSummaryModel class ProjectSummaryView: - def __init__(self, model: ProjectSummaryModel): + def __init__(self, model: ProjectSummaryModel, show_perc: bool = False): self._model = model + self._show_perc = show_perc def render(self, output: Output) -> None: print(file=output) @@ -13,16 +16,30 @@ def render(self, output: Output) -> None: print(file=output) max_project_length = max((len(p["project"]) for p in self._model.projects), default=0) + + total_seconds = sum((p["duration_obj"] for p in self._model.projects), timedelta()).total_seconds() + if self._model.current_activity: + total_seconds += self._model.current_activity["duration_obj"].total_seconds() for project in self._model.projects: - print(f"{project['project']:<{max_project_length}}: {project['duration']}", file=output) + duration_str = project['duration'] + if self._show_perc and total_seconds > 0: + perc = (project['duration_obj'].total_seconds() / total_seconds) * 100 + duration_str = f"{duration_str} ({perc:5.1f}%)" + print(f"{project['project']:<{max_project_length}}: {duration_str}", file=output) if self._model.current_activity: name = self._model.current_activity["name"] - duration = self._model.current_activity["duration"] - print(f"{name:<{max_project_length}}: {duration}", file=output) + duration_str = self._model.current_activity["duration"] + if self._show_perc and total_seconds > 0: + perc = (self._model.current_activity["duration_obj"].total_seconds() / total_seconds) * 100 + duration_str = f"{duration_str} ({perc:5.1f}%)" + print(f"{name:<{max_project_length}}: {duration_str}", file=output) print(file=output) - print(f"{'Total':<{max_project_length}}: {self._model.total_duration}", file=output) + total_str = self._model.total_duration + if self._show_perc: + total_str = f"{total_str} (100.0%)" + print(f"{'Total':<{max_project_length}}: {total_str}", file=output) print(file=output) From a62bb32cd7fc0f9678b3f1c12ddb00715a33390f Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 06:35:06 -0500 Subject: [PATCH 5/9] style: format with black and lint with flake8 --- utt/plugins/0_project_summary.py | 2 +- utt/report/project_summary/view.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/utt/plugins/0_project_summary.py b/utt/plugins/0_project_summary.py index 3cc5be9..1268af6 100644 --- a/utt/plugins/0_project_summary.py +++ b/utt/plugins/0_project_summary.py @@ -4,7 +4,7 @@ class ProjectSummaryHandler: - def __init__(self, args: argparse.Namespace,report_model: _v1._private.ReportModel, output: _v1.Output): + def __init__(self, args: argparse.Namespace, report_model: _v1._private.ReportModel, output: _v1.Output): self._args = args self._report = report_model self._output = output diff --git a/utt/report/project_summary/view.py b/utt/report/project_summary/view.py index 798f72c..04b061a 100644 --- a/utt/report/project_summary/view.py +++ b/utt/report/project_summary/view.py @@ -16,15 +16,15 @@ def render(self, output: Output) -> None: print(file=output) max_project_length = max((len(p["project"]) for p in self._model.projects), default=0) - + total_seconds = sum((p["duration_obj"] for p in self._model.projects), timedelta()).total_seconds() if self._model.current_activity: total_seconds += self._model.current_activity["duration_obj"].total_seconds() for project in self._model.projects: - duration_str = project['duration'] + duration_str = project["duration"] if self._show_perc and total_seconds > 0: - perc = (project['duration_obj'].total_seconds() / total_seconds) * 100 + perc = (project["duration_obj"].total_seconds() / total_seconds) * 100 duration_str = f"{duration_str} ({perc:5.1f}%)" print(f"{project['project']:<{max_project_length}}: {duration_str}", file=output) From a811b3563f5a2eb507f8fd3e1d5341ceba36c843 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 06:38:29 -0500 Subject: [PATCH 6/9] test: add unit test for --show-perc --- test/unit/report/test_project_summary.py | 61 +++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/test/unit/report/test_project_summary.py b/test/unit/report/test_project_summary.py index 6420fcf..0112851 100644 --- a/test/unit/report/test_project_summary.py +++ b/test/unit/report/test_project_summary.py @@ -179,13 +179,70 @@ def test_view_mixed_named_and_unnamed_projects(): model = ProjectSummaryModel(activities) view = ProjectSummaryView(model) output = StringIO() - + view.render(output) result = output.getvalue() - + lines = result.split("\n") assert "asd : 3h15" in lines[3] assert " : 1h00" in lines[4] assert "qwer: 0h45" in lines[5] assert "A : 0h30" in lines[6] assert "Total: 5h30" in lines[8] + + +def test_view_with_percentages(): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + create_activity("project3: task1", datetime(2024, 1, 1, 15, 0), 60), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=True) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00 ( 57.1%)" in lines[3] + assert "project2: 2h00 ( 28.6%)" in lines[4] + assert "project3: 1h00 ( 14.3%)" in lines[5] + assert "Total : 7h00 (100.0%)" in lines[7] + + +def test_view_with_percentages_and_current_activity(): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + create_activity("-- Current Activity --", datetime(2024, 1, 1, 15, 0), 60, is_current=True), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=True) + output = StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00 ( 57.1%)" in lines[3] + assert "project2: 2h00 ( 28.6%)" in lines[4] + assert "-- Current Activity --: 1h00 ( 14.3%)" in lines[5] + assert "Total : 7h00 (100.0%)" in lines[7] + + +def test_view_percentages_without_flag(): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=False) + output = StringIO() + + view.render(output) + result = output.getvalue() + + assert "%" not in result + assert "project1: 4h00" in result + assert "project2: 2h00" in result From 4b790aaba2ac438283322ca77a2d8167dd237c77 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 06:53:49 -0500 Subject: [PATCH 7/9] test: add integration test for --show-perc --- test/integration/Makefile | 13 +++++++++++++ .../data/utt-project-summary-perc.stdout | 10 ++++++++++ test/unit/report/test_project_summary.py | 16 ++++++++-------- 3 files changed, 31 insertions(+), 8 deletions(-) create mode 100644 test/integration/data/utt-project-summary-perc.stdout diff --git a/test/integration/Makefile b/test/integration/Makefile index 3911953..075f695 100644 --- a/test/integration/Makefile +++ b/test/integration/Makefile @@ -20,6 +20,7 @@ all: \ report-hello-only-today \ report-project \ report-project-summary \ + report-project-summary-perc \ report-per-day \ report-project-per-day \ report-project-per-day-csv \ @@ -268,6 +269,18 @@ report-project-summary: $(UTT) @echo "<< REPORT-PROJECT-SUMMARY" +.PHONY: report-project-summary-perc +report-project-summary-perc: $(UTT) + @echo + @echo ">> REPORT-PROJECT-SUMMARY-PERC" + + mkdir -p `dirname $(UTT_DATA_FILENAME)` + cp data/utt-1.log $(UTT_DATA_FILENAME) + bash -c 'diff -u <(utt --now "2014-3-19 18:30" project-summary 2014-3-19 --no-current-activity --show-perc) data/utt-project-summary-perc.stdout' + + @echo "<< REPORT-PROJECT-SUMMARY-PERC" + + .PHONY: report-per-day report-per-day: $(UTT) # NOTE: this is not an intended use of the `--per-day` switch. diff --git a/test/integration/data/utt-project-summary-perc.stdout b/test/integration/data/utt-project-summary-perc.stdout new file mode 100644 index 0000000..53375a9 --- /dev/null +++ b/test/integration/data/utt-project-summary-perc.stdout @@ -0,0 +1,10 @@ + +------------------------------- Project Summary -------------------------------- + +asd : 3h15 ( 59.1%) + : 1h00 ( 18.2%) +qwer: 0h45 ( 13.6%) +A : 0h30 ( 9.1%) + +Total: 5h30 (100.0%) + diff --git a/test/unit/report/test_project_summary.py b/test/unit/report/test_project_summary.py index 0112851..d99b649 100644 --- a/test/unit/report/test_project_summary.py +++ b/test/unit/report/test_project_summary.py @@ -179,10 +179,10 @@ def test_view_mixed_named_and_unnamed_projects(): model = ProjectSummaryModel(activities) view = ProjectSummaryView(model) output = StringIO() - + view.render(output) result = output.getvalue() - + lines = result.split("\n") assert "asd : 3h15" in lines[3] assert " : 1h00" in lines[4] @@ -200,10 +200,10 @@ def test_view_with_percentages(): model = ProjectSummaryModel(activities) view = ProjectSummaryView(model, show_perc=True) output = StringIO() - + view.render(output) result = output.getvalue() - + lines = result.split("\n") assert "project1: 4h00 ( 57.1%)" in lines[3] assert "project2: 2h00 ( 28.6%)" in lines[4] @@ -220,10 +220,10 @@ def test_view_with_percentages_and_current_activity(): model = ProjectSummaryModel(activities) view = ProjectSummaryView(model, show_perc=True) output = StringIO() - + view.render(output) result = output.getvalue() - + lines = result.split("\n") assert "project1: 4h00 ( 57.1%)" in lines[3] assert "project2: 2h00 ( 28.6%)" in lines[4] @@ -239,10 +239,10 @@ def test_view_percentages_without_flag(): model = ProjectSummaryModel(activities) view = ProjectSummaryView(model, show_perc=False) output = StringIO() - + view.render(output) result = output.getvalue() - + assert "%" not in result assert "project1: 4h00" in result assert "project2: 2h00" in result From e30a44f450665430748cd4f408793a4819984206 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 07:09:30 -0500 Subject: [PATCH 8/9] fix: alignment in project summary report --- utt/report/project_summary/view.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/utt/report/project_summary/view.py b/utt/report/project_summary/view.py index 04b061a..9275a58 100644 --- a/utt/report/project_summary/view.py +++ b/utt/report/project_summary/view.py @@ -21,11 +21,17 @@ def render(self, output: Output) -> None: if self._model.current_activity: total_seconds += self._model.current_activity["duration_obj"].total_seconds() + max_duration_length = 0 + if self._show_perc: + durations = [len(p["duration"]) for p in self._model.projects] + durations.append(len(self._model.total_duration)) + max_duration_length = max(durations, default=0) + for project in self._model.projects: duration_str = project["duration"] if self._show_perc and total_seconds > 0: perc = (project["duration_obj"].total_seconds() / total_seconds) * 100 - duration_str = f"{duration_str} ({perc:5.1f}%)" + duration_str = f"{duration_str:<{max_duration_length}} ({perc:5.1f}%)" print(f"{project['project']:<{max_project_length}}: {duration_str}", file=output) if self._model.current_activity: @@ -39,7 +45,7 @@ def render(self, output: Output) -> None: print(file=output) total_str = self._model.total_duration if self._show_perc: - total_str = f"{total_str} (100.0%)" + total_str = f"{total_str:<{max_duration_length}} (100.0%)" print(f"{'Total':<{max_project_length}}: {total_str}", file=output) print(file=output) From e95b3a608c71fd4865624ce56a1590608789c792 Mon Sep 17 00:00:00 2001 From: Logan Thomas Date: Thu, 30 Oct 2025 07:38:09 -0500 Subject: [PATCH 9/9] refactor: simplify project-summary command dependencies --- utt/api/_v1/__init__.py | 1 + utt/plugins/0_project_summary.py | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/utt/api/_v1/__init__.py b/utt/api/_v1/__init__.py index dfa6cef..77412a7 100644 --- a/utt/api/_v1/__init__.py +++ b/utt/api/_v1/__init__.py @@ -14,6 +14,7 @@ from ...report.activities.view import ActivitiesView from ...report.details.view import DetailsView from ...report.per_day.view import PerDayView +from ...report.project_summary.model import ProjectSummaryModel from ...report.project_summary.view import ProjectSummaryView from ...report.projects.view import ProjectsView from ...report.summary.view import SummaryView diff --git a/utt/plugins/0_project_summary.py b/utt/plugins/0_project_summary.py index 1268af6..48d1413 100644 --- a/utt/plugins/0_project_summary.py +++ b/utt/plugins/0_project_summary.py @@ -4,18 +4,27 @@ class ProjectSummaryHandler: - def __init__(self, args: argparse.Namespace, report_model: _v1._private.ReportModel, output: _v1.Output): + def __init__( + self, + args: argparse.Namespace, + filtered_activities: _v1.Activities, + output: _v1.Output, + ): self._args = args - self._report = report_model + self._activities = filtered_activities self._output = output def __call__(self): - view = _v1.ProjectSummaryView(self._report.project_summary_model, show_perc=self._args.show_perc) + model = _v1.ProjectSummaryModel(self._activities) + view = _v1.ProjectSummaryView(model, show_perc=self._args.show_perc) view.render(self._output) def add_args(parser: argparse.ArgumentParser): parser.add_argument("report_date", metavar="date", type=str, nargs="?") + + # Set defaults for report_args attributes that project-summary doesn't use + # but are required by the ReportArgs component parser.set_defaults(csv_section=None, comments=False, details=False, per_day=False) parser.add_argument(