diff --git a/README.md b/README.md index b33f05a..9c9fc38 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ or with `pipx`: ```bash pipx install trakcli ``` + ### Brew TBA @@ -67,15 +68,15 @@ Run `poetry build` and then pipx install ./dist/trakcli-x.x.x-py3-none-any.whl ``` -to install `trak` usign the wheel file. +to install `trak` using the wheel file. ## Usage -The package has the useful `--help` command that will explain all the commands. +The package has the useful `--help` command that explains all the commands. `trak --help` -The CLI will guide you through what you should and must do with messages with specific messages. +The CLI guides you through what you should and must do with specific messages. ### Basic commands @@ -90,7 +91,7 @@ trak stop trak status # Show the amount of hours spend on the project -trak report +trak report project ``` Start tracking a billable project: diff --git a/cli/pdm.lock b/cli/pdm.lock index c193e0f..8f5c6a9 100644 --- a/cli/pdm.lock +++ b/cli/pdm.lock @@ -4,12 +4,12 @@ [metadata] groups = ["default", "dev"] strategy = ["cross_platform"] -lock_version = "4.4" -content_hash = "sha256:f08abe9823b92a01d7d5b733359530e9182bcdbab8accf4503daba4901676b7b" +lock_version = "4.4.1" +content_hash = "sha256:4f1a58a437acd2fd30310b2defbe2c098f36aefaaf58d66efd23b1751170009b" [[package]] name = "black" -version = "23.11.0" +version = "23.12.1" requires_python = ">=3.8" summary = "The uncompromising code formatter." dependencies = [ @@ -20,12 +20,16 @@ dependencies = [ "platformdirs>=2", ] files = [ - {file = "black-23.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cf57719e581cfd48c4efe28543fea3d139c6b6f1238b3f0102a9c73992cbb479"}, - {file = "black-23.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:698c1e0d5c43354ec5d6f4d914d0d553a9ada56c85415700b81dc90125aac244"}, - {file = "black-23.11.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760415ccc20f9e8747084169110ef75d545f3b0932ee21368f63ac0fee86b221"}, - {file = "black-23.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:58e5f4d08a205b11800332920e285bd25e1a75c54953e05502052738fe16b3b5"}, - {file = "black-23.11.0-py3-none-any.whl", hash = "sha256:54caaa703227c6e0c87b76326d0862184729a69b73d3b7305b6288e1d830067e"}, - {file = "black-23.11.0.tar.gz", hash = "sha256:4c68855825ff432d197229846f971bc4d6666ce90492e5b02013bcaca4d9ab05"}, + {file = "black-23.12.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8d4df77958a622f9b5a4c96edb4b8c0034f8434032ab11077ec6c56ae9f384ba"}, + {file = "black-23.12.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:602cfb1196dc692424c70b6507593a2b29aac0547c1be9a1d1365f0d964c353b"}, + {file = "black-23.12.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c4352800f14be5b4864016882cdba10755bd50805c95f728011bcb47a4afd59"}, + {file = "black-23.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:0808494f2b2df923ffc5723ed3c7b096bd76341f6213989759287611e9837d50"}, + {file = "black-23.12.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:25e57fd232a6d6ff3f4478a6fd0580838e47c93c83eaf1ccc92d4faf27112c4e"}, + {file = "black-23.12.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d9e13db441c509a3763a7a3d9a49ccc1b4e974a47be4e08ade2a228876500ec"}, + {file = "black-23.12.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1bd9c210f8b109b1762ec9fd36592fdd528485aadb3f5849b2740ef17e674e"}, + {file = "black-23.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:ae76c22bde5cbb6bfd211ec343ded2163bba7883c7bc77f6b756a1049436fbb9"}, + {file = "black-23.12.1-py3-none-any.whl", hash = "sha256:78baad24af0f033958cad29731e27363183e140962595def56423e626f4bee3e"}, + {file = "black-23.12.1.tar.gz", hash = "sha256:4ce3ef14ebe8d9509188014d96af1c456a910d5b5cbf434a09fef7e024b3d0d5"}, ] [[package]] @@ -51,6 +55,16 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +requires_python = ">=3.7" +summary = "brain-dead simple config-ini parsing" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -96,22 +110,32 @@ files = [ [[package]] name = "pathspec" -version = "0.11.2" -requires_python = ">=3.7" +version = "0.12.1" +requires_python = ">=3.8" summary = "Utility library for gitignore style pattern matching of file paths." files = [ - {file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"}, - {file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"}, + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] [[package]] name = "platformdirs" -version = "4.0.0" -requires_python = ">=3.7" +version = "4.1.0" +requires_python = ">=3.8" summary = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." files = [ - {file = "platformdirs-4.0.0-py3-none-any.whl", hash = "sha256:118c954d7e949b35437270383a3f2531e99dd93cf7ce4dc8340d3356d30f173b"}, - {file = "platformdirs-4.0.0.tar.gz", hash = "sha256:cb633b2bcf10c51af60beb0ab06d2f1d69064b43abf4c185ca6b28865f3f9731"}, + {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, + {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, +] + +[[package]] +name = "pluggy" +version = "1.3.0" +requires_python = ">=3.8" +summary = "plugin and hook calling mechanisms for python" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, ] [[package]] @@ -124,6 +148,22 @@ files = [ {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] +[[package]] +name = "pytest" +version = "7.4.4" +requires_python = ">=3.7" +summary = "pytest: simple powerful testing with Python" +dependencies = [ + "colorama; sys_platform == \"win32\"", + "iniconfig", + "packaging", + "pluggy<2.0,>=0.12", +] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + [[package]] name = "rich" version = "13.7.0" @@ -140,27 +180,27 @@ files = [ [[package]] name = "ruff" -version = "0.1.6" +version = "0.1.11" requires_python = ">=3.7" summary = "An extremely fast Python linter and code formatter, written in Rust." files = [ - {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"}, - {file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"}, - {file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"}, - {file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"}, - {file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"}, - {file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"}, - {file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"}, - {file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"}, - {file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"}, - {file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"}, - {file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:a7f772696b4cdc0a3b2e527fc3c7ccc41cdcb98f5c80fdd4f2b8c50eb1458196"}, + {file = "ruff-0.1.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:934832f6ed9b34a7d5feea58972635c2039c7a3b434fe5ba2ce015064cb6e955"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea0d3e950e394c4b332bcdd112aa566010a9f9c95814844a7468325290aabfd9"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9bd4025b9c5b429a48280785a2b71d479798a69f5c2919e7d274c5f4b32c3607"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1ad00662305dcb1e987f5ec214d31f7d6a062cae3e74c1cbccef15afd96611d"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4b077ce83f47dd6bea1991af08b140e8b8339f0ba8cb9b7a484c30ebab18a23f"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4a88efecec23c37b11076fe676e15c6cdb1271a38f2b415e381e87fe4517f18"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b25093dad3b055667730a9b491129c42d45e11cdb7043b702e97125bcec48a1"}, + {file = "ruff-0.1.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:231d8fb11b2cc7c0366a326a66dafc6ad449d7fcdbc268497ee47e1334f66f77"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:09c415716884950080921dd6237767e52e227e397e2008e2bed410117679975b"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0f58948c6d212a6b8d41cd59e349751018797ce1727f961c2fa755ad6208ba45"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:190a566c8f766c37074d99640cd9ca3da11d8deae2deae7c9505e68a4a30f740"}, + {file = "ruff-0.1.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6464289bd67b2344d2a5d9158d5eb81025258f169e69a46b741b396ffb0cda95"}, + {file = "ruff-0.1.11-py3-none-win32.whl", hash = "sha256:9b8f397902f92bc2e70fb6bebfa2139008dc72ae5177e66c383fa5426cb0bf2c"}, + {file = "ruff-0.1.11-py3-none-win_amd64.whl", hash = "sha256:eb85ee287b11f901037a6683b2374bb0ec82928c5cbc984f575d0437979c521a"}, + {file = "ruff-0.1.11-py3-none-win_arm64.whl", hash = "sha256:97ce4d752f964ba559c7023a86e5f8e97f026d511e48013987623915431c7ea9"}, + {file = "ruff-0.1.11.tar.gz", hash = "sha256:f9d4d88cb6eeb4dfe20f9f0519bd2eaba8119bde87c3d5065c541dbae2b5a2cb"}, ] [[package]] @@ -206,10 +246,10 @@ files = [ [[package]] name = "typing-extensions" -version = "4.8.0" +version = "4.9.0" requires_python = ">=3.8" summary = "Backported and Experimental Type Hints for Python 3.8+" files = [ - {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, - {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, ] diff --git a/cli/pyproject.toml b/cli/pyproject.toml index 09dd7cc..26df5de 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -30,6 +30,35 @@ black = ">=23.9.1" ruff = "^0.0.292" +[tool.pdm] +[tool.pdm.dev-dependencies] +dev = ["black>=23.9.1", "ruff<1.0.0,>=0.0.292", "pytest>=7.4.4"] + +[tool.pdm.build] +includes = [] [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[project] +authors = [ + { name = "Luca Fedrizzi", email = "9001053+fedriz@users.noreply.github.com" }, +] +license = { text = "AGPL-3.0" } +requires-python = ">=3.11,<4.0" +dependencies = ["typer[all]>=0.9.0"] +name = "trakcli" +version = "0.0.4" +description = "Keep a record of the time you dedicate to your projects." +readme = "README.md" +keywords = ["cli", "timetraker", "time", "traker", "tool"] + +[project.urls] +"Trak on X" = "https://twitter.com/usetrakcom" +"Luca Fedrizzi's website" = "https://lucafedrizzi.com/" +"Luca Fedrizzi on X" = "https://twitter.com/lc_fd" +homepage = "https://usetrak.com/" +repository = "https://github.com/lcfd/trak" + +[project.scripts] +trak = "trakcli.main:app" diff --git a/cli/trakcli/create/commands/work.py b/cli/trakcli/create/commands/work.py index 818b895..9d08459 100644 --- a/cli/trakcli/create/commands/work.py +++ b/cli/trakcli/create/commands/work.py @@ -111,6 +111,7 @@ def create_work( to_date=to_date.strftime("%Y-%d-%m"), description=description, done=False, + paid=False, ) if works is not None: diff --git a/cli/trakcli/main.py b/cli/trakcli/main.py index 6afb49a..191e4b2 100644 --- a/cli/trakcli/main.py +++ b/cli/trakcli/main.py @@ -29,7 +29,7 @@ from trakcli.projects.commands import app as projects_app from trakcli.projects.database import get_projects_from_config from trakcli.projects.utils.print_missing_project import print_missing_project -from trakcli.report.commands.main import report +from trakcli.report import app as report_app from trakcli.utils.print_with_padding import print_with_padding from trakcli.works import app as works_app @@ -49,6 +49,9 @@ app.add_typer(projects_app, name="projects", help="Interact with your projects.") app.add_typer(create_app, name="create", help="Create something in trak.") app.add_typer(works_app, name="works", help="Interact with your works.") +app.add_typer(report_app, name="report", help="Get useful insights from your records.") + +# app.command()(report) @app.callback() @@ -97,9 +100,6 @@ def main( return -app.command()(report) - - @app.command(name="start", help="Start a trak session.") def start_tracker( project: str, @@ -249,7 +249,7 @@ def status( ) else: rprint( - Panel( + Panel.fit( title="๐Ÿ’ฌ Current status", renderable=print_with_padding( f"""Project: [bold]{current_session['project']}[/bold] @@ -266,11 +266,11 @@ def status( ) else: rprint( - Panel( + Panel.fit( title="๐Ÿ’ฌ No active session", renderable=print_with_padding( ( - "Ther aren't active sessions.\n\n" + "There is no ongoing session at the moment.\n\n" "Use the command: trak start to start a new session of work." ) ), diff --git a/cli/trakcli/report/__init__.py b/cli/trakcli/report/__init__.py index e69de29..3ba7952 100644 --- a/cli/trakcli/report/__init__.py +++ b/cli/trakcli/report/__init__.py @@ -0,0 +1,9 @@ +import typer + +from .commands.report_project import report_project + +app = typer.Typer() + + +app.command("project")(report_project) +app.command("projects")(report_project) diff --git a/cli/trakcli/report/commands/main.py b/cli/trakcli/report/commands/main.py deleted file mode 100644 index 371a758..0000000 --- a/cli/trakcli/report/commands/main.py +++ /dev/null @@ -1,154 +0,0 @@ -from datetime import datetime -from typing import Annotated, Optional - -import typer -from rich import print as rprint -from rich.table import Table - -from trakcli.database.basic import get_db_content -from trakcli.report.commands.main_functions import ( - create_details_table, - filter_records, - get_grouped_records, - get_table_title, -) - -ALL_PROJECTS = "all" - - -def report( - project: Annotated[str, typer.Argument()] = ALL_PROJECTS, - billable: Annotated[ - bool, - typer.Option( - "--billable", - "-b", - help="Consider only the billable records.", - ), - ] = False, - details: Annotated[ - bool, - typer.Option( - "--details", - "-d", - help="Show all sessions that occurred in the chosen period in detail.", - ), - ] = False, - today: Annotated[ - bool, - typer.Option( - "--today", - help="Consider only today's records.", - ), - ] = False, - yesterday: Annotated[ - bool, - typer.Option( - "--yesterday", - help="Consider only this month's records.", - ), - ] = False, - week: Annotated[ - bool, - typer.Option( - "--week", - help="Consider only this week's records.", - ), - ] = False, - month: Annotated[ - bool, - typer.Option( - "--month", - help="Consider only this month's records.", - ), - ] = False, - year: Annotated[ - bool, - typer.Option( - "--year", - help="Consider only this year's records.", - ), - ] = False, - start: Annotated[ - Optional[datetime], - typer.Option( - "--start", - help="Start date (e.g. 2023-10-08) for the time range. If --end is not provided, trak will report the data for the provided date.", - formats=["%Y-%m-%d"], - ), - ] = None, - end: Annotated[ - Optional[datetime], - typer.Option( - "--end", - help="End date (e.g. 2023-11-24) for the time range. Won't work without the start flag.", - formats=["%Y-%m-%d"], - ), - ] = None, -): - """Get reports for your projects.""" - - db_content = get_db_content() - - report_table_title = get_table_title( - today, yesterday, week, month, year, start, end - ) - - main_table = Table(title=report_table_title) - - main_table.add_column("๐Ÿท๏ธ Project", style="cyan", no_wrap=True) - main_table.add_column("๐Ÿงฎ Time spent", style="magenta") - - grouped = get_grouped_records(project, db_content, ALL_PROJECTS) - records = [] - details_tables = [] - total_acc_seconds = 0 - - for g in grouped: - records = filter_records( - grouped[g], billable, yesterday, today, week, month, start, end - ) - - acc_seconds = 0 - - for record in records: - record_start = record.get("start", "") - record_end = record.get("end", "") - - if record_start != "" and record_end != "": - start_datetime = datetime.fromisoformat(record_start) - end_datetime = datetime.fromisoformat(record_end) - - diff = end_datetime - start_datetime - - acc_seconds = acc_seconds + diff.seconds - - m, _ = divmod(diff.seconds, 60) - h, m = divmod(m, 60) - - total_acc_seconds += acc_seconds - m, _ = divmod(acc_seconds, 60) - h, m = divmod(m, 60) - - main_table.add_row(g, f"[bold]{h}h {m}m[/bold]") - - if details and len(records): - details_tables.append(create_details_table(g, records)) - - rprint("") - - # Add Total if all projects - if project == ALL_PROJECTS: - m, _ = divmod(total_acc_seconds, 60) - h, m = divmod(m, 60) - - main_table.add_section() - main_table.add_row("Total", f"[bold]{h}h {m}m[/bold]") - - # Print summary report table - rprint(main_table) - - # Print details - for details_table in details_tables: - rprint("") - rprint(details_table) diff --git a/cli/trakcli/report/commands/main_functions.py b/cli/trakcli/report/commands/main_functions.py deleted file mode 100644 index 8b0a83a..0000000 --- a/cli/trakcli/report/commands/main_functions.py +++ /dev/null @@ -1,139 +0,0 @@ -from datetime import datetime, timedelta - -from rich.table import Table - -from trakcli.utils.format_date import format_date -from trakcli.utils.same_week import same_week - - -def get_table_title(today, yesterday, week, month, year, start, end): - table_title = "Report" - - if today: - table_title += " for today" - elif yesterday: - table_title += " for yestarday" - elif week: - table_title += " for this week" - elif month: - table_title += " for this month" - elif year: - table_title += " for this year" - elif start and end == "": - table_title += f" for the day {start}" - elif start and end: - table_title += f" for the period from {start} to {end}" - - return table_title - - -def create_details_table(project, records): - details_table = Table(title=f"Sessions for {project}") - - details_table.add_column("Start", style="green", no_wrap=True) - details_table.add_column("End", style="orange3", no_wrap=True) - details_table.add_column("Category", style="steel_blue1") - details_table.add_column("Tag", style="steel_blue3") - details_table.add_column("Hours", style="yellow", no_wrap=True) - details_table.add_column("Billable") - - for record in records: - record_start = record.get("start", "") - record_end = record.get("end", "") or datetime.now().isoformat() - - h, m = 0, 0 - - if record_start != "": - start_datetime = datetime.fromisoformat(record_start) - end_datetime = datetime.fromisoformat(record_end) - - diff = end_datetime - start_datetime - - m, _ = divmod(diff.seconds, 60) - h, m = divmod(m, 60) - - details_table.add_row( - format_date(record["start"]), - format_date(record["end"]) if record["end"] != "" else "๐Ÿƒ Ongoing", - record["category"] or "---", - record["tag"] or "---", - f"{h}h {m}m" if record_start != "" else "", - "โœ…" if record["billable"] else "", - ) - - return details_table - - -def get_grouped_records(project, records, all): - grouped = {} - for record in records: - record_project = record.get("project", False) - - if record_project: - if record_project == project or project == all: - if isinstance(grouped.get(record_project, False), list): - grouped[record_project].append(record) - else: - grouped[record_project] = [record] - - return grouped - - -def filter_records(records, billable, yesterday, today, week, month, start, end): - actual_month = datetime.today().month - actual_year = datetime.today().year - - if billable: - records = [record for record in records if record["billable"] == billable] - - # Only one time filer type is allowed - if yesterday: - records = [ - record - for record in records - if record["end"] - and datetime.fromisoformat(record["end"]).date() - == datetime.today().date() - timedelta(1) - ] - elif today: - records = [ - record - for record in records - if record["end"] - and datetime.fromisoformat(record["end"]).date() == datetime.today().date() - ] - elif week: - records = [ - record - for record in records - if record["end"] - and same_week( - datetime.fromisoformat(record["end"]).date().strftime("%Y%m%d"), - ) - ] - elif month: - records = [ - record - for record in records - if record["start"] - and record["end"] - and datetime.fromisoformat(record["end"]).month == actual_month - and datetime.fromisoformat(record["end"]).year == actual_year - ] - elif start is not None and end is None: - records = [ - record - for record in records - if record.get("end", "") != "" - and datetime.fromisoformat(record["end"]).date() == start.date() - ] - elif start is not None and end is not None: - records = [ - record - for record in records - if record.get("end", "") != "" - and datetime.fromisoformat(record["end"]).date() >= start.date() - and datetime.fromisoformat(record["end"]).date() <= end.date() - ] - - return records diff --git a/cli/trakcli/report/commands/report_project.py b/cli/trakcli/report/commands/report_project.py new file mode 100644 index 0000000..ba0c2c1 --- /dev/null +++ b/cli/trakcli/report/commands/report_project.py @@ -0,0 +1,273 @@ +from datetime import datetime +from typing import Annotated, Optional + +import typer +from rich import print as rprint +from rich.progress import Progress +from rich.table import Table + +from trakcli.database.basic import get_db_content +from trakcli.report.functions.create_details_table import create_details_table +from trakcli.report.functions.filter_records import filter_records +from trakcli.report.functions.get_grouped_records import get_grouped_records +from trakcli.report.functions.get_table_title import get_table_title +from trakcli.works.database import get_project_works_from_config + +ALL_PROJECTS = "all" + + +def report_project( + project: Annotated[str, typer.Argument()] = ALL_PROJECTS, + billable: Annotated[ + bool, + typer.Option( + "--billable", + "-b", + help="Consider only the billable records.", + ), + ] = False, + works: Annotated[ + bool, + typer.Option( + "--works", + help="WORKS WORKS WORKS", + ), + ] = False, + details: Annotated[ + bool, + typer.Option( + "--details", + "-d", + help="Show all sessions that occurred in the chosen period in detail.", + ), + ] = False, + today: Annotated[ + bool, + typer.Option( + "--today", + help="Consider only today's records.", + ), + ] = False, + yesterday: Annotated[ + bool, + typer.Option( + "--yesterday", + "-y", + help="Consider only this month's records.", + ), + ] = False, + week: Annotated[ + bool, + typer.Option( + "--week", + "-w", + help="Consider only this week's records.", + ), + ] = False, + month: Annotated[ + bool, + typer.Option( + "--month", + "-m", + help="Consider only this month's records.", + ), + ] = False, + year: Annotated[ + bool, + typer.Option( + "--year", + "-y", + help="Consider only this year's records.", + ), + ] = False, + start: Annotated[ + Optional[datetime], + typer.Option( + "--start", + "-s", + help="Start date (e.g. 2023-10-08) for the time range. If --end is not provided, trak will report the data for the provided date.", + formats=["%Y-%m-%d"], + ), + ] = None, + end: Annotated[ + Optional[datetime], + typer.Option( + "--end", + "-e", + help="End date (e.g. 2023-11-24) for the time range. Won't work without the start flag.", + formats=["%Y-%m-%d"], + ), + ] = None, +): + """Get reports for your projects.""" + + db_content = get_db_content() + + report_table_title = get_table_title( + today, yesterday, week, month, year, start, end + ) + + main_table = Table(title=report_table_title) + + main_table.add_column("๐Ÿท๏ธ Project", style="cyan", no_wrap=True) + main_table.add_column("๐Ÿงฎ Time spent", style="magenta") + + grouped = get_grouped_records(project, db_content, ALL_PROJECTS) + + # + # Accumulators + # + + projects_data = [] + total_acc_seconds = 0 + + for g in grouped: + if works: + # If works is passed only billable records are considered. + # All the time filters are ignored since they already are in the work. + records = filter_records( + records=grouped[g], + billable=True, + yesterday=yesterday, + today=None, + week=None, + month=None, + start=None, + end=None, + ) + else: + records = filter_records( + grouped[g], billable, yesterday, today, week, month, start, end + ) + + acc_seconds = 0 + + for record in records: + record_start = record.get("start", "") + record_end = record.get("end", "") + + if record_start != "" and record_end != "": + start_datetime = datetime.fromisoformat(record_start) + end_datetime = datetime.fromisoformat(record_end) + + diff = end_datetime - start_datetime + + acc_seconds = acc_seconds + diff.seconds + + m, _ = divmod(diff.seconds, 60) + h, m = divmod(m, 60) + + total_acc_seconds += acc_seconds + m, _ = divmod(acc_seconds, 60) + h, m = divmod(m, 60) + + main_table.add_row(g, f"[bold]{h}h {m}m[/bold]") + + project_data = {"project": g, "details": None, "works": [], "records": records} + + if len(records): + if details: + project_data["details"] = create_details_table(g, records) + + if works: + project_works = get_project_works_from_config(g) + if project_works is not None: + for work in project_works: + if work.get("done") is not True: + project_data["works"].append(work) + + projects_data.append(project_data) + + rprint("") + + # Add Total if all projects + if project == ALL_PROJECTS: + m, _ = divmod(total_acc_seconds, 60) + h, m = divmod(m, 60) + + main_table.add_section() + main_table.add_row("Total", f"[bold]{h}h {m}m[/bold]") + + # Print summary report table + rprint(main_table) + + # Print detailed data + for data in projects_data: + if data["details"] is not None: + rprint("") + rprint(data["details"]) + + project_works = data["works"] + if project_works is not None and works is True: + if len(project_works): + for pw in project_works: + start_date_string = pw.get("from_date") + end_date_string = pw.get("to_date") + start_date = datetime.strptime(start_date_string, "%Y-%m-%dT%H:%M") + end_date = datetime.strptime(end_date_string, "%Y-%m-%dT%H:%M") + + # Print the data for a work + filtered_records = filter_records( + records=data.get("records"), start=start_date, end=end_date + ) + + acc_seconds = 0 + + work_time = pw.get("time", None) + + for record in filtered_records: + record_start = record.get("start", "") + record_end = record.get("end", "") + + if record_start != "" and record_end != "": + start_datetime = datetime.fromisoformat(record_start) + end_datetime = datetime.fromisoformat(record_end) + + diff = end_datetime - start_datetime + + acc_seconds = acc_seconds + diff.seconds + + m, _ = divmod(diff.seconds, 60) + h, m = divmod(m, 60) + + m, _ = divmod(acc_seconds, 60) + h, m = divmod(m, 60) + + rprint("") + rprint("โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + rprint(f"โ”ƒ [blue]Work {pw['id']}") + rprint(f"โ”ƒ [green]{pw['name']}") + rprint("โ”ƒ ---") + rprint(f"โ”ƒ start: {start_date}, end: {end_date}") + rprint(f"โ”ƒ project: {data['project']}") + rprint("โ”กโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") + rprint("โ”‚ ") + with Progress() as progress: + rprint("โ”‚ [blue]Used time budget:") + rprint(f"โ”‚ Total: {pw['time']} hours") + rprint(f"โ”‚ Used: {h} hours {m} minutes ") + task = progress.add_task("", total=work_time * 3600) + progress.update(task, advance=acc_seconds) + rprint("โ”‚ ") + with Progress() as progress: + rprint("โ”‚ [blue]Closeness to the deadline:") + start = datetime.strptime(pw.get("from_date"), "%Y-%m-%dT%H:%M") + end = datetime.strptime(pw.get("to_date"), "%Y-%m-%dT%H:%M") + work_duration_days = (end - start).days + today_to_deadline_days = (end - datetime.today()).days + today_from_start_days = (datetime.today() - start).days + rprint(f"โ”‚ Total: {work_duration_days} days") + rprint(f"โ”‚ Remaining: {today_to_deadline_days} days") + task = progress.add_task("", total=work_duration_days) + progress.update(task, advance=today_from_start_days) + rprint("โ”‚ ") + rprint("โ”‚ [blue]Workable hours (8h/day) until deadline:") + today_to_deadline_days = (end - datetime.today()).days + rprint( + f"โ”‚ {(today_to_deadline_days *24) / 8} hours in {today_to_deadline_days} days" + ) + rprint("โ”‚ ") + rprint("โ”‚ [blue]Value of your work so far:") + rprint(f"โ”‚ {pw['rate']*h}โ‚ฌ") + rprint("โ”‚ ") + rprint("โ””โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”") diff --git a/cli/trakcli/report/functions/create_details_table.py b/cli/trakcli/report/functions/create_details_table.py new file mode 100644 index 0000000..4d359dd --- /dev/null +++ b/cli/trakcli/report/functions/create_details_table.py @@ -0,0 +1,42 @@ +from datetime import datetime, timedelta + +from rich.table import Table + +from trakcli.utils.format_date import format_date + + +def create_details_table(project, records): + details_table = Table(title=f"Sessions for {project}") + + details_table.add_column("Start", style="green", no_wrap=True) + details_table.add_column("End", style="orange3", no_wrap=True) + details_table.add_column("Category", style="steel_blue1") + details_table.add_column("Tag", style="steel_blue3") + details_table.add_column("Hours", style="yellow", no_wrap=True) + details_table.add_column("Billable") + + for record in records: + record_start = record.get("start", "") + record_end = record.get("end", "") or datetime.now().isoformat() + + h, m = 0, 0 + + if record_start != "": + start_datetime = datetime.fromisoformat(record_start) + end_datetime = datetime.fromisoformat(record_end) + + diff = end_datetime - start_datetime + + m, _ = divmod(diff.seconds, 60) + h, m = divmod(m, 60) + + details_table.add_row( + format_date(record["start"]), + format_date(record["end"]) if record["end"] != "" else "๐Ÿƒ Ongoing", + record["category"] or "---", + record["tag"] or "---", + f"{h}h {m}m" if record_start != "" else "", + "โœ…" if record["billable"] else "", + ) + + return details_table diff --git a/cli/trakcli/report/functions/filter_records.py b/cli/trakcli/report/functions/filter_records.py new file mode 100644 index 0000000..c2b2716 --- /dev/null +++ b/cli/trakcli/report/functions/filter_records.py @@ -0,0 +1,72 @@ +from datetime import datetime, timedelta + +from trakcli.utils.same_week import same_week + + +def filter_records( + records, + billable=None, + yesterday=None, + today=None, + week=None, + month=None, + start=None, + end=None, +): + actual_month = datetime.today().month + actual_year = datetime.today().year + + if billable: + records = [record for record in records if record["billable"] == billable] + + # Only one time filer type is allowed + if yesterday: + records = [ + record + for record in records + if record["end"] + and datetime.fromisoformat(record["end"]).date() + == datetime.today().date() - timedelta(1) + ] + elif today: + records = [ + record + for record in records + if record["end"] + and datetime.fromisoformat(record["end"]).date() == datetime.today().date() + ] + elif week: + records = [ + record + for record in records + if record["end"] + and same_week( + datetime.fromisoformat(record["end"]).date().strftime("%Y%m%d"), + ) + ] + elif month: + records = [ + record + for record in records + if record["start"] + and record["end"] + and datetime.fromisoformat(record["end"]).month == actual_month + and datetime.fromisoformat(record["end"]).year == actual_year + ] + elif start is not None and end is None: + records = [ + record + for record in records + if record.get("end", "") != "" + and datetime.fromisoformat(record["end"]).date() == start.date() + ] + elif start is not None and end is not None: + records = [ + record + for record in records + if record.get("end", "") != "" + and datetime.fromisoformat(record["end"]).date() >= start.date() + and datetime.fromisoformat(record["end"]).date() <= end.date() + ] + + return records diff --git a/cli/trakcli/report/functions/get_grouped_records.py b/cli/trakcli/report/functions/get_grouped_records.py new file mode 100644 index 0000000..aa7ea9e --- /dev/null +++ b/cli/trakcli/report/functions/get_grouped_records.py @@ -0,0 +1,15 @@ +def get_grouped_records(project, records, all): + """Get a list of records and group them by project name.""" + + grouped = {} + for record in records: + record_project = record.get("project", False) + + if record_project: + if record_project == project or project == all: + if isinstance(grouped.get(record_project, False), list): + grouped[record_project].append(record) + else: + grouped[record_project] = [record] + + return grouped diff --git a/cli/trakcli/report/functions/get_table_title.py b/cli/trakcli/report/functions/get_table_title.py new file mode 100644 index 0000000..8c8fc65 --- /dev/null +++ b/cli/trakcli/report/functions/get_table_title.py @@ -0,0 +1,30 @@ +from datetime import datetime + + +def get_table_title( + today: bool | None = None, + yesterday: bool | None = None, + week: bool | None = None, + month: bool | None = None, + year: bool | None = None, + start: datetime | None = None, + end: datetime | None = None, +): + table_title = "Report" + + if today: + table_title += " for today" + elif yesterday: + table_title += " for yestarday" + elif week: + table_title += " for this week" + elif month: + table_title += " for this month" + elif year: + table_title += " for this year" + elif start and end == "": + table_title += f" for the day {start}" + elif start and end: + table_title += f" for the period from {start} to {end}" + + return table_title diff --git a/cli/trakcli/report/functions/test_functions.py b/cli/trakcli/report/functions/test_functions.py new file mode 100644 index 0000000..091783a --- /dev/null +++ b/cli/trakcli/report/functions/test_functions.py @@ -0,0 +1,20 @@ +from datetime import datetime +from trakcli.database.models import Record +from trakcli.report.functions.get_grouped_records import get_grouped_records +from trakcli.report.functions.get_table_title import get_table_title + +record = Record( + project="test", start=datetime.now().isoformat(), end=datetime.now().isoformat() +) + +fake_records = [record._asdict() for _ in range(4)] + + +def test_get_grouped_records__base(): + test_projects = get_grouped_records(project="test", records=fake_records, all=False) + + assert len(test_projects["test"]) == 4 + + +def test_get_table_title__base(): + assert get_table_title(today=True) == "Report for today" diff --git a/cli/trakcli/works/__init__.py b/cli/trakcli/works/__init__.py index 2c63bf3..2f7fce2 100644 --- a/cli/trakcli/works/__init__.py +++ b/cli/trakcli/works/__init__.py @@ -3,6 +3,7 @@ from trakcli.works.commands.delete import delete_work from trakcli.works.commands.done import done_work from trakcli.works.commands.list import list_works +from trakcli.works.commands.paid import paid_work app = typer.Typer() @@ -10,3 +11,4 @@ app.command("list")(list_works) app.command("delete")(delete_work) app.command("done")(done_work) +app.command("paid")(paid_work) diff --git a/cli/trakcli/works/commands/list.py b/cli/trakcli/works/commands/list.py index 708eef7..9d1c08a 100644 --- a/cli/trakcli/works/commands/list.py +++ b/cli/trakcli/works/commands/list.py @@ -24,6 +24,8 @@ def print_project_works(works, project_id): works_table.add_column("Rate") works_table.add_column("From") works_table.add_column("To") + works_table.add_column("Done") + works_table.add_column("Paid") if works is not None and len(works): for w in works: @@ -56,6 +58,8 @@ def print_project_works(works, project_id): f"{rate}", from_date, to_date, + "โœ…" if w.get("done", False) else "๐Ÿƒ", + "โœ…" if w.get("paid", False) else "โŒ", ) rprint("") diff --git a/cli/trakcli/works/commands/paid.py b/cli/trakcli/works/commands/paid.py new file mode 100644 index 0000000..2d498be --- /dev/null +++ b/cli/trakcli/works/commands/paid.py @@ -0,0 +1,73 @@ +from typing import Annotated + +import typer +from rich import print as rprint +from rich.panel import Panel +from rich.prompt import Confirm + +from trakcli.projects.database import get_projects_from_config +from trakcli.projects.utils.print_missing_project import print_missing_project +from trakcli.utils.print_with_padding import print_with_padding +from trakcli.works.database import ( + get_project_works_from_config, + set_project_works_in_config, +) + + +def paid_work( + work_id: Annotated[str, typer.Argument()], + project_id: Annotated[ + str, + typer.Option( + "--in", "--of", "-p", help="The project's id in which the work is located." + ), + ], +): + """Mark a work of a project as paid.""" + + projects = get_projects_from_config() + + if project_id in projects: + confirm_done = Confirm.ask( + f"Are you sure you want to mark the [green]{work_id}[/green] work from [green]{project_id}[/green] project as paid?", + default=False, + ) + if not confirm_done: + rprint("") + rprint("[yellow]Not marked as paid.[/yellow]") + raise typer.Abort() + + works = get_project_works_from_config(project_id) + if works is not None: + works_ids = [w["id"] for w in works] + if work_id in works_ids: + filtered_works = [ + {**w, "paid": True} if w["id"] == work_id else w for w in works + ] + + set_project_works_in_config(project_id, filtered_works) + + rprint("") + rprint( + Panel.fit( + title="[green]Success[/green]", + renderable=f"Work {work_id} successfully from {project_id} project marked as paid.", + ) + ) + else: + rprint("") + rprint( + Panel.fit( + title="[red]The work doesn't exist[/red]", + renderable=print_with_padding( + ( + "You can create a new work with the command:\n" + "trak create work -p -n -t --from 2024-01-01 --to 2024-02-01" + ) + ), + ) + ) + + return + else: + print_missing_project(projects) diff --git a/cli/trakcli/works/models.py b/cli/trakcli/works/models.py index b2d9a32..acc71ff 100644 --- a/cli/trakcli/works/models.py +++ b/cli/trakcli/works/models.py @@ -10,3 +10,4 @@ class Work(NamedTuple): to_date: str description: str = "" done: bool = False + paid: bool = False