diff --git a/test/integration/Makefile b/test/integration/Makefile index b8c56e4..075f695 100644 --- a/test/integration/Makefile +++ b/test/integration/Makefile @@ -19,6 +19,8 @@ all: \ report-timezone-daylight-change \ 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 \ @@ -56,7 +58,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 +257,30 @@ 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-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/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..d99b649 --- /dev/null +++ b/test/unit/report/test_project_summary.py @@ -0,0 +1,248 @@ +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] + + +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 diff --git a/utt/api/_v1/__init__.py b/utt/api/_v1/__init__.py index e43abab..77412a7 100644 --- a/utt/api/_v1/__init__.py +++ b/utt/api/_v1/__init__.py @@ -14,6 +14,8 @@ 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 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..48d1413 --- /dev/null +++ b/utt/plugins/0_project_summary.py @@ -0,0 +1,116 @@ +import argparse + +from ..api import _v1 + + +class ProjectSummaryHandler: + def __init__( + self, + args: argparse.Namespace, + filtered_activities: _v1.Activities, + output: _v1.Output, + ): + self._args = args + self._activities = filtered_activities + self._output = output + + def __call__(self): + 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( + "--show-perc", + action="store_true", + default=False, + help="Show percentage of total time for each project", + ) + + 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." + ), + ) + + +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..e69de29 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..9275a58 --- /dev/null +++ b/utt/report/project_summary/view.py @@ -0,0 +1,51 @@ +from datetime import timedelta + +from ...components.output import Output +from .. import formatter +from .model import ProjectSummaryModel + + +class ProjectSummaryView: + 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) + 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) + + 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() + + 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:<{max_duration_length}} ({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_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) + total_str = self._model.total_duration + if self._show_perc: + total_str = f"{total_str:<{max_duration_length}} (100.0%)" + print(f"{'Total':<{max_project_length}}: {total_str}", file=output) + + print(file=output)